wuptracker 0.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.
wuptracker/common.py ADDED
@@ -0,0 +1,382 @@
1
+ """Utilidades compartilhadas entre capture.py e generate.py."""
2
+
3
+ import base64
4
+ import io
5
+ import os
6
+ import platform
7
+ import re
8
+ import subprocess
9
+ import sys
10
+
11
+ from dotenv import load_dotenv
12
+
13
+ from . import config
14
+ from . import paths
15
+
16
+ load_dotenv(paths.ENV_FILE)
17
+ load_dotenv() # também o .env do diretório atual, se houver (dev/legado)
18
+
19
+ PHASES = [
20
+ "reconhecimento", "enumeracao", "acesso_inicial", "pos_exploracao",
21
+ "privesc", "web", "crypto", "stego", "misc",
22
+ ]
23
+
24
+
25
+ def slugify(text):
26
+ text = text.strip().lower()
27
+ text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE)
28
+ text = re.sub(r"[\s_-]+", "-", text)
29
+ return text.strip("-") or "sessao"
30
+
31
+
32
+ def list_sessions():
33
+ """Lista as sessões em SESSIONS_DIR, mais recentes primeiro.
34
+
35
+ Cada item: name, dir, room, profile, started_at, duration, captures (int),
36
+ pending (int, capturas sem .json), ongoing (bool), has_writeup (bool).
37
+ """
38
+ import json
39
+
40
+ base = config.SESSIONS_DIR
41
+ out = []
42
+ if not os.path.isdir(base):
43
+ return out
44
+ for name in os.listdir(base):
45
+ sdir = os.path.join(base, name)
46
+ meta_path = os.path.join(sdir, "meta.json")
47
+ if not os.path.isfile(meta_path):
48
+ continue
49
+ try:
50
+ with open(meta_path, encoding="utf-8") as f:
51
+ meta = json.load(f)
52
+ except (OSError, ValueError):
53
+ meta = {}
54
+ caps_dir = os.path.join(sdir, "captures")
55
+ pngs = [f for f in os.listdir(caps_dir)] if os.path.isdir(caps_dir) else []
56
+ shots = sorted(f[:-4] for f in pngs if f.endswith(".png")
57
+ and not f.endswith("_crop.png"))
58
+ analysed = {f[:-5] for f in pngs if f.endswith(".json")}
59
+ alive = False
60
+ try:
61
+ with open(os.path.join(sdir, "capture.pid")) as f:
62
+ os.kill(int(f.read().strip()), 0)
63
+ alive = True
64
+ except (OSError, ValueError):
65
+ alive = False
66
+ wbase = os.path.join(config.WRITEUPS_DIR, name)
67
+ has_writeup = os.path.isdir(wbase) or any(
68
+ f == f"{name}.md" or f.startswith(f"{name}-")
69
+ for f in (os.listdir(config.WRITEUPS_DIR)
70
+ if os.path.isdir(config.WRITEUPS_DIR) else []))
71
+ out.append({
72
+ "name": name,
73
+ "dir": sdir,
74
+ "room": meta.get("room", name),
75
+ "profile": meta.get("profile", "?"),
76
+ "started_at": meta.get("started_at", ""),
77
+ "duration": meta.get("duration"),
78
+ "captures": len(shots),
79
+ "pending": sum(1 for s in shots if s not in analysed),
80
+ "ongoing": alive,
81
+ "crashed": "ended_at" not in meta and not alive,
82
+ "has_writeup": has_writeup,
83
+ })
84
+ out.sort(key=lambda s: s["started_at"], reverse=True)
85
+ return out
86
+
87
+
88
+ def fmt_elapsed(seconds):
89
+ seconds = int(seconds)
90
+ h, rem = divmod(seconds, 3600)
91
+ m, s = divmod(rem, 60)
92
+ return f"{h:02d}:{m:02d}:{s:02d}"
93
+
94
+
95
+ def png_to_jpeg_b64(png_path, quality=None):
96
+ """Converte um PNG para JPEG base64 para reduzir o payload da API."""
97
+ from PIL import Image
98
+
99
+ quality = quality or config.SCREENSHOT_QUALITY
100
+ with Image.open(png_path) as im:
101
+ im = im.convert("RGB")
102
+ buf = io.BytesIO()
103
+ im.save(buf, format="JPEG", quality=quality)
104
+ return base64.b64encode(buf.getvalue()).decode("ascii")
105
+
106
+
107
+ def cursor_pos():
108
+ """Posição (x, y) global do cursor, ou None se não for possível obter."""
109
+ try:
110
+ from pynput.mouse import Controller
111
+
112
+ x, y = Controller().position
113
+ return int(x), int(y)
114
+ except Exception:
115
+ return None
116
+
117
+
118
+ def grab_screen(png_path, active_only=None):
119
+ """Captura a tela para png_path.
120
+
121
+ active_only=True -> só o monitor onde o cursor está (fallback: tela toda)
122
+ active_only=False -> todos os monitores
123
+ active_only=None -> usa config.CAPTURE_ACTIVE_MONITOR_ONLY
124
+ """
125
+ if active_only is None:
126
+ active_only = getattr(config, "CAPTURE_ACTIVE_MONITOR_ONLY", True)
127
+
128
+ errors = []
129
+ wayland = bool(os.environ.get("WAYLAND_DISPLAY")) or \
130
+ os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
131
+
132
+ # Em Wayland o mss/X11 não capturam o compositor real.
133
+ if wayland:
134
+ from shutil import which
135
+
136
+ # spectacle: -m = monitor atual (sob o cursor); -f = tela cheia
137
+ spectacle_mode = "-m" if active_only else "-f"
138
+ wl = [
139
+ ("spectacle", ["spectacle", "-b", "-n", spectacle_mode, "-o", png_path]),
140
+ ("grim", ["grim", png_path]), # wlroots
141
+ ("gnome-screenshot", ["gnome-screenshot", "-f", png_path]), # GNOME
142
+ ]
143
+ for name, cmd in wl:
144
+ if not which(name):
145
+ continue
146
+ try:
147
+ subprocess.run(cmd, check=True, capture_output=True, timeout=25)
148
+ if os.path.exists(png_path) and os.path.getsize(png_path) > 0:
149
+ return name
150
+ errors.append(f"{name}: não gerou arquivo")
151
+ except Exception as e:
152
+ errors.append(f"{name}: {e}")
153
+ raise RuntimeError(
154
+ "Falha ao capturar tela no Wayland — " + " | ".join(errors) +
155
+ ". Instale 'spectacle' (KDE), 'grim' (wlroots) ou 'gnome-screenshot'.")
156
+
157
+ # X11: mss — captura por monitor (o retângulo-união falha em multi-monitor).
158
+ try:
159
+ import mss
160
+ from PIL import Image
161
+
162
+ with mss.mss() as sct:
163
+ mons = sct.monitors[1:] or [sct.monitors[0]]
164
+
165
+ target = None
166
+ if active_only:
167
+ pos = cursor_pos()
168
+ if pos:
169
+ for m in mons:
170
+ if (m["left"] <= pos[0] < m["left"] + m["width"] and
171
+ m["top"] <= pos[1] < m["top"] + m["height"]):
172
+ target = m
173
+ break
174
+ if target is None and len(mons) == 1:
175
+ target = mons[0]
176
+
177
+ if target is not None:
178
+ s = sct.grab(target)
179
+ Image.frombytes("RGB", s.size, s.rgb).save(png_path)
180
+ elif len(mons) == 1:
181
+ s = sct.grab(mons[0])
182
+ Image.frombytes("RGB", s.size, s.rgb).save(png_path)
183
+ else:
184
+ imgs = [(m, sct.grab(m)) for m in mons]
185
+ left = min(m["left"] for m, _ in imgs)
186
+ top = min(m["top"] for m, _ in imgs)
187
+ width = max(m["left"] + m["width"] for m, _ in imgs) - left
188
+ height = max(m["top"] + m["height"] for m, _ in imgs) - top
189
+ canvas = Image.new("RGB", (width, height), "black")
190
+ for m, s in imgs:
191
+ canvas.paste(Image.frombytes("RGB", s.size, s.rgb),
192
+ (m["left"] - left, m["top"] - top))
193
+ canvas.save(png_path)
194
+ return "mss"
195
+ except Exception as e:
196
+ errors.append(f"mss: {e}")
197
+
198
+ # X11: ferramentas de linha de comando (captura a tela toda)
199
+ from shutil import which
200
+
201
+ candidates = [
202
+ ("scrot", ["scrot", "-o", png_path]),
203
+ ("maim", ["maim", png_path]),
204
+ ("import", ["import", "-window", "root", png_path]),
205
+ ("spectacle", ["spectacle", "-b", "-n", "-f", "-o", png_path]),
206
+ ("gnome-screenshot", ["gnome-screenshot", "-f", png_path]),
207
+ ]
208
+ for name, cmd in candidates:
209
+ if not which(name):
210
+ continue
211
+ try:
212
+ subprocess.run(cmd, check=True, capture_output=True, timeout=20)
213
+ if os.path.exists(png_path) and os.path.getsize(png_path) > 0:
214
+ return name
215
+ errors.append(f"{name}: não gerou arquivo")
216
+ except Exception as e:
217
+ errors.append(f"{name}: {e}")
218
+
219
+ raise RuntimeError("Falha ao capturar tela — " + " | ".join(errors))
220
+
221
+
222
+ def image_size(path):
223
+ """(width, height) de uma imagem, ou (0, 0) se não der para abrir."""
224
+ try:
225
+ from PIL import Image
226
+
227
+ with Image.open(path) as im:
228
+ return im.size
229
+ except Exception:
230
+ return (0, 0)
231
+
232
+
233
+ def optimize_image(src, dst_stem, max_width=None, quality=None):
234
+ """Salva uma versão leve de `src`: redimensiona para max_width e converte
235
+ para WebP (ou PNG otimizado se WebP indisponível). `dst_stem` é o caminho
236
+ SEM extensão. Retorna o caminho final gravado, ou None se falhar."""
237
+ from PIL import Image
238
+
239
+ if max_width is None:
240
+ max_width = getattr(config, "IMAGE_MAX_WIDTH", 1600)
241
+ if quality is None:
242
+ quality = getattr(config, "WEBP_QUALITY", 80)
243
+ want_webp = getattr(config, "WEBP_IMAGES", True)
244
+
245
+ try:
246
+ with Image.open(src) as im:
247
+ im = im.convert("RGB")
248
+ if max_width and im.width > max_width:
249
+ h = round(im.height * max_width / im.width)
250
+ im = im.resize((max_width, h), Image.LANCZOS)
251
+ if want_webp:
252
+ try:
253
+ out = dst_stem + ".webp"
254
+ im.save(out, "WEBP", quality=quality, method=6)
255
+ return out
256
+ except Exception:
257
+ pass
258
+ out = dst_stem + ".png"
259
+ im.save(out, optimize=True)
260
+ return out
261
+ except Exception:
262
+ return None
263
+
264
+
265
+ def crop_image(src_png, dst_png, box, pad=None):
266
+ """Recorta src_png para a região `box` (frações 0..1: x, y, w, h) e salva
267
+ em dst_png. Retorna True se recortou, False se a região é inválida/grande
268
+ demais (nesse caso não gera arquivo)."""
269
+ from PIL import Image
270
+
271
+ try:
272
+ x, y = float(box["x"]), float(box["y"])
273
+ w, h = float(box["w"]), float(box["h"])
274
+ except (KeyError, TypeError, ValueError):
275
+ return False
276
+ if not (0 <= x < 1 and 0 <= y < 1 and 0 < w <= 1 and 0 < h <= 1):
277
+ return False
278
+ # região cobre quase tudo -> não vale a pena recortar
279
+ if w * h >= 0.9:
280
+ return False
281
+
282
+ if pad is None:
283
+ pad = getattr(config, "CROP_PADDING", 0.03)
284
+ x0 = max(0.0, x - pad)
285
+ y0 = max(0.0, y - pad)
286
+ x1 = min(1.0, x + w + pad)
287
+ y1 = min(1.0, y + h + pad)
288
+
289
+ with Image.open(src_png) as im:
290
+ W, H = im.size
291
+ box_px = (int(x0 * W), int(y0 * H), int(x1 * W), int(y1 * H))
292
+ cw, ch = box_px[2] - box_px[0], box_px[3] - box_px[1]
293
+ if cw < 60 or ch < 60:
294
+ return False
295
+ # recorte já com a margem cobre quase tudo -> não compensa
296
+ if (cw * ch) / (W * H) >= 0.85:
297
+ return False
298
+ im.crop(box_px).save(dst_png)
299
+ return True
300
+
301
+
302
+ def check_capture_backend():
303
+ """Verifica no início se há algum backend de captura disponível."""
304
+ from shutil import which
305
+
306
+ wayland = bool(os.environ.get("WAYLAND_DISPLAY")) or \
307
+ os.environ.get("XDG_SESSION_TYPE", "").lower() == "wayland"
308
+
309
+ if wayland:
310
+ for t in ("spectacle", "grim", "gnome-screenshot"):
311
+ if which(t):
312
+ return t
313
+ return None
314
+
315
+ try:
316
+ import mss # noqa: F401
317
+ import PIL # noqa: F401
318
+
319
+ return "mss"
320
+ except Exception:
321
+ pass
322
+ for t in ("scrot", "maim", "import", "spectacle", "gnome-screenshot"):
323
+ if which(t):
324
+ return t
325
+ return None
326
+
327
+
328
+ def notify(title, message):
329
+ if not config.SHOW_NOTIFICATION:
330
+ return
331
+ try:
332
+ subprocess.run(["notify-send", title, message, "--icon=camera"],
333
+ check=False, capture_output=True, timeout=5)
334
+ except Exception:
335
+ try:
336
+ from plyer import notification
337
+
338
+ notification.notify(title=title, message=message, timeout=3)
339
+ except Exception:
340
+ pass
341
+
342
+
343
+ def beep():
344
+ if config.PLAY_SOUND:
345
+ print("\a", end="", flush=True)
346
+
347
+
348
+ def system_name():
349
+ return platform.system().lower()
350
+
351
+
352
+ def open_in_editor(path):
353
+ try:
354
+ if sys.platform == "darwin":
355
+ subprocess.run(["open", path], check=False)
356
+ elif os.name == "nt":
357
+ os.startfile(path) # type: ignore[attr-defined]
358
+ else:
359
+ subprocess.run(["xdg-open", path], check=False)
360
+ except Exception:
361
+ pass
362
+
363
+
364
+ # Cores ANSI
365
+ def c_green(s):
366
+ return f"\033[92m{s}\033[0m"
367
+
368
+
369
+ def c_yellow(s):
370
+ return f"\033[93m{s}\033[0m"
371
+
372
+
373
+ def c_red(s):
374
+ return f"\033[91m{s}\033[0m"
375
+
376
+
377
+ def c_cyan(s):
378
+ return f"\033[96m{s}\033[0m"
379
+
380
+
381
+ def c_dim(s):
382
+ return f"\033[2m{s}\033[0m"
wuptracker/config.py ADDED
@@ -0,0 +1,156 @@
1
+ """Configurações da ferramenta de captura de writeups.
2
+
3
+ Os valores abaixo são os padrões. Um arquivo de config em
4
+ ~/.config/wuptracker/config.json (gerenciado por `wuptracker config set ...`)
5
+ sobrepõe qualquer chave MAIÚSCULA.
6
+ """
7
+
8
+ import json as _json
9
+ import os as _os
10
+
11
+ from . import paths as _paths
12
+
13
+ # Tecla de atalho para capturar a tela (nome de pynput.keyboard.Key, ex: "f9")
14
+ CAPTURE_KEY = "f9"
15
+
16
+ # Tecla para encerrar a sessão
17
+ EXIT_KEY = "f10"
18
+
19
+ # Qualidade do screenshot ao converter para JPEG no envio à API (1-100)
20
+ SCREENSHOT_QUALITY = 85
21
+
22
+ # Capturar apenas o monitor onde o cursor do mouse está (setups multi-monitor).
23
+ # Se False, captura todos os monitores juntos.
24
+ CAPTURE_ACTIVE_MONITOR_ONLY = True
25
+
26
+ # Recortar a imagem para a região relevante indicada pelo modelo de visão.
27
+ # O screenshot original é sempre preservado; o recorte vira <nome>_crop.png.
28
+ CROP_IMAGES = True
29
+
30
+ # Margem extra em volta do recorte (fração da dimensão, 0.03 = 3%)
31
+ CROP_PADDING = 0.03
32
+
33
+ # Embutir as imagens (recortadas quando houver) no writeup gerado.
34
+ # As imagens usadas são copiadas para writeups/<sessão>/assets/
35
+ EMBED_IMAGES = True
36
+
37
+ # Otimizar as imagens do writeup: converter para WebP (bem mais leve que PNG).
38
+ # Se o WebP não estiver disponível no Pillow, cai para PNG otimizado.
39
+ WEBP_IMAGES = True
40
+
41
+ # Qualidade do WebP (0-100). 80 costuma ser indistinguível e ~5x menor.
42
+ WEBP_QUALITY = 80
43
+
44
+ # Largura máxima das imagens do writeup em pixels (redimensiona mantendo proporção).
45
+ # 0 = não redimensionar.
46
+ IMAGE_MAX_WIDTH = 1600
47
+
48
+ # Pasta base para sessões (dado da ferramenta -> XDG_DATA_HOME)
49
+ SESSIONS_DIR = _paths.SESSIONS_DIR
50
+
51
+ # Pasta para writeups gerados — relativa ao diretório onde você roda o comando
52
+ # (o writeup é um artefato do seu trabalho ali, não um dado global da ferramenta)
53
+ WRITEUPS_DIR = "writeups"
54
+
55
+ # Perfil padrão do writeup quando não passado na linha de comando:
56
+ # "thm" -> pentest/CTF (fases, flags, privesc)
57
+ # "generico" -> registro cronológico de qualquer sessão de trabalho
58
+ # Pode ser sobrescrito com: python capture.py "Nome" --profile generico
59
+ DEFAULT_PROFILE = "thm"
60
+
61
+ # Estilo padrão do writeup (generate.py --style ...):
62
+ # tecnico | corrido | resumo | blog | linkedin
63
+ # generate.py aceita vários de uma vez: --style tecnico,linkedin
64
+ DEFAULT_STYLE = "tecnico"
65
+
66
+ # Backend do LLM (wuptracker config backend ...):
67
+ # "claude_cli" -> CLI do Claude Code (assinatura Pro/Max, sem custo por token,
68
+ # sem API key). Requer o comando `claude` no PATH.
69
+ # "anthropic" -> API da Anthropic (ANTHROPIC_API_KEY). Cobra por token.
70
+ # "ollama" -> modelo local via Ollama. Grátis, offline, privado.
71
+ # "openai" -> endpoint compatível com a API OpenAI (OpenAI, Groq,
72
+ # OpenRouter, LM Studio, llama.cpp server...).
73
+ BACKEND = "claude_cli"
74
+
75
+ # --- backend "anthropic" ---
76
+ CLAUDE_MODEL = "claude-sonnet-5"
77
+
78
+ # --- backend "claude_cli" --- (None = usa o modelo padrão do seu CLI)
79
+ CLAUDE_CLI_MODEL = None
80
+
81
+ # --- backend "ollama" ---
82
+ OLLAMA_HOST = "http://localhost:11434"
83
+ OLLAMA_MODEL = "llama3.2-vision" # precisa ser um modelo com visão
84
+
85
+ # --- backend "openai" (compatível) ---
86
+ OPENAI_BASE_URL = "https://api.openai.com/v1"
87
+ OPENAI_MODEL = "gpt-4o-mini"
88
+ # A chave vai em OPENAI_API_KEY (.env). Servidores locais podem dispensar.
89
+
90
+ # --- backend "gemini" (Google) --- chave em GEMINI_API_KEY (.env), tier grátis
91
+ GEMINI_MODEL = "gemini-2.0-flash"
92
+
93
+ # Máximo de tokens na resposta de geração do writeup
94
+ GENERATE_MAX_TOKENS = 8000
95
+
96
+ # Máximo de tokens na análise de cada frame
97
+ ANALYZE_MAX_TOKENS = 1024
98
+
99
+ # Som de confirmação ao capturar
100
+ PLAY_SOUND = True
101
+
102
+ # Notificação desktop ao capturar
103
+ SHOW_NOTIFICATION = True
104
+
105
+ # Abrir o writeup no editor padrão após gerar
106
+ OPEN_AFTER_GENERATE = False
107
+
108
+ # A chave da API é lida de ANTHROPIC_API_KEY (.env ou ambiente). Nunca hardcodar.
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # Overlay local (~/.config/wuptracker/config.json) — não editar à mão,
112
+ # use `wuptracker config`
113
+ # ---------------------------------------------------------------------------
114
+ CONFIG_LOCAL_PATH = _paths.CONFIG_FILE
115
+ _DEFAULTS = {k: v for k, v in dict(globals()).items() if k.isupper()}
116
+
117
+
118
+ def load_local():
119
+ try:
120
+ with open(CONFIG_LOCAL_PATH, encoding="utf-8") as f:
121
+ return _json.load(f)
122
+ except (OSError, ValueError):
123
+ return {}
124
+
125
+
126
+ def save_local(data):
127
+ if data:
128
+ _paths.ensure_dirs()
129
+ with open(CONFIG_LOCAL_PATH, "w", encoding="utf-8") as f:
130
+ _json.dump(data, f, indent=2, ensure_ascii=False)
131
+ elif _os.path.exists(CONFIG_LOCAL_PATH):
132
+ _os.remove(CONFIG_LOCAL_PATH)
133
+
134
+
135
+ def set_local(key, value):
136
+ data = load_local()
137
+ data[key] = value
138
+ save_local(data)
139
+ globals()[key] = value
140
+
141
+
142
+ def unset_local(key):
143
+ data = load_local()
144
+ data.pop(key, None)
145
+ save_local(data)
146
+ if key in _DEFAULTS:
147
+ globals()[key] = _DEFAULTS[key]
148
+
149
+
150
+ def default_of(key):
151
+ return _DEFAULTS.get(key)
152
+
153
+
154
+ for _k, _v in load_local().items():
155
+ if _k.isupper():
156
+ globals()[_k] = _v