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.

@@ -0,0 +1,3 @@
1
+ """Explorador de la jerarquía CWE (vista 1000 de MITRE) en la terminal y en el navegador."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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."