diff --git a/packages/guardian/src/api.ts b/packages/guardian/src/api.ts index fc96700..60ee69b 100644 --- a/packages/guardian/src/api.ts +++ b/packages/guardian/src/api.ts @@ -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) diff --git a/packages/guardian/src/application/use-cases/AnalyzeProject.ts b/packages/guardian/src/application/use-cases/AnalyzeProject.ts index ef4d81a..0cfe930 100644 --- a/packages/guardian/src/application/use-cases/AnalyzeProject.ts +++ b/packages/guardian/src/application/use-cases/AnalyzeProject.ts @@ -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() } diff --git a/packages/guardian/src/application/use-cases/pipeline/ExecuteDetection.ts b/packages/guardian/src/application/use-cases/pipeline/ExecuteDetection.ts index 1d5742e..f51c37b 100644 --- a/packages/guardian/src/application/use-cases/pipeline/ExecuteDetection.ts +++ b/packages/guardian/src/application/use-cases/pipeline/ExecuteDetection.ts @@ -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 { @@ -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 } diff --git a/packages/guardian/src/domain/services/IDuplicateValueTracker.ts b/packages/guardian/src/domain/services/IDuplicateValueTracker.ts new file mode 100644 index 0000000..ec6055b --- /dev/null +++ b/packages/guardian/src/domain/services/IDuplicateValueTracker.ts @@ -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 +} diff --git a/packages/guardian/src/domain/value-objects/HardcodedValue.ts b/packages/guardian/src/domain/value-objects/HardcodedValue.ts index 725a362..6f42eab 100644 --- a/packages/guardian/src/domain/value-objects/HardcodedValue.ts +++ b/packages/guardian/src/domain/value-objects/HardcodedValue.ts @@ -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 { } 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 { 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 { 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 { } 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 { 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)) + } } diff --git a/packages/guardian/src/infrastructure/analyzers/AnemicModelDetector.ts b/packages/guardian/src/infrastructure/analyzers/AnemicModelDetector.ts index c2e920a..68b5ab3 100644 --- a/packages/guardian/src/infrastructure/analyzers/AnemicModelDetector.ts +++ b/packages/guardian/src/infrastructure/analyzers/AnemicModelDetector.ts @@ -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, ) } diff --git a/packages/guardian/src/infrastructure/analyzers/AstTreeTraverser.ts b/packages/guardian/src/infrastructure/analyzers/AstTreeTraverser.ts new file mode 100644 index 0000000..f482bfa --- /dev/null +++ b/packages/guardian/src/infrastructure/analyzers/AstTreeTraverser.ts @@ -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() + + 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() + } + } +} diff --git a/packages/guardian/src/infrastructure/analyzers/DuplicateValueTracker.ts b/packages/guardian/src/infrastructure/analyzers/DuplicateValueTracker.ts new file mode 100644 index 0000000..5b8d238 --- /dev/null +++ b/packages/guardian/src/infrastructure/analyzers/DuplicateValueTracker.ts @@ -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() + + /** + * 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() + } +} diff --git a/packages/guardian/src/infrastructure/analyzers/HardcodeDetector.ts b/packages/guardian/src/infrastructure/analyzers/HardcodeDetector.ts index 8b0383b..c2517c0 100644 --- a/packages/guardian/src/infrastructure/analyzers/HardcodeDetector.ts +++ b/packages/guardian/src/infrastructure/analyzers/HardcodeDetector.ts @@ -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) } } diff --git a/packages/guardian/src/infrastructure/analyzers/SecretDetector.ts b/packages/guardian/src/infrastructure/analyzers/SecretDetector.ts index 1e1c601..1f1f0c9 100644 --- a/packages/guardian/src/infrastructure/analyzers/SecretDetector.ts +++ b/packages/guardian/src/infrastructure/analyzers/SecretDetector.ts @@ -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, }, ], } diff --git a/packages/guardian/src/infrastructure/strategies/AstBooleanAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/AstBooleanAnalyzer.ts new file mode 100644 index 0000000..e365539 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/AstBooleanAnalyzer.ts @@ -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, + ) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/AstConfigObjectAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/AstConfigObjectAnalyzer.ts new file mode 100644 index 0000000..615f916 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/AstConfigObjectAnalyzer.ts @@ -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)}...` + } +} diff --git a/packages/guardian/src/infrastructure/strategies/AstContextChecker.ts b/packages/guardian/src/infrastructure/strategies/AstContextChecker.ts new file mode 100644 index 0000000..c226731 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/AstContextChecker.ts @@ -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 + } +} diff --git a/packages/guardian/src/infrastructure/strategies/AstNumberAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/AstNumberAnalyzer.ts new file mode 100644 index 0000000..8952e4f --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/AstNumberAnalyzer.ts @@ -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, + ) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/AstStringAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/AstStringAnalyzer.ts new file mode 100644 index 0000000..c444640 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/AstStringAnalyzer.ts @@ -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, + ) + } +} diff --git a/packages/guardian/src/infrastructure/strategies/BraceTracker.ts b/packages/guardian/src/infrastructure/strategies/BraceTracker.ts deleted file mode 100644 index 8df3983..0000000 --- a/packages/guardian/src/infrastructure/strategies/BraceTracker.ts +++ /dev/null @@ -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 } - } -} diff --git a/packages/guardian/src/infrastructure/strategies/ExportConstantAnalyzer.ts b/packages/guardian/src/infrastructure/strategies/ExportConstantAnalyzer.ts deleted file mode 100644 index 4666e20..0000000 --- a/packages/guardian/src/infrastructure/strategies/ExportConstantAnalyzer.ts +++ /dev/null @@ -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)) - ) - } -} diff --git a/packages/guardian/src/infrastructure/strategies/MagicNumberMatcher.ts b/packages/guardian/src/infrastructure/strategies/MagicNumberMatcher.ts deleted file mode 100644 index a083685..0000000 --- a/packages/guardian/src/infrastructure/strategies/MagicNumberMatcher.ts +++ /dev/null @@ -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)) - } -} diff --git a/packages/guardian/src/infrastructure/strategies/MagicStringMatcher.ts b/packages/guardian/src/infrastructure/strategies/MagicStringMatcher.ts deleted file mode 100644 index 693ef4c..0000000 --- a/packages/guardian/src/infrastructure/strategies/MagicStringMatcher.ts +++ /dev/null @@ -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) - } -} diff --git a/packages/guardian/src/infrastructure/strategies/ValuePatternMatcher.ts b/packages/guardian/src/infrastructure/strategies/ValuePatternMatcher.ts new file mode 100644 index 0000000..b394c01 --- /dev/null +++ b/packages/guardian/src/infrastructure/strategies/ValuePatternMatcher.ts @@ -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) + ) + } +} diff --git a/packages/guardian/src/shared/constants/rules.ts b/packages/guardian/src/shared/constants/rules.ts index d002c39..1aec7d2 100644 --- a/packages/guardian/src/shared/constants/rules.ts +++ b/packages/guardian/src/shared/constants/rules.ts @@ -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 diff --git a/packages/guardian/tests/unit/domain/EntityExposure.test.ts b/packages/guardian/tests/unit/domain/EntityExposure.test.ts new file mode 100644 index 0000000..035ee88 --- /dev/null +++ b/packages/guardian/tests/unit/domain/EntityExposure.test.ts @@ -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") + expect(example).toContain("Promise") + 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") + expect(example).toContain("Promise") + 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) + }) + }) +}) diff --git a/packages/guardian/tests/unit/infrastructure/DuplicateValueTracker.test.ts b/packages/guardian/tests/unit/infrastructure/DuplicateValueTracker.test.ts new file mode 100644 index 0000000..fc74d24 --- /dev/null +++ b/packages/guardian/tests/unit/infrastructure/DuplicateValueTracker.test.ts @@ -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") + }) + }) +}) diff --git a/packages/guardian/tests/unit/infrastructure/SecretDetector.test.ts b/packages/guardian/tests/unit/infrastructure/SecretDetector.test.ts index b717797..9912076 100644 --- a/packages/guardian/tests/unit/infrastructure/SecretDetector.test.ts +++ b/packages/guardian/tests/unit/infrastructure/SecretDetector.test.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) + }) + }) })