golemorph 1.1.0__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.
- golemorph/__init__.py +29 -0
- golemorph/__main__.py +6 -0
- golemorph/cli.py +272 -0
- golemorph/data/ALG/FirstName.csv +501 -0
- golemorph/data/ALG/Surname.csv +501 -0
- golemorph/data/ARG/FirstName.csv +501 -0
- golemorph/data/ARG/Surname.csv +501 -0
- golemorph/data/AUT/FirstName.csv +501 -0
- golemorph/data/AUT/Surname.csv +501 -0
- golemorph/data/BEL/FirstName.csv +501 -0
- golemorph/data/BEL/Surname.csv +501 -0
- golemorph/data/BGR/FirstName.csv +501 -0
- golemorph/data/BGR/Surname.csv +501 -0
- golemorph/data/BRA/FirstName.csv +501 -0
- golemorph/data/BRA/Surname.csv +501 -0
- golemorph/data/CAN/FirstName.csv +501 -0
- golemorph/data/CAN/Surname.csv +501 -0
- golemorph/data/CHN/FirstName.csv +501 -0
- golemorph/data/CHN/Surname.csv +501 -0
- golemorph/data/COL/FirstName.csv +501 -0
- golemorph/data/COL/Surname.csv +501 -0
- golemorph/data/CZE/FirstName.csv +501 -0
- golemorph/data/CZE/Surname.csv +501 -0
- golemorph/data/DEU/FirstName.csv +501 -0
- golemorph/data/DEU/Surname.csv +501 -0
- golemorph/data/DNK/FirstName.csv +501 -0
- golemorph/data/DNK/Surname.csv +501 -0
- golemorph/data/EGY/FirstName.csv +501 -0
- golemorph/data/EGY/Surname.csv +501 -0
- golemorph/data/ESP/FirstName.csv +501 -0
- golemorph/data/ESP/Surname.csv +501 -0
- golemorph/data/FIN/FirstName.csv +501 -0
- golemorph/data/FIN/Surname.csv +501 -0
- golemorph/data/FRA/FirstName.csv +501 -0
- golemorph/data/FRA/Surname.csv +501 -0
- golemorph/data/GBR/FirstName.csv +501 -0
- golemorph/data/GBR/Surname.csv +501 -0
- golemorph/data/GRC/FirstName.csv +501 -0
- golemorph/data/GRC/Surname.csv +501 -0
- golemorph/data/HRV/FirstName.csv +501 -0
- golemorph/data/HRV/Surname.csv +501 -0
- golemorph/data/HUN/FirstName.csv +501 -0
- golemorph/data/HUN/Surname.csv +501 -0
- golemorph/data/IDN/FirstName.csv +501 -0
- golemorph/data/IDN/Surname.csv +501 -0
- golemorph/data/IND/FirstName.csv +501 -0
- golemorph/data/IND/Surname.csv +501 -0
- golemorph/data/IRL/FirstName.csv +501 -0
- golemorph/data/IRL/Surname.csv +501 -0
- golemorph/data/ITA/FirstName.csv +501 -0
- golemorph/data/ITA/Surname.csv +501 -0
- golemorph/data/JPN/FirstName.csv +501 -0
- golemorph/data/JPN/Surname.csv +501 -0
- golemorph/data/KOR/FirstName.csv +501 -0
- golemorph/data/KOR/Surname.csv +501 -0
- golemorph/data/MEX/FirstName.csv +501 -0
- golemorph/data/MEX/Surname.csv +501 -0
- golemorph/data/MRN/FirstName.csv +501 -0
- golemorph/data/MRN/Surname.csv +501 -0
- golemorph/data/MYS/FirstName.csv +501 -0
- golemorph/data/MYS/Surname.csv +501 -0
- golemorph/data/NGA/FirstName.csv +501 -0
- golemorph/data/NGA/Surname.csv +501 -0
- golemorph/data/NLD/FirstName.csv +501 -0
- golemorph/data/NLD/Surname.csv +501 -0
- golemorph/data/NOR/FirstName.csv +501 -0
- golemorph/data/NOR/Surname.csv +501 -0
- golemorph/data/PHL/FirstName.csv +501 -0
- golemorph/data/PHL/Surname.csv +501 -0
- golemorph/data/POL/FirstName.csv +501 -0
- golemorph/data/POL/Surname.csv +501 -0
- golemorph/data/PRT/FirstName.csv +501 -0
- golemorph/data/PRT/Surname.csv +501 -0
- golemorph/data/RUS/FirstName.csv +501 -0
- golemorph/data/RUS/Surname.csv +501 -0
- golemorph/data/SAU/FirstName.csv +501 -0
- golemorph/data/SAU/Surname.csv +501 -0
- golemorph/data/SGP/FirstName.csv +501 -0
- golemorph/data/SGP/Surname.csv +501 -0
- golemorph/data/SUI/FirstName.csv +501 -0
- golemorph/data/SUI/Surname.csv +501 -0
- golemorph/data/SVN/FirstName.csv +501 -0
- golemorph/data/SVN/Surname.csv +501 -0
- golemorph/data/SWE/FirstName.csv +501 -0
- golemorph/data/SWE/Surname.csv +501 -0
- golemorph/data/TUN/FirstName.csv +501 -0
- golemorph/data/TUN/Surname.csv +501 -0
- golemorph/data/TUR/FirstName.csv +501 -0
- golemorph/data/TUR/Surname.csv +501 -0
- golemorph/data/USA/FirstName.csv +501 -0
- golemorph/data/USA/Surname.csv +501 -0
- golemorph/data/ZAF/FirstName.csv +501 -0
- golemorph/data/ZAF/Surname.csv +501 -0
- golemorph/data/manifest.yaml +996 -0
- golemorph/loader.py +201 -0
- golemorph/models.py +130 -0
- golemorph/persona.py +371 -0
- golemorph/sampler.py +54 -0
- golemorph-1.1.0.dist-info/METADATA +197 -0
- golemorph-1.1.0.dist-info/RECORD +104 -0
- golemorph-1.1.0.dist-info/WHEEL +5 -0
- golemorph-1.1.0.dist-info/entry_points.txt +2 -0
- golemorph-1.1.0.dist-info/licenses/LICENSE +21 -0
- golemorph-1.1.0.dist-info/top_level.txt +1 -0
golemorph/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Golemorph: complete, coherent synthetic personas for authorized red team
|
|
2
|
+
spearphishing campaigns.
|
|
3
|
+
|
|
4
|
+
The public surface is deliberately small: load an origin, generate personas,
|
|
5
|
+
hand them to GoPhish. Everything locale-specific (name order, dial code, email
|
|
6
|
+
domains, cities, roles) is data in `data/manifest.yaml`, never code, so
|
|
7
|
+
adding a locale never means touching this package.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .loader import load_origin, load_first_names, load_surnames, origins
|
|
11
|
+
from .models import Gender, NameEntry, OriginProfile, Persona
|
|
12
|
+
from .persona import generate_personas
|
|
13
|
+
from .sampler import NameSampler
|
|
14
|
+
|
|
15
|
+
__version__ = "2.0.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Gender",
|
|
19
|
+
"NameEntry",
|
|
20
|
+
"OriginProfile",
|
|
21
|
+
"Persona",
|
|
22
|
+
"NameSampler",
|
|
23
|
+
"generate_personas",
|
|
24
|
+
"load_first_names",
|
|
25
|
+
"load_origin",
|
|
26
|
+
"load_surnames",
|
|
27
|
+
"origins",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
golemorph/__main__.py
ADDED
golemorph/cli.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Command-line interface: `golemorph --help`.
|
|
2
|
+
|
|
3
|
+
A small set of options covering what a campaign actually needs: pick an
|
|
4
|
+
origin, generate a batch, export (bare names, JSON records, GoPhish CSV).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import csv
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from contextlib import nullcontext
|
|
14
|
+
|
|
15
|
+
from .loader import origins
|
|
16
|
+
from .models import Gender, Persona
|
|
17
|
+
from .persona import (
|
|
18
|
+
_DEFAULT_MAX_PERCENTILE,
|
|
19
|
+
_DEFAULT_MIN_PERCENTILE,
|
|
20
|
+
generate_personas,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
class _PaddedParser(argparse.ArgumentParser):
|
|
24
|
+
"""ArgumentParser that frames its help text with a blank line above and
|
|
25
|
+
below, matching the spacing of the `--list-origins` listing."""
|
|
26
|
+
|
|
27
|
+
def format_help(self) -> str:
|
|
28
|
+
return f"\n{super().format_help()}\n"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
parser = _PaddedParser(
|
|
33
|
+
prog="golemorph",
|
|
34
|
+
description="Generate complete, coherent synthetic personas "
|
|
35
|
+
"(names, emails, phones, backstory) for phishing campaigns.",
|
|
36
|
+
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"-o",
|
|
40
|
+
"--origin",
|
|
41
|
+
default="USA",
|
|
42
|
+
type=str.upper, # codes are upper-case; accept any case on the CLI
|
|
43
|
+
metavar="CODE",
|
|
44
|
+
help="origin code, case-insensitive (see --list-origins)",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"-n",
|
|
49
|
+
"--count",
|
|
50
|
+
type=int,
|
|
51
|
+
default=1,
|
|
52
|
+
help="number of personas to generate",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument(
|
|
55
|
+
"-u",
|
|
56
|
+
"--unique",
|
|
57
|
+
nargs="?",
|
|
58
|
+
const="full",
|
|
59
|
+
choices=("first", "last", "full"),
|
|
60
|
+
metavar="PART",
|
|
61
|
+
help="reject and redraw duplicates so names don't repeat; PART is "
|
|
62
|
+
"full (whole name, the default when given bare), first (given names) "
|
|
63
|
+
"or last (surnames); errors if the pool is too small",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"-g",
|
|
67
|
+
"--gender",
|
|
68
|
+
choices=["Male", "Female"],
|
|
69
|
+
help="force one gender for every persona (default: mixed, per name)",
|
|
70
|
+
)
|
|
71
|
+
parser.add_argument(
|
|
72
|
+
"--unweighted",
|
|
73
|
+
action="store_true",
|
|
74
|
+
help="uniform name sampling instead of frequency-weighted",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--seed",
|
|
78
|
+
type=int,
|
|
79
|
+
help="RNG seed for reproducible campaigns (also seeds --unique redraws, "
|
|
80
|
+
"so retries never silently shift the draw)",
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--min-common",
|
|
84
|
+
type=float,
|
|
85
|
+
default=_DEFAULT_MIN_PERCENTILE,
|
|
86
|
+
metavar="PCT",
|
|
87
|
+
help="exclude names rarer than this percentile (0-100) so personas "
|
|
88
|
+
"avoid odd, unplaceable names; 0 keeps every name",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--max-common",
|
|
92
|
+
type=float,
|
|
93
|
+
default=_DEFAULT_MAX_PERCENTILE,
|
|
94
|
+
metavar="PCT",
|
|
95
|
+
help="exclude names more common than this percentile (0-100) so "
|
|
96
|
+
"personas avoid the 'John Doe' effect; 100 keeps every name",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"-f",
|
|
100
|
+
"--format",
|
|
101
|
+
choices=("name", "csv", "json", "gophish"),
|
|
102
|
+
default="name",
|
|
103
|
+
help="output format: bare names, full CSV/JSON records, or GoPhish CSV",
|
|
104
|
+
)
|
|
105
|
+
parser.add_argument(
|
|
106
|
+
"--output",
|
|
107
|
+
metavar="FILE",
|
|
108
|
+
help="write output to FILE instead of stdout",
|
|
109
|
+
)
|
|
110
|
+
parser.add_argument(
|
|
111
|
+
"--list-origins",
|
|
112
|
+
nargs="?",
|
|
113
|
+
const="all",
|
|
114
|
+
default=None,
|
|
115
|
+
type=str.lower,
|
|
116
|
+
choices=("all", *_GROUP_SLUGS),
|
|
117
|
+
metavar="GROUP",
|
|
118
|
+
help="print available origins and exit; optionally filter by region "
|
|
119
|
+
f"group ({', '.join(_GROUP_SLUGS)}), default all",
|
|
120
|
+
)
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# Region display order; anything not listed is appended alphabetically after.
|
|
125
|
+
_REGION_ORDER = ("Europe", "Americas", "Africa & MENA", "Asia-Pacific")
|
|
126
|
+
|
|
127
|
+
# CLI slug -> region name, for `--list-origins <group>`. Short, lower-case and
|
|
128
|
+
# easy to type; the region names themselves carry spaces and punctuation.
|
|
129
|
+
_GROUP_SLUGS = {
|
|
130
|
+
"europe": "Europe",
|
|
131
|
+
"americas": "Americas",
|
|
132
|
+
"africa": "Africa & MENA",
|
|
133
|
+
"asia": "Asia-Pacific",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _print_origins(group: str = "all") -> None:
|
|
138
|
+
"""Pretty, color listing of origins for `--list-origins`, one table per
|
|
139
|
+
region. `group` is a slug from :data:`_GROUP_SLUGS`, or ``"all"``.
|
|
140
|
+
|
|
141
|
+
`rich.Console` detects the terminal: full color + box when attached to a
|
|
142
|
+
TTY, plain text when piped or redirected (and it honors NO_COLOR), so a
|
|
143
|
+
`golemorph --list-origins | grep` stays clean on its own.
|
|
144
|
+
"""
|
|
145
|
+
from rich.console import Console
|
|
146
|
+
from rich.table import Table
|
|
147
|
+
|
|
148
|
+
# Bucket origins by region, preserving the loader's alphabetical order
|
|
149
|
+
# within each group.
|
|
150
|
+
groups: dict[str, list] = {}
|
|
151
|
+
for p in origins():
|
|
152
|
+
groups.setdefault(p.region, []).append(p)
|
|
153
|
+
|
|
154
|
+
ordered = [r for r in _REGION_ORDER if r in groups]
|
|
155
|
+
ordered += sorted(r for r in groups if r not in _REGION_ORDER)
|
|
156
|
+
|
|
157
|
+
if group != "all":
|
|
158
|
+
wanted = _GROUP_SLUGS[group]
|
|
159
|
+
ordered = [r for r in ordered if r == wanted]
|
|
160
|
+
|
|
161
|
+
# Fixed per-column widths, computed once across *all* origins, so every
|
|
162
|
+
# region table lines up to the same total width instead of each shrinking
|
|
163
|
+
# to its own group's content.
|
|
164
|
+
headers = ("LABEL", "CODE", "LANG", "DIAL")
|
|
165
|
+
all_rows = [
|
|
166
|
+
(p.label, p.code, p.language, p.dial_code) for p in origins()
|
|
167
|
+
]
|
|
168
|
+
widths = [
|
|
169
|
+
max(len(headers[i]), *(len(r[i]) for r in all_rows))
|
|
170
|
+
for i in range(len(headers))
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
console = Console()
|
|
174
|
+
console.print() # blank line before the listing
|
|
175
|
+
for i, region in enumerate(ordered):
|
|
176
|
+
if i:
|
|
177
|
+
console.print() # blank line between tables
|
|
178
|
+
members = groups[region]
|
|
179
|
+
table = Table(
|
|
180
|
+
title=f"{region} ({len(members)})",
|
|
181
|
+
header_style="bold cyan",
|
|
182
|
+
title_style="bold magenta",
|
|
183
|
+
title_justify="left",
|
|
184
|
+
)
|
|
185
|
+
table.add_column("LABEL", style="green", width=widths[0])
|
|
186
|
+
table.add_column("CODE", style="yellow", no_wrap=True, width=widths[1])
|
|
187
|
+
table.add_column("LANG", no_wrap=True, width=widths[2])
|
|
188
|
+
table.add_column("DIAL", style="dim", no_wrap=True, width=widths[3])
|
|
189
|
+
for p in members:
|
|
190
|
+
table.add_row(p.label, p.code, p.language, p.dial_code)
|
|
191
|
+
console.print(table)
|
|
192
|
+
console.print() # blank line after the listing
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _write(format: str, personas: list[Persona], stream) -> None:
|
|
196
|
+
"""Serialize personas in the requested export format."""
|
|
197
|
+
if format == "name":
|
|
198
|
+
# Human-facing default: a rich table with the headline persona fields.
|
|
199
|
+
# (The csv/json/gophish formats stay machine-clean for piping.)
|
|
200
|
+
from rich.console import Console
|
|
201
|
+
from rich.table import Table
|
|
202
|
+
|
|
203
|
+
table = Table(header_style="bold cyan")
|
|
204
|
+
table.add_column("NAME", style="bold green")
|
|
205
|
+
table.add_column("SEX", no_wrap=True)
|
|
206
|
+
table.add_column("AGE", justify="right", no_wrap=True)
|
|
207
|
+
table.add_column("BORN", justify="right", no_wrap=True)
|
|
208
|
+
table.add_column("CITY")
|
|
209
|
+
table.add_column("LANG", no_wrap=True)
|
|
210
|
+
table.add_column("ROLE", style="dim")
|
|
211
|
+
table.add_column("PHONE", no_wrap=True)
|
|
212
|
+
table.add_column("EMAIL", style="cyan")
|
|
213
|
+
table.add_column("COMMON", justify="right", no_wrap=True)
|
|
214
|
+
for p in personas:
|
|
215
|
+
table.add_row(
|
|
216
|
+
p.full_name, p.gender.value if p.gender else "",
|
|
217
|
+
str(p.age), str(p.birth_year), p.city,
|
|
218
|
+
p.language, p.role, p.phone, p.email, f"{p.commonality}%",
|
|
219
|
+
)
|
|
220
|
+
console = Console(file=stream)
|
|
221
|
+
console.print() # blank line before the table
|
|
222
|
+
console.print(table)
|
|
223
|
+
console.print() # blank line after the table
|
|
224
|
+
elif format == "json":
|
|
225
|
+
json.dump(
|
|
226
|
+
[p.to_dict() for p in personas], stream, ensure_ascii=False, indent=2
|
|
227
|
+
)
|
|
228
|
+
stream.write("\n")
|
|
229
|
+
else: # csv / gophish: tabular exports keyed by their header row
|
|
230
|
+
rows = [
|
|
231
|
+
p.to_gophish() if format == "gophish" else p.to_dict() for p in personas
|
|
232
|
+
]
|
|
233
|
+
writer = csv.DictWriter(stream, fieldnames=list(rows[0]) if rows else [])
|
|
234
|
+
writer.writeheader()
|
|
235
|
+
writer.writerows(rows)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main(argv: list[str] | None = None) -> None:
|
|
240
|
+
args = build_parser().parse_args(argv)
|
|
241
|
+
if args.list_origins is not None:
|
|
242
|
+
_print_origins(args.list_origins)
|
|
243
|
+
return
|
|
244
|
+
try:
|
|
245
|
+
personas = generate_personas(
|
|
246
|
+
args.origin,
|
|
247
|
+
args.count,
|
|
248
|
+
Gender(args.gender) if args.gender else None,
|
|
249
|
+
seed=args.seed,
|
|
250
|
+
weighted=not args.unweighted,
|
|
251
|
+
unique=args.unique,
|
|
252
|
+
min_percentile=args.min_common,
|
|
253
|
+
max_percentile=args.max_common,
|
|
254
|
+
)
|
|
255
|
+
except ValueError as exc:
|
|
256
|
+
# Known, user-facing failures (unknown origin, pool too small for
|
|
257
|
+
# --unique): a clean one-line message, not a traceback.
|
|
258
|
+
raise SystemExit(f"golemorph: error: {exc}") from None
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
with (
|
|
262
|
+
open(args.output, "w", encoding="utf-8", newline="")
|
|
263
|
+
if args.output
|
|
264
|
+
else nullcontext(sys.stdout)
|
|
265
|
+
) as stream:
|
|
266
|
+
_write(args.format, personas, stream)
|
|
267
|
+
except OSError as exc:
|
|
268
|
+
raise SystemExit(f"golemorph: error: cannot write output: {exc}") from None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
if __name__ == "__main__":
|
|
272
|
+
main()
|