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/__init__.py +3 -0
- wuptracker/capture.py +342 -0
- wuptracker/cli.py +187 -0
- wuptracker/common.py +382 -0
- wuptracker/config.py +156 -0
- wuptracker/configtool.py +561 -0
- wuptracker/generate.py +282 -0
- wuptracker/llm.py +315 -0
- wuptracker/paths.py +25 -0
- wuptracker/profiles.py +293 -0
- wuptracker-0.1.0.dist-info/METADATA +442 -0
- wuptracker-0.1.0.dist-info/RECORD +15 -0
- wuptracker-0.1.0.dist-info/WHEEL +4 -0
- wuptracker-0.1.0.dist-info/entry_points.txt +2 -0
- wuptracker-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|