import DictionaryL33tMatcher from '../dictionary/variants/matching/l33t' import DictionaryReverseMatcher from '../dictionary/variants/matching/reverse' import DictionaryMatcher from '../dictionary/matching' import Options from '../../Options' import { MatchOptions, WordSequenceMatch, DictionaryMatch, MatcherBaseClass, L33tMatch, } from '../../types' interface WordMatch { word: string i: number j: number rank: number dictionaryName: string } /* *------------------------------------------------------------------------------- * word sequences (oneTwoThree, fourFiveSix) ------------------------------ *------------------------------------------------------------------------------- */ class MatchWordSequence extends MatcherBaseClass { dictionary: DictionaryMatcher dictionaryL33t: DictionaryL33tMatcher dictionaryReverse: DictionaryReverseMatcher constructor(options: Options) { super(options) this.dictionary = new DictionaryMatcher(this.options, true) this.dictionaryL33t = new DictionaryL33tMatcher(this.options, true) this.dictionaryReverse = new DictionaryReverseMatcher(this.options, true) } match(matchOptions: MatchOptions): WordSequenceMatch[] { const { password } = matchOptions // Get all dictionary matches first const dictionaryMatches = this.dictionary.match(matchOptions) const dictionaryL33tMatches = this.dictionaryL33t.match(matchOptions) const dictionaryReverseMatches = this.dictionaryReverse.match(matchOptions) const filteredDictionaryMatches = this.filterDictionaryMatches([ ...dictionaryMatches, ...dictionaryL33tMatches, ...dictionaryReverseMatches, ]) // Convert dictionary matches to our internal format const wordMatches = this.convertToWordMatches(filteredDictionaryMatches) // Find sequences of consecutive words return this.findWordSequences(wordMatches, password) } private filterDictionaryMatches(matches: (L33tMatch | DictionaryMatch)[]) { // sort by start index, then by end index return ( [...matches] .sort((a, b) => { if (a.i !== b.i) return a.i - b.i if (a.j !== b.j) return a.j - b.j // prefer forward over reversed if (a.reversed !== b.reversed) return a.reversed ? 1 : -1 // prefer non-l33t if (a.l33t !== b.l33t) return a.l33t ? 1 : -1 // prefer better rank return a.rank - b.rank }) // Keep only non-overlapping matches, favoring earlier ones .reduce((acc, match) => { const last = acc[acc.length - 1] if (!last || match.i > last.j) { acc.push(match) } return acc }, []) ) } private convertToWordMatches( dictionaryMatches: DictionaryMatch[], ): WordMatch[] { return dictionaryMatches.map((match) => ({ word: match.matchedWord, i: match.i, j: match.j, rank: match.rank, dictionaryName: match.dictionaryName, })) } private findWordSequences( wordMatches: WordMatch[], password: string, ): WordSequenceMatch[] { const sequences: WordSequenceMatch[] = [] if (wordMatches.length === 0) { return sequences } // Sort matches by start position const sortedMatches = [...wordMatches].sort((a, b) => a.i - b.i) // Find all possible sequences for (let startIdx = 0; startIdx < sortedMatches.length; startIdx += 1) { const sequencesFromStart = this.findSequencesFromStart( sortedMatches, startIdx, password, ) sequences.push(...sequencesFromStart) } return sequences } // eslint-disable-next-line max-statements private findSequencesFromStart( sortedMatches: WordMatch[], startIdx: number, password: string, ): WordSequenceMatch[] { const sequences: WordSequenceMatch[] = [] const startMatch = sortedMatches[startIdx] // Start with single word sequence let currentSequence = [startMatch] let currentEnd = startMatch.j // Try to extend the sequence for ( let nextIdx = startIdx + 1; nextIdx < sortedMatches.length; nextIdx += 1 ) { const nextMatch = sortedMatches[nextIdx] // Only extend if the next word can form a valid sequence if (this.isValidWordSequence(currentSequence, nextMatch, password)) { currentSequence.push(nextMatch) currentEnd = nextMatch.j } else if (nextMatch.i > currentEnd) { // Gap found, save current sequence and start new one if (currentSequence.length > 1) { sequences.push( this.createWordSequenceMatch(currentSequence, password), ) } currentSequence = [nextMatch] currentEnd = nextMatch.j } // If nextMatch.i <= currentEnd, it overlaps, so we skip it } // Don't forget the last sequence if (currentSequence.length > 1) { sequences.push(this.createWordSequenceMatch(currentSequence, password)) } return sequences } private isValidWordSequence( currentSequence: WordMatch[], nextMatch: WordMatch, password: string, ): boolean { // Get the text between the last word and the next word const lastWord = currentSequence[currentSequence.length - 1] const textBetween = password.slice(lastWord.j + 1, nextMatch.i) // Check for common word separators const separators = ['', ' ', '-', '_', '.', ''] // For simple concatenation (no separator) const isSimpleConcat = textBetween === '' // For snake_case or kebab-case, check for separators const hasValidSeparator = separators.some((sep) => textBetween === sep) // For camelCase, we need to check if the next word starts with uppercase // and there's no separator (or just a single character that could be uppercase) const isCamelCase = textBetween.length === 1 && textBetween === textBetween.toUpperCase() && textBetween !== textBetween.toLowerCase() return hasValidSeparator || isSimpleConcat || isCamelCase } private createWordSequenceMatch( sequence: WordMatch[], password: string, ): WordSequenceMatch { const firstMatch = sequence[0] const lastMatch = sequence[sequence.length - 1] const words = sequence.map((match) => match.word) // Determine if sequence is ascending (by rank) const ranks = sequence.map((match) => match.rank) const ascending = ranks.every((rank, i) => i === 0 || rank >= ranks[i - 1]) // Use the most common dictionary name, or the first one const dictionaryNames = sequence.map((match) => match.dictionaryName) const dictionaryName = this.getMostCommon(dictionaryNames) || firstMatch.dictionaryName return { pattern: 'wordSequence', i: firstMatch.i, j: lastMatch.j, token: password.slice(firstMatch.i, lastMatch.j + 1), words, wordCount: words.length, dictionaryName, ascending, } } private getMostCommon(array: T[]): T | null { if (array.length === 0) return null const counts = new Map() let maxCount = 0 let mostCommon: T | null = null array.forEach((item) => { const count = (counts.get(item) || 0) + 1 counts.set(item, count) if (count > maxCount) { maxCount = count mostCommon = item } }) return mostCommon } } export default MatchWordSequence