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.
This commit is contained in:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
282
nucleo/saber/enriquece/cosecha.py
Executable file
282
nucleo/saber/enriquece/cosecha.py
Executable file
|
|
@ -0,0 +1,282 @@
|
|||
#!/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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue