pawpy-cli 1.0.0b0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pawpy/__init__.py +8 -0
- pawpy/__main__.py +6 -0
- pawpy/api/__init__.py +1 -0
- pawpy/api/dashboard.py +183 -0
- pawpy/api/rest.py +145 -0
- pawpy/cli.py +341 -0
- pawpy/config.py +60 -0
- pawpy/data/__init__.py +6 -0
- pawpy/data/common_passwords.py +139 -0
- pawpy/data/updater.py +49 -0
- pawpy/filters/__init__.py +1 -0
- pawpy/filters/policy.py +59 -0
- pawpy/generator/__init__.py +5 -0
- pawpy/generator/core.py +314 -0
- pawpy/generator/gpu.py +64 -0
- pawpy/generator/hybrid.py +99 -0
- pawpy/generator/sorter.py +136 -0
- pawpy/mutations/__init__.py +20 -0
- pawpy/mutations/dates.py +72 -0
- pawpy/mutations/keyboard.py +99 -0
- pawpy/mutations/leet.py +65 -0
- pawpy/mutations/mangle.py +238 -0
- pawpy/mutations/markov.py +125 -0
- pawpy/mutations/templates.py +131 -0
- pawpy/profile/__init__.py +5 -0
- pawpy/profile/base.py +161 -0
- pawpy/profile/multi.py +93 -0
- pawpy/profile/plugins/__init__.py +55 -0
- pawpy/profile/plugins/example.py +22 -0
- pawpy/scoring/__init__.py +1 -0
- pawpy/scoring/scorer.py +66 -0
- pawpy/utils.py +135 -0
- pawpy_cli-1.0.0b0.dist-info/METADATA +721 -0
- pawpy_cli-1.0.0b0.dist-info/RECORD +37 -0
- pawpy_cli-1.0.0b0.dist-info/WHEEL +5 -0
- pawpy_cli-1.0.0b0.dist-info/entry_points.txt +2 -0
- pawpy_cli-1.0.0b0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Common mangle rules and Hashcat/John-style rule engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger("pawpy.mangle")
|
|
10
|
+
|
|
11
|
+
# Common append/prepend strings
|
|
12
|
+
APPENDS = [
|
|
13
|
+
"",
|
|
14
|
+
"1",
|
|
15
|
+
"2",
|
|
16
|
+
"3",
|
|
17
|
+
"12",
|
|
18
|
+
"13",
|
|
19
|
+
"21",
|
|
20
|
+
"22",
|
|
21
|
+
"23",
|
|
22
|
+
"99",
|
|
23
|
+
"123",
|
|
24
|
+
"1234",
|
|
25
|
+
"!",
|
|
26
|
+
"@",
|
|
27
|
+
"#",
|
|
28
|
+
"$",
|
|
29
|
+
"!!",
|
|
30
|
+
"!@#",
|
|
31
|
+
"01",
|
|
32
|
+
"69",
|
|
33
|
+
"!",
|
|
34
|
+
"?",
|
|
35
|
+
"*",
|
|
36
|
+
".",
|
|
37
|
+
"_",
|
|
38
|
+
"-",
|
|
39
|
+
"@1",
|
|
40
|
+
"@123",
|
|
41
|
+
"#1",
|
|
42
|
+
]
|
|
43
|
+
PREPENDS = ["", "1", "123", "!", "@", "#", "$", "?", "*"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def mangle_rules(word: str) -> List[str]:
|
|
47
|
+
"""Apply common mangle rules: capitalise, upper, reverse, append, prepend.
|
|
48
|
+
|
|
49
|
+
Returns a list of mutated variants (always includes the original).
|
|
50
|
+
"""
|
|
51
|
+
results = set()
|
|
52
|
+
results.add(word)
|
|
53
|
+
results.add(word.capitalize())
|
|
54
|
+
results.add(word.upper())
|
|
55
|
+
results.add(word.lower())
|
|
56
|
+
results.add(word[::-1]) # reverse
|
|
57
|
+
results.add(word.swapcase())
|
|
58
|
+
results.add(word[0].upper() + word[1:].lower() if word else word)
|
|
59
|
+
# toggle first and last case
|
|
60
|
+
if len(word) > 1:
|
|
61
|
+
results.add(word[0].upper() + word[1:-1] + word[-1].upper())
|
|
62
|
+
|
|
63
|
+
for suffix in APPENDS:
|
|
64
|
+
results.add(word + suffix)
|
|
65
|
+
results.add(word.capitalize() + suffix)
|
|
66
|
+
|
|
67
|
+
for prefix in PREPENDS:
|
|
68
|
+
if prefix: # skip empty prefix (already have original)
|
|
69
|
+
results.add(prefix + word)
|
|
70
|
+
results.add(prefix + word.capitalize())
|
|
71
|
+
|
|
72
|
+
return sorted(results)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Hashcat / John the Ripper rule engine
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _apply_rule(word: str, rule: str) -> Optional[str]:
|
|
81
|
+
"""Apply a single hashcat-style rule to *word*.
|
|
82
|
+
|
|
83
|
+
Supported rules (subset of hashcat):
|
|
84
|
+
- Lowercase: l / : (legacy)
|
|
85
|
+
- Uppercase: u
|
|
86
|
+
- Capitalise: c (first char upper, rest lower)
|
|
87
|
+
- Swap case: s
|
|
88
|
+
- Reverse: r
|
|
89
|
+
- Append: $X (append char X)
|
|
90
|
+
- Prepend: ^X (prepend char X)
|
|
91
|
+
- Truncate left: [N (keep first N chars)
|
|
92
|
+
- Truncate right: ]N (keep last N chars)
|
|
93
|
+
- Insert at pos: iNX (insert char X at position N)
|
|
94
|
+
- Overwrite at pos: oNX (overwrite char at position N with X)
|
|
95
|
+
- Duplicate: d
|
|
96
|
+
- Duplicate first: p
|
|
97
|
+
- Duplicate last: z
|
|
98
|
+
- Replace: sXY (replace all X with Y)
|
|
99
|
+
"""
|
|
100
|
+
w = list(word)
|
|
101
|
+
|
|
102
|
+
i = 0
|
|
103
|
+
while i < len(rule):
|
|
104
|
+
cmd = rule[i]
|
|
105
|
+
|
|
106
|
+
if cmd == "l":
|
|
107
|
+
w = [c.lower() for c in w]
|
|
108
|
+
i += 1
|
|
109
|
+
elif cmd == "u":
|
|
110
|
+
w = [c.upper() for c in w]
|
|
111
|
+
i += 1
|
|
112
|
+
elif cmd == "c":
|
|
113
|
+
if w:
|
|
114
|
+
w = [w[0].upper()] + [c.lower() for c in w[1:]]
|
|
115
|
+
i += 1
|
|
116
|
+
elif cmd == "s":
|
|
117
|
+
w = [c.swapcase() for c in w]
|
|
118
|
+
i += 1
|
|
119
|
+
elif cmd == "r":
|
|
120
|
+
w = w[::-1]
|
|
121
|
+
i += 1
|
|
122
|
+
elif cmd == "d":
|
|
123
|
+
w = w + w
|
|
124
|
+
i += 1
|
|
125
|
+
elif cmd == "p":
|
|
126
|
+
if w:
|
|
127
|
+
w = [w[0]] + w
|
|
128
|
+
i += 1
|
|
129
|
+
elif cmd == "z":
|
|
130
|
+
if w:
|
|
131
|
+
w = w + [w[-1]]
|
|
132
|
+
i += 1
|
|
133
|
+
elif cmd == "$":
|
|
134
|
+
# Append next char
|
|
135
|
+
if i + 1 < len(rule):
|
|
136
|
+
w.append(rule[i + 1])
|
|
137
|
+
i += 2
|
|
138
|
+
else:
|
|
139
|
+
i += 1
|
|
140
|
+
elif cmd == "^":
|
|
141
|
+
# Prepend next char
|
|
142
|
+
if i + 1 < len(rule):
|
|
143
|
+
w.insert(0, rule[i + 1])
|
|
144
|
+
i += 2
|
|
145
|
+
else:
|
|
146
|
+
i += 1
|
|
147
|
+
elif cmd == "[":
|
|
148
|
+
# Truncate from left – keep first N
|
|
149
|
+
m = re.match(r"\[(\d+)", rule[i:])
|
|
150
|
+
if m:
|
|
151
|
+
n = int(m.group(1))
|
|
152
|
+
w = w[:n]
|
|
153
|
+
i += 1 + len(m.group(1))
|
|
154
|
+
else:
|
|
155
|
+
i += 1
|
|
156
|
+
elif cmd == "]":
|
|
157
|
+
# Truncate from right – keep last N
|
|
158
|
+
m = re.match(r"\](\d+)", rule[i:])
|
|
159
|
+
if m:
|
|
160
|
+
n = int(m.group(1))
|
|
161
|
+
w = w[-n:] if n > 0 else []
|
|
162
|
+
i += 1 + len(m.group(1))
|
|
163
|
+
else:
|
|
164
|
+
i += 1
|
|
165
|
+
elif cmd == "i":
|
|
166
|
+
# Insert char X at position N: iNX
|
|
167
|
+
m = re.match(r"i(\d+)(.)", rule[i:])
|
|
168
|
+
if m:
|
|
169
|
+
pos = int(m.group(1))
|
|
170
|
+
ch = m.group(2)
|
|
171
|
+
if pos <= len(w):
|
|
172
|
+
w.insert(pos, ch)
|
|
173
|
+
i += 1 + len(m.group(1)) + 1
|
|
174
|
+
else:
|
|
175
|
+
i += 1
|
|
176
|
+
elif cmd == "o":
|
|
177
|
+
# Overwrite char at position N with X: oNX
|
|
178
|
+
m = re.match(r"o(\d+)(.)", rule[i:])
|
|
179
|
+
if m:
|
|
180
|
+
pos = int(m.group(1))
|
|
181
|
+
ch = m.group(2)
|
|
182
|
+
if 0 <= pos < len(w):
|
|
183
|
+
w[pos] = ch
|
|
184
|
+
i += 1 + len(m.group(1)) + 1
|
|
185
|
+
else:
|
|
186
|
+
i += 1
|
|
187
|
+
elif cmd == "'":
|
|
188
|
+
# Replace all X with Y: sXY (or 'XY in john format)
|
|
189
|
+
if i + 2 < len(rule):
|
|
190
|
+
old_ch = rule[i + 1]
|
|
191
|
+
new_ch = rule[i + 2]
|
|
192
|
+
w = [new_ch if c == old_ch else c for c in w]
|
|
193
|
+
i += 3
|
|
194
|
+
else:
|
|
195
|
+
i += 1
|
|
196
|
+
else:
|
|
197
|
+
# Unknown rule – skip
|
|
198
|
+
i += 1
|
|
199
|
+
|
|
200
|
+
return "".join(w)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def apply_hashcat_rules(word: str, rules: List[str]) -> List[str]:
|
|
204
|
+
"""Apply a list of hashcat-style rules to *word*.
|
|
205
|
+
|
|
206
|
+
Each rule string may contain multiple rule commands (applied left to
|
|
207
|
+
right). Returns the list of results.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
word: The base word.
|
|
211
|
+
rules: A list of rule strings, one per line (as read from a .rule file).
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
List of mutated words. Empty results are excluded.
|
|
215
|
+
"""
|
|
216
|
+
results = set()
|
|
217
|
+
for rule in rules:
|
|
218
|
+
rule = rule.strip()
|
|
219
|
+
if not rule or rule.startswith("#"):
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
mutated = _apply_rule(word, rule)
|
|
223
|
+
if mutated:
|
|
224
|
+
results.add(mutated)
|
|
225
|
+
except Exception:
|
|
226
|
+
logger.debug("Failed to apply rule '%s' to '%s'", rule, word)
|
|
227
|
+
return sorted(results)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def load_rules_file(path: str) -> List[str]:
|
|
231
|
+
"""Load rules from a hashcat .rule file (one rule per line)."""
|
|
232
|
+
rules = []
|
|
233
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
|
234
|
+
for line in fh:
|
|
235
|
+
stripped = line.strip()
|
|
236
|
+
if stripped and not stripped.startswith("#"):
|
|
237
|
+
rules.append(stripped)
|
|
238
|
+
return rules
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Markov chain-based password generation.
|
|
2
|
+
|
|
3
|
+
Trains a character-level Markov model on a corpus of words and
|
|
4
|
+
generates new candidate passwords that follow similar patterns.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import random
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("pawpy.markov")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MarkovModel:
|
|
18
|
+
"""Character-level Markov chain for password generation.
|
|
19
|
+
|
|
20
|
+
The model records the probability of each character following a
|
|
21
|
+
sequence of *order* previous characters. Generation starts from
|
|
22
|
+
a random seed and follows the chain until a stop condition.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, order: int = 2) -> None:
|
|
26
|
+
self.order = order
|
|
27
|
+
# transition_table[context][next_char] = count
|
|
28
|
+
self.transition_table: Dict[str, Dict[str, int]] = defaultdict(
|
|
29
|
+
lambda: defaultdict(int)
|
|
30
|
+
)
|
|
31
|
+
self.total_chars = 0
|
|
32
|
+
|
|
33
|
+
def train(self, words: List[str]) -> None:
|
|
34
|
+
"""Train the model on a list of words."""
|
|
35
|
+
for word in words:
|
|
36
|
+
w = word.lower().strip()
|
|
37
|
+
if not w:
|
|
38
|
+
continue
|
|
39
|
+
# Pad start and end
|
|
40
|
+
padded = "^" * self.order + w + "$"
|
|
41
|
+
for i in range(len(padded) - self.order):
|
|
42
|
+
context = padded[i : i + self.order]
|
|
43
|
+
next_char = padded[i + self.order]
|
|
44
|
+
self.transition_table[context][next_char] += 1
|
|
45
|
+
self.total_chars += 1
|
|
46
|
+
|
|
47
|
+
logger.debug(
|
|
48
|
+
"Markov model trained: order=%d, contexts=%d, total_chars=%d",
|
|
49
|
+
self.order,
|
|
50
|
+
len(self.transition_table),
|
|
51
|
+
self.total_chars,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def generate(self, min_len: int = 6, max_len: int = 16) -> Optional[str]:
|
|
55
|
+
"""Generate a single password from the trained model."""
|
|
56
|
+
if not self.transition_table:
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# Start from a random context
|
|
60
|
+
contexts = [ctx for ctx in self.transition_table if all(c == "^" for c in ctx)]
|
|
61
|
+
if not contexts:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
context = random.choice(contexts)
|
|
65
|
+
result = ""
|
|
66
|
+
|
|
67
|
+
for _ in range(max_len):
|
|
68
|
+
if context not in self.transition_table:
|
|
69
|
+
break
|
|
70
|
+
next_chars = self.transition_table[context]
|
|
71
|
+
if not next_chars:
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
# Weighted random choice
|
|
75
|
+
total = sum(next_chars.values())
|
|
76
|
+
r = random.randint(1, total)
|
|
77
|
+
cumulative = 0
|
|
78
|
+
chosen = "$" # default: end
|
|
79
|
+
for ch, count in next_chars.items():
|
|
80
|
+
cumulative += count
|
|
81
|
+
if cumulative >= r:
|
|
82
|
+
chosen = ch
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
if chosen == "$":
|
|
86
|
+
break
|
|
87
|
+
result += chosen
|
|
88
|
+
context = context[1:] + chosen
|
|
89
|
+
|
|
90
|
+
if len(result) < min_len:
|
|
91
|
+
return None
|
|
92
|
+
return result
|
|
93
|
+
|
|
94
|
+
def generate_many(
|
|
95
|
+
self, count: int, min_len: int = 6, max_len: int = 16
|
|
96
|
+
) -> List[str]:
|
|
97
|
+
"""Generate *count* unique passwords."""
|
|
98
|
+
results: set = set()
|
|
99
|
+
attempts = 0
|
|
100
|
+
max_attempts = count * 10
|
|
101
|
+
while len(results) < count and attempts < max_attempts:
|
|
102
|
+
word = self.generate(min_len, max_len)
|
|
103
|
+
if word:
|
|
104
|
+
results.add(word)
|
|
105
|
+
attempts += 1
|
|
106
|
+
return sorted(results)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def train_markov(words: List[str], order: int = 2) -> MarkovModel:
|
|
110
|
+
"""Convenience function: create, train, and return a MarkovModel."""
|
|
111
|
+
model = MarkovModel(order=order)
|
|
112
|
+
model.train(words)
|
|
113
|
+
return model
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def generate_markov_words(
|
|
117
|
+
words: List[str],
|
|
118
|
+
count: int = 5000,
|
|
119
|
+
order: int = 2,
|
|
120
|
+
min_len: int = 6,
|
|
121
|
+
max_len: int = 16,
|
|
122
|
+
) -> List[str]:
|
|
123
|
+
"""Train a Markov model on *words* and generate *count* candidates."""
|
|
124
|
+
model = train_markov(words, order=order)
|
|
125
|
+
return model.generate_many(count, min_len, max_len)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Custom pattern template engine.
|
|
2
|
+
|
|
3
|
+
Expands templates like ``[FirstName][Year][Special]`` by substituting
|
|
4
|
+
profile fields and generating combinatorial expansions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Dict, List
|
|
12
|
+
|
|
13
|
+
# Recognised template tokens (inside [brackets])
|
|
14
|
+
_TOKEN_RE = re.compile(r"\[([A-Za-z_]+)\]")
|
|
15
|
+
|
|
16
|
+
# Special character sets used by some tokens
|
|
17
|
+
_SPECIALS = ["!", "@", "#", "$", "%", "^", "&", "*", "?", ".", "_", "-"]
|
|
18
|
+
_DIGITS = [str(i) for i in range(10)]
|
|
19
|
+
|
|
20
|
+
# Current year
|
|
21
|
+
_CURRENT_YEAR = datetime.now().year
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _resolve_token(token: str, profile: Dict[str, Any]) -> List[str]:
|
|
25
|
+
"""Resolve a single template token to a list of possible values."""
|
|
26
|
+
t = token.lower()
|
|
27
|
+
|
|
28
|
+
# Direct profile fields
|
|
29
|
+
field_map = {
|
|
30
|
+
"firstname": "firstname",
|
|
31
|
+
"lastname": "lastname",
|
|
32
|
+
"nickname": "nickname",
|
|
33
|
+
"partner": "partner",
|
|
34
|
+
"partner_nick": "partner_nick",
|
|
35
|
+
"pet": "pet",
|
|
36
|
+
"company": "company",
|
|
37
|
+
"hometown": "hometown",
|
|
38
|
+
"color": "favourite_color",
|
|
39
|
+
"favourite_color": "favourite_color",
|
|
40
|
+
}
|
|
41
|
+
if t in field_map:
|
|
42
|
+
val = profile.get(field_map[t], "")
|
|
43
|
+
if isinstance(val, str) and val.strip():
|
|
44
|
+
return [val, val.capitalize(), val.upper(), val.lower()]
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
# Date-related tokens
|
|
48
|
+
if t in ("year", "yr"):
|
|
49
|
+
return [str(_CURRENT_YEAR - i) for i in range(40)] # 1986..current
|
|
50
|
+
if t in ("year2", "yr2"):
|
|
51
|
+
return [str(y)[-2:] for y in range(_CURRENT_YEAR - 40, _CURRENT_YEAR + 1)]
|
|
52
|
+
if t == "date":
|
|
53
|
+
bd = profile.get("birthdate", "")
|
|
54
|
+
return [bd] if isinstance(bd, str) and bd.strip() else []
|
|
55
|
+
|
|
56
|
+
# Character class tokens
|
|
57
|
+
if t == "digit":
|
|
58
|
+
return _DIGITS
|
|
59
|
+
if t in ("special", "spec", "sym"):
|
|
60
|
+
return _SPECIALS
|
|
61
|
+
if t == "upper":
|
|
62
|
+
return [chr(c) for c in range(ord("A"), ord("Z") + 1)]
|
|
63
|
+
if t == "lower":
|
|
64
|
+
return [chr(c) for c in range(ord("a"), ord("z") + 1)]
|
|
65
|
+
|
|
66
|
+
# Children / keywords
|
|
67
|
+
if t in ("child", "children"):
|
|
68
|
+
val = profile.get("children", [])
|
|
69
|
+
if isinstance(val, list):
|
|
70
|
+
return [c for c in val if isinstance(c, str) and c.strip()]
|
|
71
|
+
return []
|
|
72
|
+
if t == "keyword" or t == "keywords":
|
|
73
|
+
val = profile.get("keywords", [])
|
|
74
|
+
if isinstance(val, list):
|
|
75
|
+
return [k for k in val if isinstance(k, str) and k.strip()]
|
|
76
|
+
return []
|
|
77
|
+
|
|
78
|
+
# Static tokens
|
|
79
|
+
if t == "sep":
|
|
80
|
+
return ["_", "-", ".", "", "@", "#"]
|
|
81
|
+
if t == "123":
|
|
82
|
+
return ["123", "1234", "12345", "!@#", "1q2w3e"]
|
|
83
|
+
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def expand_templates(
|
|
88
|
+
templates: List[str],
|
|
89
|
+
profile: Dict[str, Any],
|
|
90
|
+
max_combinations: int = 100_000,
|
|
91
|
+
) -> List[str]:
|
|
92
|
+
"""Expand a list of template strings using profile data.
|
|
93
|
+
|
|
94
|
+
Each template may contain ``[Token]`` placeholders which are resolved
|
|
95
|
+
via ``_resolve_token``. All combinations are generated.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
templates: List of template strings like ``[FirstName][Year][!]``.
|
|
99
|
+
profile: Target profile dictionary.
|
|
100
|
+
max_combinations: Safety limit on total output size.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
List of expanded password candidates.
|
|
104
|
+
"""
|
|
105
|
+
results = []
|
|
106
|
+
total = 0
|
|
107
|
+
|
|
108
|
+
for tmpl in templates:
|
|
109
|
+
# Find all tokens in order
|
|
110
|
+
tokens = _TOKEN_RE.findall(tmpl)
|
|
111
|
+
if not tokens:
|
|
112
|
+
results.append(tmpl)
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
# Resolve each token to its possible values
|
|
116
|
+
token_values = [_resolve_token(tok, profile) for tok in tokens]
|
|
117
|
+
|
|
118
|
+
# Cartesian product
|
|
119
|
+
import itertools
|
|
120
|
+
|
|
121
|
+
for combo in itertools.product(*token_values):
|
|
122
|
+
if total >= max_combinations:
|
|
123
|
+
return results
|
|
124
|
+
candidate = tmpl
|
|
125
|
+
for tok, val in zip(tokens, combo):
|
|
126
|
+
candidate = candidate.replace(f"[{tok}]", val, 1)
|
|
127
|
+
candidate = candidate.replace(f"[{tok.lower()}]", val, 1)
|
|
128
|
+
results.append(candidate)
|
|
129
|
+
total += 1
|
|
130
|
+
|
|
131
|
+
return results
|
pawpy/profile/base.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Profile collector – interactive (CUPP-style) and JSON import."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
logger = logging.getLogger("pawpy.profile")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProfileCollector:
|
|
16
|
+
"""Collect target profile data interactively or from JSON."""
|
|
17
|
+
|
|
18
|
+
# Standard fields expected in a profile
|
|
19
|
+
FIELDS = [
|
|
20
|
+
"firstname",
|
|
21
|
+
"lastname",
|
|
22
|
+
"nickname",
|
|
23
|
+
"birthdate",
|
|
24
|
+
"partner",
|
|
25
|
+
"partner_nick",
|
|
26
|
+
"partner_bdate",
|
|
27
|
+
"pet",
|
|
28
|
+
"company",
|
|
29
|
+
"hometown",
|
|
30
|
+
"favourite_color",
|
|
31
|
+
]
|
|
32
|
+
LIST_FIELDS = ["children", "keywords"]
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._profile: Dict[str, Any] = {k: "" for k in self.FIELDS}
|
|
36
|
+
self._profile["children"] = []
|
|
37
|
+
self._profile["keywords"] = []
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------------
|
|
40
|
+
# Interactive collection
|
|
41
|
+
# ------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
def interactive_collect(self) -> Dict[str, Any]:
|
|
44
|
+
"""Present a CUPP-style questionnaire and return the profile dict."""
|
|
45
|
+
console.print("\n[bold cyan]── Target Profile Collection ──[/bold cyan]\n")
|
|
46
|
+
|
|
47
|
+
prompts = {
|
|
48
|
+
"firstname": ("First name", "John"),
|
|
49
|
+
"lastname": ("Last name", "Doe"),
|
|
50
|
+
"nickname": ("Nickname", "Johnny"),
|
|
51
|
+
"birthdate": ("Birthdate (DDMMYYYY)", "01011990"),
|
|
52
|
+
"partner": ("Partner's first name", ""),
|
|
53
|
+
"partner_nick": ("Partner's nickname", ""),
|
|
54
|
+
"partner_bdate": ("Partner's birthdate (DDMMYYYY)", ""),
|
|
55
|
+
"pet": ("Pet's name", ""),
|
|
56
|
+
"company": ("Company / organisation", ""),
|
|
57
|
+
"hometown": ("City / hometown", ""),
|
|
58
|
+
"favourite_color": ("Favourite colour", ""),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for field, (label, example) in prompts.items():
|
|
62
|
+
answer = console.input(
|
|
63
|
+
f" [dim]{label}[/dim] [dim](e.g. {example})[/dim]: "
|
|
64
|
+
).strip()
|
|
65
|
+
self._profile[field] = answer
|
|
66
|
+
|
|
67
|
+
children_raw = console.input(
|
|
68
|
+
" [dim]Children's names (comma-separated)[/dim]: "
|
|
69
|
+
).strip()
|
|
70
|
+
self._profile["children"] = [
|
|
71
|
+
c.strip() for c in children_raw.split(",") if c.strip()
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
keywords_raw = console.input(
|
|
75
|
+
" [dim]Keywords / interests (comma-separated)[/dim]: "
|
|
76
|
+
).strip()
|
|
77
|
+
self._profile["keywords"] = [
|
|
78
|
+
k.strip() for k in keywords_raw.split(",") if k.strip()
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
console.print()
|
|
82
|
+
return self._profile
|
|
83
|
+
|
|
84
|
+
# ------------------------------------------------------------------
|
|
85
|
+
# JSON import
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def from_json(path: str) -> Dict[str, Any]:
|
|
90
|
+
"""Load a single profile from a JSON file.
|
|
91
|
+
|
|
92
|
+
Raises ``ValueError`` if the file contains a JSON array (use
|
|
93
|
+
``--multi`` for arrays).
|
|
94
|
+
"""
|
|
95
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
96
|
+
data = json.load(fh)
|
|
97
|
+
if isinstance(data, list):
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"File '{path}' contains a JSON array of profiles. "
|
|
100
|
+
"Use --multi to import multiple profiles."
|
|
101
|
+
)
|
|
102
|
+
return data
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Word / date extraction
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def extract_base_words(profile: Dict[str, Any]) -> List[str]:
|
|
110
|
+
"""Extract all non-empty text values from a profile as lowercase words.
|
|
111
|
+
|
|
112
|
+
String fields yield one word each; list fields yield one word per
|
|
113
|
+
element. The result is deduplicated and sorted.
|
|
114
|
+
"""
|
|
115
|
+
words: set = set()
|
|
116
|
+
list_fields = {"children", "keywords"}
|
|
117
|
+
for key, value in profile.items():
|
|
118
|
+
if key in list_fields:
|
|
119
|
+
if isinstance(value, list):
|
|
120
|
+
for item in value:
|
|
121
|
+
if isinstance(item, str) and item.strip():
|
|
122
|
+
words.add(item.strip().lower())
|
|
123
|
+
else:
|
|
124
|
+
if isinstance(value, str) and value.strip():
|
|
125
|
+
words.add(value.strip().lower())
|
|
126
|
+
return sorted(words)
|
|
127
|
+
|
|
128
|
+
@staticmethod
|
|
129
|
+
def extract_dates(profile: Dict[str, Any]) -> List[str]:
|
|
130
|
+
"""Return date strings found in the profile (birthdate, partner_bdate)."""
|
|
131
|
+
dates = []
|
|
132
|
+
for field in ("birthdate", "partner_bdate"):
|
|
133
|
+
val = profile.get(field, "")
|
|
134
|
+
if isinstance(val, str) and val.strip():
|
|
135
|
+
dates.append(val.strip())
|
|
136
|
+
return dates
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------
|
|
139
|
+
# Main entry point
|
|
140
|
+
# ------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def run(self, config) -> Dict[str, Any]:
|
|
143
|
+
"""Collect profile based on configuration.
|
|
144
|
+
|
|
145
|
+
If *config.profile_json* is set, load from file; otherwise
|
|
146
|
+
run the interactive questionnaire. Optionally run OSINT plugins.
|
|
147
|
+
"""
|
|
148
|
+
from pawpy.profile.plugins import run_plugins # lazy import
|
|
149
|
+
|
|
150
|
+
if config.profile_json:
|
|
151
|
+
self._profile = self.from_json(config.profile_json)
|
|
152
|
+
console.print(
|
|
153
|
+
f"[green]✓[/green] Loaded profile from [bold]{config.profile_json}[/bold]"
|
|
154
|
+
)
|
|
155
|
+
else:
|
|
156
|
+
self._profile = self.interactive_collect()
|
|
157
|
+
|
|
158
|
+
# Run OSINT plugins if any are installed
|
|
159
|
+
self._profile = run_plugins(self._profile)
|
|
160
|
+
|
|
161
|
+
return self._profile
|