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.
@@ -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
@@ -0,0 +1,5 @@
1
+ """Profile collection subsystem."""
2
+
3
+ from pawpy.profile.base import ProfileCollector
4
+
5
+ __all__ = ["ProfileCollector"]
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