cwe-explorer 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.
Potentially problematic release.
This version of cwe-explorer might be problematic. Click here for more details.
- cwe_explorer/__init__.py +3 -0
- cwe_explorer/__main__.py +3 -0
- cwe_explorer/cli.py +181 -0
- cwe_explorer/data.py +159 -0
- cwe_explorer/hierarchy.py +297 -0
- cwe_explorer/render_html.py +69 -0
- cwe_explorer/render_term.py +291 -0
- cwe_explorer/tui.py +268 -0
- cwe_explorer/web/logo.png +0 -0
- cwe_explorer/web/viewer.css +617 -0
- cwe_explorer/web/viewer.html +60 -0
- cwe_explorer/web/viewer.js +815 -0
- cwe_explorer-0.1.0.dist-info/METADATA +311 -0
- cwe_explorer-0.1.0.dist-info/RECORD +18 -0
- cwe_explorer-0.1.0.dist-info/WHEEL +5 -0
- cwe_explorer-0.1.0.dist-info/entry_points.txt +3 -0
- cwe_explorer-0.1.0.dist-info/licenses/LICENSE +21 -0
- cwe_explorer-0.1.0.dist-info/top_level.txt +1 -0
cwe_explorer/__init__.py
ADDED
cwe_explorer/__main__.py
ADDED
cwe_explorer/cli.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Punto de entrada: ``cwex 79`` (terminal), ``--tui`` (interactivo) o ``--web`` (navegador)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import webbrowser
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .data import cache_dir, load_catalog
|
|
13
|
+
from .hierarchy import CWENotFound, Hierarchy
|
|
14
|
+
from .render_html import write_html
|
|
15
|
+
from .render_term import BADGES, print_relatives, print_tree, to_ansi, use_color
|
|
16
|
+
|
|
17
|
+
DEFAULT_DEPTH = 2 # árbol, --tui y --web
|
|
18
|
+
DEFAULT_LIST_DEPTH = 1 # --ancestors / --descendants: sólo los directos
|
|
19
|
+
UNSET = object() # distingue «sin --depth» de «--depth all» (None)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_depth(raw: str) -> int | None:
|
|
23
|
+
if raw.lower() in ("all", "todos", "todo", "*"):
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
value = int(raw)
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise argparse.ArgumentTypeError("usa un número >= 0 o 'all'") from None
|
|
29
|
+
if value < 0:
|
|
30
|
+
raise argparse.ArgumentTypeError("usa un número >= 0 o 'all'")
|
|
31
|
+
return value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="cwex",
|
|
37
|
+
description="Muestra la jerarquía de una CWE en la vista 1000 de MITRE "
|
|
38
|
+
"(Complete Weakness Hierarchy) en la terminal o en el navegador.",
|
|
39
|
+
epilog="Ejemplos:\n"
|
|
40
|
+
" cwex 79 árbol en la terminal\n"
|
|
41
|
+
" cwex CWE-79 --tui explorador interactivo (ratón y teclado)\n"
|
|
42
|
+
" cwex 79 --web grafo interactivo en el navegador\n"
|
|
43
|
+
" cwex 664 --depth all todos los descendientes\n"
|
|
44
|
+
" cwex --search overflow buscar por nombre\n"
|
|
45
|
+
" cwex 79 --ancestors padres directos (modo 1)\n"
|
|
46
|
+
" cwex 79 --descendants hijos directos (modo 2)\n"
|
|
47
|
+
" cwex 82 --ancestors --depth all todos los ascendientes hasta el pilar\n"
|
|
48
|
+
" cwex 664 --descendants -d all --json todos los descendientes en JSON",
|
|
49
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument("cwe", nargs="?", help="identificador CWE (79, CWE-79...)")
|
|
52
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
53
|
+
mode = parser.add_mutually_exclusive_group()
|
|
54
|
+
mode.add_argument("--tui", action="store_true", help="explorador interactivo en la terminal")
|
|
55
|
+
mode.add_argument("--web", action="store_true", help="genera el grafo HTML y lo abre en el navegador")
|
|
56
|
+
mode.add_argument("--search", metavar="TEXTO", help="busca debilidades por nombre")
|
|
57
|
+
mode.add_argument("--ancestors", "--ascendientes", dest="relatives", action="store_const", const="ancestors",
|
|
58
|
+
help="lista los ascendientes: por defecto sólo los padres directos (modo 1)")
|
|
59
|
+
mode.add_argument("--descendants", "--descendientes", dest="relatives", action="store_const",
|
|
60
|
+
const="descendants", help="lista los descendientes: por defecto sólo los hijos directos (modo 2)")
|
|
61
|
+
parser.add_argument("-d", "--depth", type=parse_depth, default=UNSET, metavar="N",
|
|
62
|
+
help=f"niveles a recorrer, o 'all'. Por defecto {DEFAULT_DEPTH} en el árbol, "
|
|
63
|
+
f"--tui y --web, y {DEFAULT_LIST_DEPTH} (sólo directos) con --ancestors/--descendants")
|
|
64
|
+
parser.add_argument("--json", action="store_true", help="con --ancestors/--descendants, salida en JSON")
|
|
65
|
+
parser.add_argument("-o", "--output", metavar="FICHERO", help="ruta del HTML generado con --web")
|
|
66
|
+
parser.add_argument("--no-open", action="store_true", help="con --web, no abrir el navegador")
|
|
67
|
+
parser.add_argument("--xml", metavar="RUTA", help="usa un cwec_*.xml o .zip local en vez de descargarlo")
|
|
68
|
+
parser.add_argument("--update", action="store_true", help="vuelve a descargar el catálogo de MITRE")
|
|
69
|
+
color = parser.add_mutually_exclusive_group()
|
|
70
|
+
color.add_argument("--color", dest="color", action="store_true", default=None, help="fuerza colores ANSI")
|
|
71
|
+
color.add_argument("--no-color", dest="color", action="store_false", help="desactiva los colores")
|
|
72
|
+
return parser
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def open_web(h: Hierarchy, cid: int, depth: int | None, output: str | None = None, launch: bool = True) -> str:
|
|
76
|
+
path = write_html(h, cid, depth, output)
|
|
77
|
+
if launch:
|
|
78
|
+
webbrowser.open(path.as_uri())
|
|
79
|
+
return f"Grafo web generado en {path}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def relatives_payload(h: Hierarchy, cid: int, direction: str, depth: int | None) -> dict:
|
|
83
|
+
node = h.nodes[cid]
|
|
84
|
+
rels = h.relatives(cid, direction, depth)
|
|
85
|
+
return {
|
|
86
|
+
"cwe": node.id,
|
|
87
|
+
"name": node.name,
|
|
88
|
+
"abstraction": node.abstraction,
|
|
89
|
+
"mode": direction,
|
|
90
|
+
"depth": "all" if depth is None else depth,
|
|
91
|
+
"view": "1000",
|
|
92
|
+
"catalog": {"version": h.version, "date": h.date},
|
|
93
|
+
"count": len(rels),
|
|
94
|
+
"results": [
|
|
95
|
+
{
|
|
96
|
+
"id": r.id,
|
|
97
|
+
"name": h.nodes[r.id].name,
|
|
98
|
+
"abstraction": h.nodes[r.id].abstraction,
|
|
99
|
+
"status": h.nodes[r.id].status,
|
|
100
|
+
"distance": r.distance,
|
|
101
|
+
"via": r.via,
|
|
102
|
+
"primary_parent": r.primary_parent,
|
|
103
|
+
"primary_lineage": r.primary_lineage,
|
|
104
|
+
"url": h.nodes[r.id].url,
|
|
105
|
+
}
|
|
106
|
+
for r in rels
|
|
107
|
+
],
|
|
108
|
+
"note": h.relatives_note(cid, direction),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main(argv: list[str] | None = None) -> int:
|
|
113
|
+
try:
|
|
114
|
+
code = _main(argv)
|
|
115
|
+
sys.stdout.flush() # si la tubería ya se cerró, el error salta aquí y no al salir
|
|
116
|
+
return code
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
return 130
|
|
119
|
+
except BrokenPipeError: # p. ej. `cwex 664 --depth all | head`
|
|
120
|
+
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _main(argv: list[str] | None) -> int:
|
|
125
|
+
parser = build_parser()
|
|
126
|
+
args = parser.parse_args(argv)
|
|
127
|
+
if not args.cwe and not args.search and not args.update:
|
|
128
|
+
parser.print_usage(sys.stderr)
|
|
129
|
+
print("cwex: indica un identificador CWE, por ejemplo: cwex 79", file=sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
if args.json and not args.relatives:
|
|
132
|
+
parser.error("--json sólo se puede usar con --ancestors o --descendants")
|
|
133
|
+
if args.depth is UNSET:
|
|
134
|
+
args.depth = DEFAULT_LIST_DEPTH if args.relatives else DEFAULT_DEPTH
|
|
135
|
+
elif args.relatives and args.depth == 0:
|
|
136
|
+
parser.error("con --ancestors/--descendants la profundidad debe ser 1 o más (o 'all')")
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
h = Hierarchy(load_catalog(args.xml, refresh=args.update))
|
|
140
|
+
except Exception as exc: # red, XML corrupto, permisos...
|
|
141
|
+
print(f"cwex: no se pudo cargar el catálogo CWE: {exc}", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
|
|
144
|
+
if args.update and not args.cwe and not args.search:
|
|
145
|
+
print(f"Catálogo CWE {h.version} ({h.date}) guardado en {cache_dir()}: {len(h.nodes)} debilidades en la vista 1000.")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
if args.search:
|
|
149
|
+
hits = h.search(args.search, limit=25)
|
|
150
|
+
if not hits:
|
|
151
|
+
print(f"Sin resultados para «{args.search}».")
|
|
152
|
+
return 1
|
|
153
|
+
color = use_color(args.color)
|
|
154
|
+
for n in hits:
|
|
155
|
+
line = [(BADGES.get(n.abstraction, "??"), f"badge:{n.abstraction}"), (" ", "plain"),
|
|
156
|
+
(f"{n.label:<9}", "bold"), (" " + n.name, "plain")]
|
|
157
|
+
print(to_ansi(line, color))
|
|
158
|
+
return 0
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
node = h.get(args.cwe)
|
|
162
|
+
except (ValueError, CWENotFound) as exc:
|
|
163
|
+
print(f"cwex: {exc}", file=sys.stderr)
|
|
164
|
+
return 1
|
|
165
|
+
|
|
166
|
+
if args.relatives and args.json:
|
|
167
|
+
print(json.dumps(relatives_payload(h, node.id, args.relatives, args.depth), ensure_ascii=False, indent=2))
|
|
168
|
+
elif args.relatives:
|
|
169
|
+
print_relatives(h, node.id, args.relatives, args.depth, color=args.color)
|
|
170
|
+
elif args.web:
|
|
171
|
+
print(open_web(h, node.id, args.depth, args.output, launch=not args.no_open))
|
|
172
|
+
elif args.tui:
|
|
173
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
174
|
+
print("cwex: --tui necesita una terminal interactiva.", file=sys.stderr)
|
|
175
|
+
return 1
|
|
176
|
+
from . import tui # curses no existe en algunos Windows: se importa sólo si hace falta
|
|
177
|
+
|
|
178
|
+
tui.run(h, node.id, args.depth, lambda cid, depth: open_web(h, cid, depth))
|
|
179
|
+
else:
|
|
180
|
+
print_tree(h, node.id, args.depth, color=args.color)
|
|
181
|
+
return 0
|
cwe_explorer/data.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Descarga, parseo y caché del catálogo CWE de MITRE.
|
|
2
|
+
|
|
3
|
+
El XML oficial (~19 MB) se descarga una vez y se reduce a un JSON compacto con
|
|
4
|
+
sólo lo necesario para recorrer la vista 1000 (Complete Weakness Hierarchy).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
import zipfile
|
|
18
|
+
import xml.etree.ElementTree as ET
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
CATALOG_URL = "https://cwe.mitre.org/data/xml/cwec_latest.xml.zip"
|
|
22
|
+
HIERARCHY_VIEW = "1000"
|
|
23
|
+
CACHE_FORMAT = 1
|
|
24
|
+
|
|
25
|
+
# Naturalezas de relación que no son jerárquicas pero aportan contexto
|
|
26
|
+
# (cadenas, compuestos, solapamientos...).
|
|
27
|
+
EXTRA_NATURES = ("CanPrecede", "CanFollow", "Requires", "StartsWith", "PeerOf", "CanAlsoBe", "Overlaps")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cache_dir() -> Path:
|
|
31
|
+
override = os.environ.get("CWEX_CACHE")
|
|
32
|
+
if override:
|
|
33
|
+
return Path(override).expanduser()
|
|
34
|
+
base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
|
|
35
|
+
return Path(base) / "cwe-explorer"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _local(tag: str) -> str:
|
|
39
|
+
"""Nombre del elemento sin namespace (el esquema cambia: cwe-7, cwe-8...)."""
|
|
40
|
+
return tag.rsplit("}", 1)[-1]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _text(elem: ET.Element | None) -> str:
|
|
44
|
+
if elem is None:
|
|
45
|
+
return ""
|
|
46
|
+
return " ".join("".join(elem.itertext()).split())
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _child(elem: ET.Element, name: str) -> ET.Element | None:
|
|
50
|
+
for c in elem:
|
|
51
|
+
if _local(c.tag) == name:
|
|
52
|
+
return c
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def parse_catalog(source) -> dict:
|
|
57
|
+
"""Convierte el XML de CWE (ruta, fichero o .zip) en el diccionario de caché."""
|
|
58
|
+
source = Path(source) if isinstance(source, (str, os.PathLike)) else source
|
|
59
|
+
if isinstance(source, Path) and source.suffix == ".zip":
|
|
60
|
+
with zipfile.ZipFile(source) as zf:
|
|
61
|
+
member = next(n for n in zf.namelist() if n.endswith(".xml"))
|
|
62
|
+
root = ET.parse(io.BytesIO(zf.read(member))).getroot()
|
|
63
|
+
else:
|
|
64
|
+
root = ET.parse(source).getroot()
|
|
65
|
+
|
|
66
|
+
weaknesses: dict[str, dict] = {}
|
|
67
|
+
others: dict[str, dict] = {}
|
|
68
|
+
|
|
69
|
+
for elem in root.iter():
|
|
70
|
+
kind = _local(elem.tag)
|
|
71
|
+
if kind == "Weakness":
|
|
72
|
+
parents: list[int] = []
|
|
73
|
+
relations: list[list] = []
|
|
74
|
+
rels = _child(elem, "Related_Weaknesses")
|
|
75
|
+
for rel in rels if rels is not None else ():
|
|
76
|
+
nature, target, view = rel.get("Nature"), rel.get("CWE_ID"), rel.get("View_ID")
|
|
77
|
+
if not target or not target.isdigit():
|
|
78
|
+
continue
|
|
79
|
+
if nature == "ChildOf":
|
|
80
|
+
if view != HIERARCHY_VIEW:
|
|
81
|
+
continue
|
|
82
|
+
if rel.get("Ordinal") == "Primary":
|
|
83
|
+
parents.insert(0, int(target))
|
|
84
|
+
elif int(target) not in parents:
|
|
85
|
+
parents.append(int(target))
|
|
86
|
+
elif nature in EXTRA_NATURES:
|
|
87
|
+
entry = [nature, int(target)]
|
|
88
|
+
if entry not in relations:
|
|
89
|
+
relations.append(entry)
|
|
90
|
+
weaknesses[elem.get("ID")] = {
|
|
91
|
+
"name": elem.get("Name", ""),
|
|
92
|
+
"abstraction": elem.get("Abstraction", ""),
|
|
93
|
+
"structure": elem.get("Structure", ""),
|
|
94
|
+
"status": elem.get("Status", ""),
|
|
95
|
+
"description": _text(_child(elem, "Description")),
|
|
96
|
+
"extended": _text(_child(elem, "Extended_Description")),
|
|
97
|
+
"parents": parents,
|
|
98
|
+
"relations": relations,
|
|
99
|
+
}
|
|
100
|
+
elif kind in ("Grouping", "Category", "View"):
|
|
101
|
+
others[elem.get("ID")] = {
|
|
102
|
+
"kind": "Grouping" if kind == "Category" else kind,
|
|
103
|
+
"name": elem.get("Name", ""),
|
|
104
|
+
"status": elem.get("Status", ""),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"format": CACHE_FORMAT,
|
|
109
|
+
"version": root.get("Version", "?"),
|
|
110
|
+
"date": root.get("Date", "?"),
|
|
111
|
+
"weaknesses": weaknesses,
|
|
112
|
+
"others": others,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def download_catalog(dest: Path, url: str = CATALOG_URL) -> Path:
|
|
117
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
tmp = dest.with_suffix(".part")
|
|
119
|
+
print(f"Descargando catálogo CWE desde {url} ...", file=sys.stderr)
|
|
120
|
+
try:
|
|
121
|
+
with urllib.request.urlopen(url, timeout=60) as resp, open(tmp, "wb") as fh:
|
|
122
|
+
shutil.copyfileobj(resp, fh)
|
|
123
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
124
|
+
# Algunos Python de macOS no traen certificados raíz: se recurre a curl.
|
|
125
|
+
if not shutil.which("curl"):
|
|
126
|
+
raise RuntimeError(f"No se pudo descargar el catálogo: {exc}") from exc
|
|
127
|
+
result = subprocess.run(["curl", "-fsSL", "-o", str(tmp), url], capture_output=True, text=True)
|
|
128
|
+
if result.returncode != 0:
|
|
129
|
+
raise RuntimeError(f"No se pudo descargar el catálogo: {result.stderr.strip() or exc}") from exc
|
|
130
|
+
tmp.replace(dest)
|
|
131
|
+
return dest
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def load_catalog(xml_path: str | os.PathLike | None = None, refresh: bool = False) -> dict:
|
|
135
|
+
"""Devuelve el catálogo parseado, usando la caché JSON siempre que sea posible.
|
|
136
|
+
|
|
137
|
+
- ``xml_path``: XML o .zip local (no se descarga nada ni se toca la caché).
|
|
138
|
+
- ``refresh``: fuerza una nueva descarga del catálogo más reciente.
|
|
139
|
+
"""
|
|
140
|
+
if xml_path:
|
|
141
|
+
return parse_catalog(Path(xml_path).expanduser())
|
|
142
|
+
|
|
143
|
+
cdir = cache_dir()
|
|
144
|
+
json_path = cdir / "catalog.json"
|
|
145
|
+
zip_path = cdir / "cwec_latest.xml.zip"
|
|
146
|
+
|
|
147
|
+
if not refresh and json_path.exists():
|
|
148
|
+
try:
|
|
149
|
+
data = json.loads(json_path.read_text(encoding="utf-8"))
|
|
150
|
+
if data.get("format") == CACHE_FORMAT:
|
|
151
|
+
return data
|
|
152
|
+
except (OSError, ValueError):
|
|
153
|
+
pass
|
|
154
|
+
|
|
155
|
+
if refresh or not zip_path.exists():
|
|
156
|
+
download_catalog(zip_path)
|
|
157
|
+
data = parse_catalog(zip_path)
|
|
158
|
+
json_path.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
|
|
159
|
+
return data
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""Modelo de la vista 1000 de CWE como grafo dirigido acíclico (relaciones ChildOf)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
ABSTRACTION_ORDER = ("Pillar", "Class", "Base", "Variant", "Compound")
|
|
9
|
+
|
|
10
|
+
ABSTRACTION_INFO = {
|
|
11
|
+
"Pillar": "el nivel más abstracto; agrupa todas las debilidades de un mismo tema.",
|
|
12
|
+
"Class": "debilidad descrita de forma muy abstracta, normalmente independiente "
|
|
13
|
+
"del lenguaje o la tecnología.",
|
|
14
|
+
"Base": "debilidad todavía abstracta, pero con el detalle suficiente para deducir "
|
|
15
|
+
"métodos concretos de detección y prevención.",
|
|
16
|
+
"Variant": "debilidad ligada a un lenguaje, tecnología o tipo de recurso concreto.",
|
|
17
|
+
"Compound": "combinación de dos o más debilidades, ya sea una cadena (Chain) "
|
|
18
|
+
"o un compuesto (Composite).",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
STRUCTURE_INFO = {
|
|
22
|
+
"Simple": "Simple",
|
|
23
|
+
"Chain": "Cadena: una debilidad provoca la siguiente",
|
|
24
|
+
"Composite": "Compuesto: requiere varias debilidades a la vez",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
RELATION_LABELS = {
|
|
28
|
+
"CanPrecede": "Puede preceder a",
|
|
29
|
+
"CanFollow": "Puede seguir a",
|
|
30
|
+
"Requires": "Requiere",
|
|
31
|
+
"StartsWith": "Empieza con",
|
|
32
|
+
"PeerOf": "Par de",
|
|
33
|
+
"CanAlsoBe": "También puede ser",
|
|
34
|
+
"Overlaps": "Se solapa con",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
MAX_LINEAGES = 50
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CWENotFound(LookupError):
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Relative:
|
|
46
|
+
"""Un ascendiente o descendiente de una CWE, a la distancia más corta a la que aparece."""
|
|
47
|
+
|
|
48
|
+
id: int
|
|
49
|
+
distance: int # 1 = padre o hijo directo
|
|
50
|
+
via: list[int] # nodos de la distancia anterior por los que se llega (en distancia 1, el origen)
|
|
51
|
+
# Sólo en distancia 1: si el ChildOf entre ambos es el primario (Ordinal="Primary").
|
|
52
|
+
primary_parent: bool | None
|
|
53
|
+
# Ascendientes: está en el linaje principal del origen. Descendientes: el origen está en el suyo.
|
|
54
|
+
primary_lineage: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Weakness:
|
|
59
|
+
id: int
|
|
60
|
+
name: str
|
|
61
|
+
abstraction: str
|
|
62
|
+
structure: str
|
|
63
|
+
status: str
|
|
64
|
+
description: str
|
|
65
|
+
extended: str = ""
|
|
66
|
+
parents: list[int] = field(default_factory=list) # el padre primario va primero
|
|
67
|
+
relations: list[tuple[str, int]] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def label(self) -> str:
|
|
71
|
+
return f"CWE-{self.id}"
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def url(self) -> str:
|
|
75
|
+
return f"https://cwe.mitre.org/data/definitions/{self.id}.html"
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def primary_parent(self) -> int | None:
|
|
79
|
+
return self.parents[0] if self.parents else None
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def deprecated(self) -> bool:
|
|
83
|
+
return self.status == "Deprecated"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse_cwe_id(raw: str | int) -> int:
|
|
87
|
+
match = re.fullmatch(r"\s*(?:cwe)?[\s\-_:]*(\d+)\s*", str(raw), re.IGNORECASE)
|
|
88
|
+
if not match:
|
|
89
|
+
raise ValueError(f"'{raw}' no es un identificador CWE válido (ejemplos: 79, CWE-79)")
|
|
90
|
+
return int(match.group(1))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Hierarchy:
|
|
94
|
+
"""Vista 1000: todas las debilidades no obsoletas unidas por ChildOf."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, catalog: dict):
|
|
97
|
+
self.version = catalog.get("version", "?")
|
|
98
|
+
self.date = catalog.get("date", "?")
|
|
99
|
+
self.nodes: dict[int, Weakness] = {}
|
|
100
|
+
self.deprecated: dict[int, Weakness] = {}
|
|
101
|
+
for sid, w in catalog["weaknesses"].items():
|
|
102
|
+
node = Weakness(
|
|
103
|
+
id=int(sid),
|
|
104
|
+
name=w["name"],
|
|
105
|
+
abstraction=w["abstraction"],
|
|
106
|
+
structure=w["structure"],
|
|
107
|
+
status=w["status"],
|
|
108
|
+
description=w["description"],
|
|
109
|
+
extended=w.get("extended", ""),
|
|
110
|
+
parents=list(w["parents"]),
|
|
111
|
+
relations=[(n, t) for n, t in w.get("relations", [])],
|
|
112
|
+
)
|
|
113
|
+
(self.deprecated if node.status == "Deprecated" else self.nodes)[node.id] = node
|
|
114
|
+
self.others = {int(k): v for k, v in catalog.get("others", {}).items()}
|
|
115
|
+
|
|
116
|
+
# El catálogo a veces conserva ChildOf hacia debilidades ya obsoletas (en CWE 5.0,
|
|
117
|
+
# CWE-378 y CWE-379 cuelgan de la obsoleta CWE-377). Se mantienen como nodos
|
|
118
|
+
# marcados para no inventar un linaje distinto del publicado por MITRE.
|
|
119
|
+
for node in list(self.nodes.values()):
|
|
120
|
+
for p in node.parents:
|
|
121
|
+
if p in self.deprecated and p not in self.nodes:
|
|
122
|
+
self.nodes[p] = self.deprecated[p]
|
|
123
|
+
for node in self.nodes.values():
|
|
124
|
+
node.parents = [p for p in node.parents if p in self.nodes]
|
|
125
|
+
self.children: dict[int, list[int]] = {i: [] for i in self.nodes}
|
|
126
|
+
for node in self.nodes.values():
|
|
127
|
+
for p in node.parents:
|
|
128
|
+
self.children[p].append(node.id)
|
|
129
|
+
for kids in self.children.values():
|
|
130
|
+
kids.sort()
|
|
131
|
+
self.roots = sorted(i for i, n in self.nodes.items() if not n.parents)
|
|
132
|
+
|
|
133
|
+
# -- búsqueda ---------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def get(self, raw: str | int) -> Weakness:
|
|
136
|
+
cid = parse_cwe_id(raw)
|
|
137
|
+
if cid in self.nodes:
|
|
138
|
+
return self.nodes[cid]
|
|
139
|
+
if cid in self.deprecated:
|
|
140
|
+
dep = self.deprecated[cid]
|
|
141
|
+
raise CWENotFound(
|
|
142
|
+
f"CWE-{cid} ({dep.name}) está obsoleta (Deprecated) y no forma parte de la vista 1000."
|
|
143
|
+
)
|
|
144
|
+
if cid in self.others:
|
|
145
|
+
other = self.others[cid]
|
|
146
|
+
kind = {"Grouping": "una agrupación (antes «Category»)", "View": "una vista"}.get(
|
|
147
|
+
other["kind"], other["kind"]
|
|
148
|
+
)
|
|
149
|
+
raise CWENotFound(
|
|
150
|
+
f"CWE-{cid} ({other['name']}) es {kind}, no una debilidad: no aparece en la vista 1000."
|
|
151
|
+
)
|
|
152
|
+
raise CWENotFound(f"CWE-{cid} no existe en el catálogo CWE {self.version}.")
|
|
153
|
+
|
|
154
|
+
def search(self, text: str, limit: int = 10) -> list[Weakness]:
|
|
155
|
+
text = text.strip().lower()
|
|
156
|
+
if not text:
|
|
157
|
+
return []
|
|
158
|
+
hits = [n for n in self.nodes.values() if text in n.name.lower() or text == str(n.id)]
|
|
159
|
+
hits.sort(key=lambda n: (not n.name.lower().startswith(text), n.id))
|
|
160
|
+
return hits[:limit]
|
|
161
|
+
|
|
162
|
+
# -- recorridos -------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
def ancestors(self, cid: int) -> set[int]:
|
|
165
|
+
seen: set[int] = set()
|
|
166
|
+
stack = list(self.nodes[cid].parents)
|
|
167
|
+
while stack:
|
|
168
|
+
p = stack.pop()
|
|
169
|
+
if p not in seen:
|
|
170
|
+
seen.add(p)
|
|
171
|
+
stack.extend(self.nodes[p].parents)
|
|
172
|
+
return seen
|
|
173
|
+
|
|
174
|
+
def descendants(self, cid: int, depth: int | None = None) -> set[int]:
|
|
175
|
+
seen: set[int] = set()
|
|
176
|
+
frontier = [cid]
|
|
177
|
+
level = 0
|
|
178
|
+
while frontier and (depth is None or level < depth):
|
|
179
|
+
nxt = []
|
|
180
|
+
for n in frontier:
|
|
181
|
+
for c in self.children[n]:
|
|
182
|
+
if c not in seen:
|
|
183
|
+
seen.add(c)
|
|
184
|
+
nxt.append(c)
|
|
185
|
+
frontier = nxt
|
|
186
|
+
level += 1
|
|
187
|
+
return seen
|
|
188
|
+
|
|
189
|
+
def primary_lineage(self, cid: int) -> list[int]:
|
|
190
|
+
"""Camino desde el pilar hasta ``cid`` siguiendo siempre el padre primario."""
|
|
191
|
+
path = [cid]
|
|
192
|
+
while (parent := self.nodes[path[-1]].primary_parent) is not None and parent not in path:
|
|
193
|
+
path.append(parent)
|
|
194
|
+
return path[::-1]
|
|
195
|
+
|
|
196
|
+
def lineages(self, cid: int, limit: int = MAX_LINEAGES) -> list[list[int]]:
|
|
197
|
+
"""Todos los caminos raíz → ``cid`` (el primario primero)."""
|
|
198
|
+
paths: list[list[int]] = []
|
|
199
|
+
|
|
200
|
+
def walk(node: int, suffix: list[int]) -> None:
|
|
201
|
+
if len(paths) >= limit:
|
|
202
|
+
return
|
|
203
|
+
parents = self.nodes[node].parents
|
|
204
|
+
if not parents:
|
|
205
|
+
paths.append([node] + suffix)
|
|
206
|
+
return
|
|
207
|
+
for p in parents:
|
|
208
|
+
if p not in suffix:
|
|
209
|
+
walk(p, [node] + suffix)
|
|
210
|
+
|
|
211
|
+
walk(cid, [])
|
|
212
|
+
primary = self.primary_lineage(cid)
|
|
213
|
+
if primary in paths:
|
|
214
|
+
paths.remove(primary)
|
|
215
|
+
return [primary] + paths
|
|
216
|
+
|
|
217
|
+
def relatives(self, cid: int, direction: str, depth: int | None = 1) -> list[Relative]:
|
|
218
|
+
"""Ascendientes (``"ancestors"``) o descendientes (``"descendants"``) por niveles.
|
|
219
|
+
|
|
220
|
+
Recorrido en anchura: cada nodo sale una sola vez, en la distancia más corta, con
|
|
221
|
+
todos los nodos del nivel anterior que lo enlazan. ``depth=None`` recorre todo.
|
|
222
|
+
"""
|
|
223
|
+
if direction not in ("ancestors", "descendants"):
|
|
224
|
+
raise ValueError(f"dirección desconocida: {direction}")
|
|
225
|
+
up = direction == "ancestors"
|
|
226
|
+
dist = {cid: 0}
|
|
227
|
+
via: dict[int, list[int]] = {}
|
|
228
|
+
frontier = [cid]
|
|
229
|
+
level = 0
|
|
230
|
+
while frontier and (depth is None or level < depth):
|
|
231
|
+
level += 1
|
|
232
|
+
nxt = []
|
|
233
|
+
for n in frontier:
|
|
234
|
+
for m in self.nodes[n].parents if up else self.children[n]:
|
|
235
|
+
if m not in dist:
|
|
236
|
+
dist[m] = level
|
|
237
|
+
via[m] = [n]
|
|
238
|
+
nxt.append(m)
|
|
239
|
+
elif dist[m] == level:
|
|
240
|
+
via[m].append(n)
|
|
241
|
+
frontier = nxt
|
|
242
|
+
|
|
243
|
+
lineage = set(self.primary_lineage(cid))
|
|
244
|
+
result = []
|
|
245
|
+
for m, d in dist.items():
|
|
246
|
+
if m == cid:
|
|
247
|
+
continue
|
|
248
|
+
if up:
|
|
249
|
+
primary_parent = m == self.nodes[cid].primary_parent if d == 1 else None
|
|
250
|
+
on_lineage = m in lineage
|
|
251
|
+
else:
|
|
252
|
+
primary_parent = self.nodes[m].primary_parent == cid if d == 1 else None
|
|
253
|
+
on_lineage = cid in self.primary_lineage(m)
|
|
254
|
+
result.append(Relative(m, d, sorted(via[m]), primary_parent, on_lineage))
|
|
255
|
+
# Hacia arriba, el nodo del linaje principal encabeza cada nivel; hacia abajo, orden por ID.
|
|
256
|
+
result.sort(key=lambda r: (r.distance, up and not r.primary_parent, up and not r.primary_lineage, r.id))
|
|
257
|
+
return result
|
|
258
|
+
|
|
259
|
+
def relatives_note(self, cid: int, direction: str) -> str | None:
|
|
260
|
+
"""Explica un resultado vacío o incompleto de :meth:`relatives`."""
|
|
261
|
+
node = self.nodes[cid]
|
|
262
|
+
if direction == "ancestors":
|
|
263
|
+
if node.parents:
|
|
264
|
+
return self.lineage_warning(cid)
|
|
265
|
+
if node.abstraction == "Pillar":
|
|
266
|
+
return f"{node.label} es un Pillar: es la raíz de su rama y no tiene ascendientes en la vista 1000."
|
|
267
|
+
if node.deprecated:
|
|
268
|
+
kids = ", ".join(self.nodes[k].label for k in self.children[cid])
|
|
269
|
+
return (
|
|
270
|
+
f"{node.label} está obsoleta y no tiene padre en la vista 1000; sólo aparece "
|
|
271
|
+
f"porque {kids} siguen figurando como hijas suyas."
|
|
272
|
+
)
|
|
273
|
+
return f"{node.label} no tiene padre en la vista 1000."
|
|
274
|
+
if self.children[cid]:
|
|
275
|
+
return None
|
|
276
|
+
if node.abstraction == "Compound":
|
|
277
|
+
parts = [f"CWE-{t}" for nature, t in node.relations if nature in ("Requires", "StartsWith")]
|
|
278
|
+
return (
|
|
279
|
+
f"{node.label} es un Compound: en la vista 1000 no tiene hijos. Sus componentes "
|
|
280
|
+
f"({', '.join(parts) or 'ninguno declarado'}) se enlazan con Requires/StartsWith, no con ChildOf."
|
|
281
|
+
)
|
|
282
|
+
return f"{node.label} no tiene descendientes en la vista 1000: es una hoja de la jerarquía."
|
|
283
|
+
|
|
284
|
+
def depth_of(self, cid: int) -> int:
|
|
285
|
+
return len(self.primary_lineage(cid)) - 1
|
|
286
|
+
|
|
287
|
+
def lineage_warning(self, cid: int) -> str | None:
|
|
288
|
+
"""Explica por qué un linaje no termina en un Pillar, si es el caso."""
|
|
289
|
+
top = self.nodes[self.primary_lineage(cid)[0]]
|
|
290
|
+
if top.abstraction == "Pillar":
|
|
291
|
+
return None
|
|
292
|
+
if top.deprecated:
|
|
293
|
+
return (
|
|
294
|
+
f"Linaje incompleto: {top.label} está obsoleta en el catálogo CWE {self.version} "
|
|
295
|
+
"pero sigue figurando como padre en la vista 1000."
|
|
296
|
+
)
|
|
297
|
+
return f"Linaje incompleto: {top.label} no tiene padre en la vista 1000."
|