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.
Files changed (104) hide show
  1. golemorph/__init__.py +29 -0
  2. golemorph/__main__.py +6 -0
  3. golemorph/cli.py +272 -0
  4. golemorph/data/ALG/FirstName.csv +501 -0
  5. golemorph/data/ALG/Surname.csv +501 -0
  6. golemorph/data/ARG/FirstName.csv +501 -0
  7. golemorph/data/ARG/Surname.csv +501 -0
  8. golemorph/data/AUT/FirstName.csv +501 -0
  9. golemorph/data/AUT/Surname.csv +501 -0
  10. golemorph/data/BEL/FirstName.csv +501 -0
  11. golemorph/data/BEL/Surname.csv +501 -0
  12. golemorph/data/BGR/FirstName.csv +501 -0
  13. golemorph/data/BGR/Surname.csv +501 -0
  14. golemorph/data/BRA/FirstName.csv +501 -0
  15. golemorph/data/BRA/Surname.csv +501 -0
  16. golemorph/data/CAN/FirstName.csv +501 -0
  17. golemorph/data/CAN/Surname.csv +501 -0
  18. golemorph/data/CHN/FirstName.csv +501 -0
  19. golemorph/data/CHN/Surname.csv +501 -0
  20. golemorph/data/COL/FirstName.csv +501 -0
  21. golemorph/data/COL/Surname.csv +501 -0
  22. golemorph/data/CZE/FirstName.csv +501 -0
  23. golemorph/data/CZE/Surname.csv +501 -0
  24. golemorph/data/DEU/FirstName.csv +501 -0
  25. golemorph/data/DEU/Surname.csv +501 -0
  26. golemorph/data/DNK/FirstName.csv +501 -0
  27. golemorph/data/DNK/Surname.csv +501 -0
  28. golemorph/data/EGY/FirstName.csv +501 -0
  29. golemorph/data/EGY/Surname.csv +501 -0
  30. golemorph/data/ESP/FirstName.csv +501 -0
  31. golemorph/data/ESP/Surname.csv +501 -0
  32. golemorph/data/FIN/FirstName.csv +501 -0
  33. golemorph/data/FIN/Surname.csv +501 -0
  34. golemorph/data/FRA/FirstName.csv +501 -0
  35. golemorph/data/FRA/Surname.csv +501 -0
  36. golemorph/data/GBR/FirstName.csv +501 -0
  37. golemorph/data/GBR/Surname.csv +501 -0
  38. golemorph/data/GRC/FirstName.csv +501 -0
  39. golemorph/data/GRC/Surname.csv +501 -0
  40. golemorph/data/HRV/FirstName.csv +501 -0
  41. golemorph/data/HRV/Surname.csv +501 -0
  42. golemorph/data/HUN/FirstName.csv +501 -0
  43. golemorph/data/HUN/Surname.csv +501 -0
  44. golemorph/data/IDN/FirstName.csv +501 -0
  45. golemorph/data/IDN/Surname.csv +501 -0
  46. golemorph/data/IND/FirstName.csv +501 -0
  47. golemorph/data/IND/Surname.csv +501 -0
  48. golemorph/data/IRL/FirstName.csv +501 -0
  49. golemorph/data/IRL/Surname.csv +501 -0
  50. golemorph/data/ITA/FirstName.csv +501 -0
  51. golemorph/data/ITA/Surname.csv +501 -0
  52. golemorph/data/JPN/FirstName.csv +501 -0
  53. golemorph/data/JPN/Surname.csv +501 -0
  54. golemorph/data/KOR/FirstName.csv +501 -0
  55. golemorph/data/KOR/Surname.csv +501 -0
  56. golemorph/data/MEX/FirstName.csv +501 -0
  57. golemorph/data/MEX/Surname.csv +501 -0
  58. golemorph/data/MRN/FirstName.csv +501 -0
  59. golemorph/data/MRN/Surname.csv +501 -0
  60. golemorph/data/MYS/FirstName.csv +501 -0
  61. golemorph/data/MYS/Surname.csv +501 -0
  62. golemorph/data/NGA/FirstName.csv +501 -0
  63. golemorph/data/NGA/Surname.csv +501 -0
  64. golemorph/data/NLD/FirstName.csv +501 -0
  65. golemorph/data/NLD/Surname.csv +501 -0
  66. golemorph/data/NOR/FirstName.csv +501 -0
  67. golemorph/data/NOR/Surname.csv +501 -0
  68. golemorph/data/PHL/FirstName.csv +501 -0
  69. golemorph/data/PHL/Surname.csv +501 -0
  70. golemorph/data/POL/FirstName.csv +501 -0
  71. golemorph/data/POL/Surname.csv +501 -0
  72. golemorph/data/PRT/FirstName.csv +501 -0
  73. golemorph/data/PRT/Surname.csv +501 -0
  74. golemorph/data/RUS/FirstName.csv +501 -0
  75. golemorph/data/RUS/Surname.csv +501 -0
  76. golemorph/data/SAU/FirstName.csv +501 -0
  77. golemorph/data/SAU/Surname.csv +501 -0
  78. golemorph/data/SGP/FirstName.csv +501 -0
  79. golemorph/data/SGP/Surname.csv +501 -0
  80. golemorph/data/SUI/FirstName.csv +501 -0
  81. golemorph/data/SUI/Surname.csv +501 -0
  82. golemorph/data/SVN/FirstName.csv +501 -0
  83. golemorph/data/SVN/Surname.csv +501 -0
  84. golemorph/data/SWE/FirstName.csv +501 -0
  85. golemorph/data/SWE/Surname.csv +501 -0
  86. golemorph/data/TUN/FirstName.csv +501 -0
  87. golemorph/data/TUN/Surname.csv +501 -0
  88. golemorph/data/TUR/FirstName.csv +501 -0
  89. golemorph/data/TUR/Surname.csv +501 -0
  90. golemorph/data/USA/FirstName.csv +501 -0
  91. golemorph/data/USA/Surname.csv +501 -0
  92. golemorph/data/ZAF/FirstName.csv +501 -0
  93. golemorph/data/ZAF/Surname.csv +501 -0
  94. golemorph/data/manifest.yaml +996 -0
  95. golemorph/loader.py +201 -0
  96. golemorph/models.py +130 -0
  97. golemorph/persona.py +371 -0
  98. golemorph/sampler.py +54 -0
  99. golemorph-1.1.0.dist-info/METADATA +197 -0
  100. golemorph-1.1.0.dist-info/RECORD +104 -0
  101. golemorph-1.1.0.dist-info/WHEEL +5 -0
  102. golemorph-1.1.0.dist-info/entry_points.txt +2 -0
  103. golemorph-1.1.0.dist-info/licenses/LICENSE +21 -0
  104. 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
@@ -0,0 +1,6 @@
1
+ """Allow `python -m golemorph` alongside the installed `golemorph` script."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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()