JARVIS/nucleo/saber/enriquece/cosecha.py
sito 8e4bc8ad94 JARVIS: asistente de voz local para Linux
Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla
con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin
cuentas ni claves.

Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper
comparten tarjeta y solo caben porque estan dimensionados para ello. El
RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve
de una cache de frases.

Incluye instalador (install.sh), requisitos, y documentacion del stack,
del manejo de root y de las acciones. Los apuntes indexados y el diario NO
se incluyen: son privados y el .gitignore los bloquea.
2026-08-16 15:34:37 +02:00

282 lines
11 KiB
Python
Executable file

#!/usr/bin/env python3
"""Cosecha la metodologia de pentesting que el indexador normal se deja fuera.
./cosecha.py recorre COFRE y escribe datos/cosecha.jsonl
./cosecha.py --cuenta dice que cosecharia, sin escribir
## Por que existe, aparte de indexa.py
`indexa.py` es conservador a proposito: solo mira ficheros cuyo NOMBRE parece
documentacion (readme, notas, guia...) y no baja mas de 3 niveles. Eso funciona
para las herramientas, pero **tira la mejor metodologia que hay en el disco**:
- El `OSCP_Vault` son 72 notas OSCP escritas a mano —`SQL injection.md`,
`Reverse shell.md`, `LFI a RCE.md`, `msfvenom.md`...— con comandos reales y
enlaces cruzados. Ninguna entra: el nombre no lleva "notas/guia", y ademas
viven en `OSCP_APUNTES/OSCP_Vault/02 - Explotacion web/`, a 5 niveles.
- Igual con las cheatsheets y apuntes sueltos de AD, tunneling, privesc.
Medido antes de escribir esto: **0 de 72 notas del Vault estaban en el indice.**
Este cosechador es agresivo donde el otro es prudente: en los perfiles
ofensivos coge TODO .md/.txt que no sea ruido evidente, a la profundidad que
haga falta, y marca de que TEMA es por la carpeta que lo contiene (las del Vault
van numeradas: "01 - Enumeracion", "02 - Explotacion web"...).
No pisa a indexa.py: escribe a un fichero aparte. La union y el deduplicado los
hace reindexa.py.
"""
import argparse
import json
import os
import re
import sys
import unicodedata
def _norm(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", " ", t)
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI)) # .../nucleo
COFRE = os.path.expanduser("~/COFRE")
SALIDA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
# Los perfiles ofensivos: aqui vive el saber de pentesting. En estos se cosecha
# a lo ancho. (LINUX se queda para indexa.py, que ya lo hace bien con man pages.)
PERFILES = ["PENTESTERS", "PHISHERS", "REVERSERS", "DEFACERS", "SNEAKERS",
"HARD-WARERS", "PROTECTORS", "ZAPPERS", "AUTOMATAS"]
# Carpetas de oro: se recorren ENTERAS, sin limite de profundidad, porque su
# valor esta justo en las notas hondas.
ORO = re.compile(r"(OSCP_APUNTES|OSCP_Vault|TELMO_OSCP|RedTeam-Tools|"
r"apuntes|cheat|vault|metodolog|notas)", re.I)
# Lo que no se mira nunca: dependencias, control de versiones, binarios.
IGNORA = re.compile(
r"(^|/)(node_modules|\.git|\.obsidian|vendor|dist|build|__pycache__|"
r"\.venv|venv|site-packages|target|\.cache|\.github)(/|$)", re.I)
# Ficheros que son plantilla o licencia, no saber.
RUIDO = re.compile(r"(code_of_conduct|contributing|changelog|license|copying|"
r"pull_request|issue_template|\.min\.|package-lock)", re.I)
# Fuera de las carpetas de ORO, solo se coge lo que PARECE documentacion por el
# nombre. Es la misma idea que indexa.py, un poco mas ancha (tutorial, writeup,
# walkthrough). Sin esto entra cada .md de cada exploit.
DOC = re.compile(r"(readme|install|usage|apuntes|notas|trucos|howto|tutorial|"
r"walkthrough|writeup|write-up|guide|guia|cheat|manual|tips|"
r"metodolog|documentation|docs?)", re.I)
# Tope de ficheros por herramienta fuera del ORO. exploitdb tiene 29.925 .md:
# es una base de datos, no unos apuntes, y meterla entera ahoga el oro. Si una
# herramienta pasa de esto, se la trata como repo a granel y solo se coge su
# documentacion de primer nivel (README).
CAP_FICHEROS = 25
MIN_FRAGMENTO = 60
MAX_FRAGMENTO = 1200
MAX_PROFUNDIDAD = 4 # niveles bajo el perfil, salvo en carpetas de ORO
# Tope de tamano por fichero. Una nota de metodologia son unos pocos KB; un .txt
# de 300 MB es una wordlist (SecLists y similares llevan .txt de gigabytes) y
# leerlo entero revienta la maquina —la swap de este equipo es de 976 MiB—. Por
# encima de esto no es saber, es un diccionario, y no se lee.
MAX_BYTES = 512 * 1024
def _profundidad(ruta):
return ruta.rstrip("/").count("/")
def _tema(ruta):
"""El tema, deducido de la carpeta. Las del Vault van numeradas."""
for parte in reversed(ruta.split(os.sep)):
# "02 - Explotacion web" -> "explotacion web"
m = re.match(r"^\d{2}\s*[-.]\s*(.+)$", parte)
if m:
return m.group(1).strip().lower()
return ""
def _herramienta(ruta, perfil):
rel = os.path.relpath(ruta, os.path.join(COFRE, perfil)).split(os.sep)
return rel[0] if len(rel) > 1 else perfil
def _prefija(titulo, seccion):
"""Lleva el titulo de la nota delante de la seccion, salvo que ya lo tenga.
Sin esto, un fragmento como "## Deteccion time-based\n' AND SLEEP(5)" no
sabe de que va —¿time-based de que?—. Con "(SQL injection) ..." delante, el
vector y el cerebro tienen el contexto. Se evita duplicar si el titulo ya
aparece en la cabecera de la seccion."""
if titulo and _norm(titulo) not in _norm(seccion[:80]):
return f"({titulo}) {seccion}"
return seccion
def trocea(texto):
"""En fragmentos por encabezado, fusionando los cortos y con el titulo.
Antes se partia por encabezado y se TIRABA toda seccion de menos de 60
caracteres. Eso perdia justo lo mejor: una seccion "## Deteccion time-based"
con `' AND SLEEP(5)-- -` son 50 caracteres, y es oro. Ahora una seccion
corta se PEGA a la siguiente en vez de tirarse, y cada fragmento lleva
delante el titulo de la nota para no quedar sin contexto.
"""
texto = re.sub(r"\A---\n.*?\n---\n", "", texto, flags=re.S)
m = re.search(r"^#\s+(.+)", texto, re.M)
titulo = m.group(1).strip() if m else ""
crudas = [s.strip() for s in re.split(r"\n(?=#{1,3}\s)", texto) if s.strip()]
# fusionar hacia adelante mientras la seccion sea demasiado corta
fundidas, i = [], 0
while i < len(crudas):
sec = crudas[i]
while len(sec) < MIN_FRAGMENTO and i + 1 < len(crudas):
i += 1
sec += "\n\n" + crudas[i]
fundidas.append(sec)
i += 1
# si la ultima quedo corta, se dobla sobre la anterior. Se saca primero y
# se indexa despues: fundidas.pop() en el lado derecho ya habria acortado
# la lista y fundidas[-2] se saldria de rango.
if len(fundidas) >= 2 and len(fundidas[-1]) < MIN_FRAGMENTO:
cola = fundidas.pop()
fundidas[-1] += "\n\n" + cola
for sec in fundidas:
if len(sec) < MIN_FRAGMENTO:
continue # una nota entera diminuta
if len(sec) <= MAX_FRAGMENTO:
yield _prefija(titulo, sec)
else:
buf = ""
for parrafo in re.split(r"\n\s*\n", sec):
if len(buf) + len(parrafo) > MAX_FRAGMENTO and buf:
yield _prefija(titulo, buf.strip())
buf = ""
buf += parrafo + "\n\n"
if len(buf.strip()) >= MIN_FRAGMENTO:
yield _prefija(titulo, buf.strip())
def _candidato(ruta, fich, en_oro):
"""Un fichero vale si es texto, no es ruido, no es enorme, y —fuera del
oro— su nombre parece documentacion."""
if not fich.lower().endswith((".md", ".txt")):
return False
if RUIDO.search(fich):
return False
if not en_oro and not DOC.search(fich):
return False
try:
return os.path.getsize(ruta) <= MAX_BYTES
except OSError:
return False
def ficheros(perfil):
"""Los ficheros a cosechar de un perfil, con el tope por herramienta.
Se recorre cada herramienta (subcarpeta de primer nivel) por separado para
poder contar sus ficheros: si pasa del cap y no es oro, es un repo a granel
(exploitdb) y solo se coge su README, no sus 30.000 entradas.
"""
base = os.path.join(COFRE, perfil)
if not os.path.isdir(base):
return
# sueltos en la raiz del perfil (apuntes-ad-pentest.md, trucos_trampas.md)
for fich in sorted(os.listdir(base)):
ruta = os.path.join(base, fich)
if os.path.isfile(ruta) and _candidato(ruta, fich, en_oro=True):
yield ruta, True
for herr in sorted(os.listdir(base)):
raiz_h = os.path.join(base, herr)
if not os.path.isdir(raiz_h) or herr.startswith("."):
continue
candidatos = []
for raiz, dirs, fichs in os.walk(raiz_h):
if IGNORA.search(raiz):
dirs[:] = []
continue
en_oro = bool(ORO.search(raiz))
if not en_oro and _profundidad(raiz) - _profundidad(raiz_h) > MAX_PROFUNDIDAD:
dirs[:] = []
continue
for fich in sorted(fichs):
ruta = os.path.join(raiz, fich)
if _candidato(ruta, fich, en_oro):
candidatos.append((ruta, en_oro, _profundidad(ruta)))
del_oro = [c for c in candidatos if c[1]]
resto = [c for c in candidatos if not c[1]]
# el oro entra siempre y entero
for ruta, en_oro, _ in del_oro:
yield ruta, True
# el resto, con tope: si es un repo a granel, solo lo mas alto
if len(resto) > CAP_FICHEROS:
resto.sort(key=lambda c: c[2]) # los menos hondos primero
resto = resto[:CAP_FICHEROS]
for ruta, en_oro, _ in resto:
yield ruta, False
def construye(solo_cuenta=False):
salida = []
for perfil in PERFILES:
n_doc = n_frag = 0
for ruta, en_oro in ficheros(perfil):
try:
with open(ruta, encoding="utf-8", errors="ignore") as f:
texto = f.read()
except OSError:
continue
if len(texto.strip()) < MIN_FRAGMENTO:
continue
tema = _tema(ruta)
herr = _herramienta(ruta, perfil)
rel = os.path.relpath(ruta, COFRE)
hubo = False
for trozo in trocea(texto):
salida.append({
"tipo": "metodologia" if en_oro else "apunte",
"herramienta": herr, "perfil": perfil,
"fichero": rel, "tema": tema, "texto": trozo,
})
n_frag += 1
hubo = True
n_doc += hubo
print(f" {perfil:14s} {n_doc:5d} docs {n_frag:6d} fragmentos", flush=True)
oro = sum(1 for e in salida if e["tipo"] == "metodologia")
print(f"\n total: {len(salida)} fragmentos ({oro} de metodologia)", flush=True)
if solo_cuenta:
return 0
os.makedirs(os.path.dirname(SALIDA), exist_ok=True)
with open(SALIDA, "w", encoding="utf-8") as f:
for e in salida:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
print(f" escrito: {SALIDA}", flush=True)
return 0
def main():
p = argparse.ArgumentParser()
p.add_argument("--cuenta", action="store_true")
a = p.parse_args()
return construye(solo_cuenta=a.cuenta)
if __name__ == "__main__":
sys.exit(main())