- manuales.py cosecha el --help de las herramientas que no traen man page (ffuf, sqlmap, suite impacket, netexec, hydra...) y lo escribe como markdown en conocimiento/09 - Manuales/. Es la referencia de opciones que la man page no da; complementa el cheatsheet de invocaciones comunes. - indexa.py --publico construye saber-publico.jsonl: glosario + pack de conocimiento con los vectores ya calculados, 100% publico y portable. - busca.py usa ese indice publico como fallback cuando no hay indice personal, asi un clon recien hecho consulta al instante sin reindexar nada. - .gitignore deja pasar saber-publico.jsonl; el personal (saber.jsonl) sigue fuera. - docs/rag.md y el README del pack documentan ambas cosas.
206 lines
7.4 KiB
Python
206 lines
7.4 KiB
Python
"""Buscar en el saber de COFRE lo que hace falta para contestar una pregunta.
|
|
|
|
Esto es la mitad "recuperar" de RAG: dada una pregunta, devuelve los fragmentos
|
|
de los apuntes que mas se le parecen, para pasarselos al cerebro. El cerebro
|
|
construye la respuesta desde ESE texto, no desde lo que recuerde, que es lo que
|
|
evita que se invente flags.
|
|
|
|
## Por que se carga una vez y se guarda
|
|
|
|
El indice son 2.866 vectores. Cargarlo y encodear la pregunta cuesta, pero solo
|
|
la primera vez: el modelo y los vectores se quedan en memoria. Una consulta
|
|
despues es un producto escalar contra 2.866 filas, milisegundos.
|
|
|
|
## Por que no una base de datos vectorial
|
|
|
|
FAISS, Chroma y compañia son para millones de vectores. Con 2.866, numpy y un
|
|
producto escalar es mas rapido de arrancar, no añade una dependencia pesada, y
|
|
el indice es un jsonl que se puede abrir y leer con los ojos.
|
|
"""
|
|
import json
|
|
import os
|
|
|
|
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
|
|
# El indice portable que viene en el repo. Se usa tal cual mientras no exista el
|
|
# personal, para que un clon recien hecho ya sepa de pentesting sin reindexar.
|
|
INDICE_PUBLICO = os.path.join(AQUI, "datos", "saber-publico.jsonl")
|
|
|
|
_modelo = None
|
|
_entradas = None
|
|
_matriz = None
|
|
|
|
|
|
def _fuente():
|
|
"""El indice personal si existe; si no, el publico que trae el repo."""
|
|
if os.path.exists(INDICE):
|
|
return INDICE
|
|
if os.path.exists(INDICE_PUBLICO):
|
|
return INDICE_PUBLICO
|
|
return None
|
|
|
|
|
|
def _carga():
|
|
global _modelo, _entradas, _matriz
|
|
if _entradas is not None:
|
|
return _entradas is not False
|
|
|
|
fuente = _fuente()
|
|
if not fuente:
|
|
_entradas = False
|
|
return False
|
|
|
|
import numpy as np
|
|
from model2vec import StaticModel
|
|
|
|
entradas, vectores = [], []
|
|
with open(fuente, encoding="utf-8") as f:
|
|
for l in f:
|
|
try:
|
|
d = json.loads(l)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
v = d.pop("v", None)
|
|
if v is None:
|
|
continue
|
|
entradas.append(d)
|
|
vectores.append(v)
|
|
|
|
_entradas = entradas
|
|
_matriz = np.array(vectores, dtype="float32")
|
|
# normalizar una vez: asi la similitud es un simple producto escalar
|
|
normas = np.linalg.norm(_matriz, axis=1, keepdims=True)
|
|
_matriz = _matriz / np.clip(normas, 1e-9, None)
|
|
_modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
|
|
return True
|
|
|
|
|
|
def _norm(t: str) -> str:
|
|
import re
|
|
import unicodedata
|
|
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)
|
|
|
|
|
|
# El texto normalizado de cada entrada y su nombre, cacheados para el bono.
|
|
_texto_norm = None
|
|
_nombre_norm = None
|
|
|
|
|
|
def _bono_palabras(palabras):
|
|
"""Un vector de bonos, uno por entrada, segun cuantas palabras compartan."""
|
|
import numpy as np
|
|
global _texto_norm, _nombre_norm
|
|
if _texto_norm is None:
|
|
_texto_norm = [set(_norm(e["texto"]).split()) for e in _entradas]
|
|
_nombre_norm = [_norm(e.get("herramienta", "")) for e in _entradas]
|
|
bono = np.zeros(len(_entradas), dtype="float32")
|
|
for i, (palabras_e, nombre) in enumerate(zip(_texto_norm, _nombre_norm)):
|
|
comunes = len(palabras & palabras_e)
|
|
b = 0.08 * comunes # cada palabra compartida suma
|
|
if nombre and nombre in palabras: # y nombrar la herramienta, mucho
|
|
b += 0.5
|
|
bono[i] = b
|
|
return bono
|
|
|
|
|
|
def busca(pregunta: str, cuantos: int = 5, perfil: str = None) -> list:
|
|
"""Los fragmentos mas parecidos a la pregunta, mejor primero.
|
|
|
|
Cada uno lleva su similitud (0-1), de que herramienta y fichero viene, y el
|
|
texto. perfil filtra por carpeta de COFRE si se quiere acotar.
|
|
"""
|
|
if not _carga() or not pregunta.strip():
|
|
return []
|
|
import numpy as np
|
|
|
|
q = _modelo.encode([pregunta])[0]
|
|
q = q / max(float(np.linalg.norm(q)), 1e-9)
|
|
sim = _matriz @ q
|
|
|
|
# Impulso por palabras exactas (busqueda hibrida).
|
|
#
|
|
# Los embeddings estaticos fallan cuando la pregunta va en castellano y el
|
|
# texto en ingles —"cambiar permisos" no se parece a "change file mode
|
|
# bits"—. Pero si la pregunta NOMBRA la herramienta ("como uso chmod") o
|
|
# comparte palabras con el texto, eso es una señal fuerte que la similitud
|
|
# semantica sola no aprovecha. Se suma un bono por cada palabra de la
|
|
# pregunta que aparezca, y uno grande si coincide el nombre de la
|
|
# herramienta: asi "usa nmap para..." lleva nmap al primer puesto.
|
|
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
|
|
if palabras:
|
|
bono = _bono_palabras(palabras)
|
|
sim = sim + bono
|
|
|
|
orden = np.argsort(-sim)[: cuantos * 6]
|
|
salida, vistos = [], set()
|
|
for i in orden:
|
|
e = _entradas[i]
|
|
if perfil and e.get("perfil") != perfil:
|
|
continue
|
|
# sin duplicados: OSCP_APUNTES es copia de los apuntes de primer nivel.
|
|
# Se comparan los primeros 120 caracteres, que basta para reconocerlos.
|
|
firma = e["texto"][:120]
|
|
if firma in vistos:
|
|
continue
|
|
vistos.add(firma)
|
|
salida.append({**e, "sim": round(float(sim[i]), 3)})
|
|
if len(salida) >= cuantos:
|
|
break
|
|
return salida
|
|
|
|
|
|
def contexto(pregunta: str, cuantos: int = 5, minimo: float = 0.35) -> str:
|
|
"""Lo mismo, pero ya montado como texto para meterle al cerebro.
|
|
|
|
Se corta por debajo de `minimo` de similitud: pasarle al modelo fragmentos
|
|
que no vienen a cuento es lo que le hace mezclar herramientas que no tienen
|
|
nada que ver. Mejor darle poco y bueno que mucho y ruidoso.
|
|
"""
|
|
trozos = [t for t in busca(pregunta, cuantos) if t["sim"] >= minimo]
|
|
if not trozos:
|
|
return ""
|
|
lineas = ["De los apuntes de COFRE del usuario:\n"]
|
|
for t in trozos:
|
|
fuente = t.get("fichero") or f"{t['perfil']}/{t['herramienta']}"
|
|
lineas.append(f"--- {fuente} ---\n{t['texto']}\n")
|
|
return "\n".join(lineas)
|
|
|
|
|
|
def resumen(tope_temas: int = 10) -> str:
|
|
"""Un resumen de QUE hay indexado, para que el modelo sepa de que sabe.
|
|
|
|
Se deriva del indice de VERDAD, no de una lista fija en el prompt. Asi es
|
|
honesto en cualquier maquina: en la del usuario dice sus areas reales; en un
|
|
clon sin apuntes propios dira lo que haya (comandos de Linux) o nada. Lo usa
|
|
el cerebro para el prompt de sistema; evita que presuma de un conocimiento
|
|
que en esa maquina no tiene."""
|
|
if not _carga() or not _entradas:
|
|
return ""
|
|
from collections import Counter
|
|
perfiles, temas = Counter(), Counter()
|
|
n_comandos = 0
|
|
for e in _entradas:
|
|
t = e.get("tipo")
|
|
if t == "comando":
|
|
n_comandos += 1
|
|
elif t in ("apunte", "metodologia", "ficha", "pregunta") and e.get("perfil"):
|
|
perfiles[e["perfil"]] += 1
|
|
if e.get("tema"):
|
|
temas[e["tema"]] += 1
|
|
partes = []
|
|
if perfiles:
|
|
partes.append("apuntes de " + ", ".join(p for p, _ in perfiles.most_common(6)))
|
|
if temas:
|
|
partes.append("metodologia en " + ", ".join(t for t, _ in temas.most_common(tope_temas)))
|
|
if n_comandos:
|
|
partes.append(f"{n_comandos} comandos de Linux")
|
|
if not partes:
|
|
return ""
|
|
return ("En los apuntes que tienes indexados (buscalos con buscar_en_apuntes) "
|
|
"hay: " + "; ".join(partes) + ".")
|
|
|
|
|
|
def disponible() -> bool:
|
|
return _fuente() is not None
|