cwe-explorer 0.1.1__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.
@@ -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.1"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
cwe_explorer/cli.py ADDED
@@ -0,0 +1,190 @@
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 .details import load_details
14
+ from .hierarchy import CWENotFound, Hierarchy
15
+ from .render_html import write_html
16
+ from .render_term import BADGES, print_relatives, print_tree, to_ansi, use_color
17
+
18
+ DEFAULT_DEPTH = 2 # árbol, --tui y --web
19
+ DEFAULT_LIST_DEPTH = 1 # --ancestors / --descendants: sólo los directos
20
+ UNSET = object() # distingue «sin --depth» de «--depth all» (None)
21
+
22
+
23
+ def parse_depth(raw: str) -> int | None:
24
+ if raw.lower() in ("all", "*"):
25
+ return None
26
+ try:
27
+ value = int(raw)
28
+ except ValueError:
29
+ raise argparse.ArgumentTypeError("use a number >= 0 or 'all'") from None
30
+ if value < 0:
31
+ raise argparse.ArgumentTypeError("use a number >= 0 or 'all'")
32
+ return value
33
+
34
+
35
+ def build_parser() -> argparse.ArgumentParser:
36
+ parser = argparse.ArgumentParser(
37
+ prog="cwex",
38
+ description="Explore the hierarchy of a CWE in MITRE's View 1000 "
39
+ "(Complete Weakness Hierarchy) in the terminal or in the browser.",
40
+ epilog="examples:\n"
41
+ " cwex 79 tree in the terminal\n"
42
+ " cwex CWE-79 --tui interactive explorer (mouse and keyboard)\n"
43
+ " cwex 79 --web interactive graph in the browser\n"
44
+ " cwex 664 --depth all every descendant\n"
45
+ " cwex --search overflow search by name\n"
46
+ " cwex 79 --ancestors direct parents (mode 1)\n"
47
+ " cwex 79 --descendants direct children (mode 2)\n"
48
+ " cwex 82 --ancestors --depth all every ancestor up to the pillar\n"
49
+ " cwex 664 --descendants -d all --json every descendant as JSON\n"
50
+ " cwex 79 --tui --detailed every section of each CWE (also the tree and --web)",
51
+ formatter_class=argparse.RawDescriptionHelpFormatter,
52
+ )
53
+ parser.add_argument("cwe", nargs="?", help="CWE identifier (79, CWE-79...)")
54
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
55
+ mode = parser.add_mutually_exclusive_group()
56
+ mode.add_argument("--tui", action="store_true", help="interactive explorer in the terminal")
57
+ mode.add_argument("--web", action="store_true", help="build the HTML graph and open it in the browser")
58
+ mode.add_argument("--search", metavar="TEXT", help="search weaknesses by name")
59
+ mode.add_argument("--ancestors", dest="relatives", action="store_const", const="ancestors",
60
+ help="list the ancestors: only the direct parents by default (mode 1)")
61
+ mode.add_argument("--descendants", dest="relatives", action="store_const", const="descendants",
62
+ help="list the descendants: only the direct children by default (mode 2)")
63
+ parser.add_argument("-d", "--depth", type=parse_depth, default=UNSET, metavar="N",
64
+ help=f"levels to walk, or 'all'. Default: {DEFAULT_DEPTH} for the tree, --tui and "
65
+ f"--web, and {DEFAULT_LIST_DEPTH} (direct only) with --ancestors/--descendants")
66
+ parser.add_argument("--json", action="store_true", help="with --ancestors/--descendants, output JSON")
67
+ parser.add_argument("--detailed", action="store_true",
68
+ help="show every section of each CWE (consequences, mitigations, examples, observed "
69
+ "CVEs, references...) in the tree, --tui and --web")
70
+ parser.add_argument("-o", "--output", metavar="FILE", help="path of the HTML written by --web")
71
+ parser.add_argument("--no-open", action="store_true", help="with --web, do not open the browser")
72
+ parser.add_argument("--xml", metavar="PATH", help="use a local cwec_*.xml or .zip instead of downloading it")
73
+ parser.add_argument("--update", action="store_true", help="download the MITRE catalog again")
74
+ color = parser.add_mutually_exclusive_group()
75
+ color.add_argument("--color", dest="color", action="store_true", default=None, help="force ANSI colors")
76
+ color.add_argument("--no-color", dest="color", action="store_false", help="disable colors")
77
+ return parser
78
+
79
+
80
+ def open_web(h: Hierarchy, cid: int, depth: int | None, output: str | None = None, launch: bool = True,
81
+ details: dict | None = None) -> str:
82
+ path = write_html(h, cid, depth, output, details)
83
+ if launch:
84
+ webbrowser.open(path.as_uri())
85
+ return f"Web graph written to {path}"
86
+
87
+
88
+ def relatives_payload(h: Hierarchy, cid: int, direction: str, depth: int | None) -> dict:
89
+ node = h.nodes[cid]
90
+ rels = h.relatives(cid, direction, depth)
91
+ return {
92
+ "cwe": node.id,
93
+ "name": node.name,
94
+ "abstraction": node.abstraction,
95
+ "mode": direction,
96
+ "depth": "all" if depth is None else depth,
97
+ "view": "1000",
98
+ "catalog": {"version": h.version, "date": h.date},
99
+ "count": len(rels),
100
+ "results": [
101
+ {
102
+ "id": r.id,
103
+ "name": h.nodes[r.id].name,
104
+ "abstraction": h.nodes[r.id].abstraction,
105
+ "status": h.nodes[r.id].status,
106
+ "distance": r.distance,
107
+ "via": r.via,
108
+ "primary_parent": r.primary_parent,
109
+ "primary_lineage": r.primary_lineage,
110
+ "url": h.nodes[r.id].url,
111
+ }
112
+ for r in rels
113
+ ],
114
+ "note": h.relatives_note(cid, direction),
115
+ }
116
+
117
+
118
+ def main(argv: list[str] | None = None) -> int:
119
+ try:
120
+ code = _main(argv)
121
+ sys.stdout.flush() # si la tubería ya se cerró, el error salta aquí y no al salir
122
+ return code
123
+ except KeyboardInterrupt:
124
+ return 130
125
+ except BrokenPipeError: # p. ej. `cwex 664 --depth all | head`
126
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
127
+ return 0
128
+
129
+
130
+ def _main(argv: list[str] | None) -> int:
131
+ parser = build_parser()
132
+ args = parser.parse_args(argv)
133
+ if not args.cwe and not args.search and not args.update:
134
+ parser.print_usage(sys.stderr)
135
+ print("cwex: give a CWE identifier, for example: cwex 79", file=sys.stderr)
136
+ return 2
137
+ if args.json and not args.relatives:
138
+ parser.error("--json can only be used with --ancestors or --descendants")
139
+ if args.detailed and (args.relatives or args.search):
140
+ parser.error("--detailed only works with the tree view, --tui and --web")
141
+ if args.depth is UNSET:
142
+ args.depth = DEFAULT_LIST_DEPTH if args.relatives else DEFAULT_DEPTH
143
+ elif args.relatives and args.depth == 0:
144
+ parser.error("with --ancestors/--descendants the depth must be 1 or more (or 'all')")
145
+
146
+ try:
147
+ h = Hierarchy(load_catalog(args.xml, refresh=args.update))
148
+ details = load_details(h.version, h.date, args.xml) if args.detailed else None
149
+ except Exception as exc: # red, XML corrupto, permisos...
150
+ print(f"cwex: could not load the CWE catalog: {exc}", file=sys.stderr)
151
+ return 1
152
+
153
+ if args.update and not args.cwe and not args.search:
154
+ print(f"CWE catalog {h.version} ({h.date}) saved to {cache_dir()}: {len(h.nodes)} weaknesses in View 1000.")
155
+ return 0
156
+
157
+ if args.search:
158
+ hits = h.search(args.search, limit=25)
159
+ if not hits:
160
+ print(f"No results for \"{args.search}\".")
161
+ return 1
162
+ color = use_color(args.color)
163
+ for n in hits:
164
+ line = [(BADGES.get(n.abstraction, "??"), f"badge:{n.abstraction}"), (" ", "plain"),
165
+ (f"{n.label:<9}", "bold"), (" " + n.name, "plain")]
166
+ print(to_ansi(line, color))
167
+ return 0
168
+
169
+ try:
170
+ node = h.get(args.cwe)
171
+ except (ValueError, CWENotFound) as exc:
172
+ print(f"cwex: {exc}", file=sys.stderr)
173
+ return 1
174
+
175
+ if args.relatives and args.json:
176
+ print(json.dumps(relatives_payload(h, node.id, args.relatives, args.depth), ensure_ascii=False, indent=2))
177
+ elif args.relatives:
178
+ print_relatives(h, node.id, args.relatives, args.depth, color=args.color)
179
+ elif args.web:
180
+ print(open_web(h, node.id, args.depth, args.output, launch=not args.no_open, details=details))
181
+ elif args.tui:
182
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
183
+ print("cwex: --tui needs an interactive terminal.", file=sys.stderr)
184
+ return 1
185
+ from . import tui # curses no existe en algunos Windows: se importa sólo si hace falta
186
+
187
+ tui.run(h, node.id, args.depth, lambda cid, depth: open_web(h, cid, depth, details=details), details)
188
+ else:
189
+ print_tree(h, node.id, args.depth, color=args.color, details=details)
190
+ return 0
cwe_explorer/data.py ADDED
@@ -0,0 +1,163 @@
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 read_root(source) -> ET.Element:
57
+ """Raíz del XML de CWE a partir de una ruta, un fichero abierto o un .zip."""
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
+ return ET.parse(io.BytesIO(zf.read(member))).getroot()
63
+ return ET.parse(source).getroot()
64
+
65
+
66
+ def parse_catalog(source) -> dict:
67
+ """Convierte el XML de CWE (ruta, fichero o .zip) en el diccionario de caché."""
68
+ root = read_root(source)
69
+
70
+ weaknesses: dict[str, dict] = {}
71
+ others: dict[str, dict] = {}
72
+
73
+ for elem in root.iter():
74
+ kind = _local(elem.tag)
75
+ if kind == "Weakness":
76
+ parents: list[int] = []
77
+ relations: list[list] = []
78
+ rels = _child(elem, "Related_Weaknesses")
79
+ for rel in rels if rels is not None else ():
80
+ nature, target, view = rel.get("Nature"), rel.get("CWE_ID"), rel.get("View_ID")
81
+ if not target or not target.isdigit():
82
+ continue
83
+ if nature == "ChildOf":
84
+ if view != HIERARCHY_VIEW:
85
+ continue
86
+ if rel.get("Ordinal") == "Primary":
87
+ parents.insert(0, int(target))
88
+ elif int(target) not in parents:
89
+ parents.append(int(target))
90
+ elif nature in EXTRA_NATURES:
91
+ entry = [nature, int(target)]
92
+ if entry not in relations:
93
+ relations.append(entry)
94
+ weaknesses[elem.get("ID")] = {
95
+ "name": elem.get("Name", ""),
96
+ "abstraction": elem.get("Abstraction", ""),
97
+ "structure": elem.get("Structure", ""),
98
+ "status": elem.get("Status", ""),
99
+ "description": _text(_child(elem, "Description")),
100
+ "extended": _text(_child(elem, "Extended_Description")),
101
+ "parents": parents,
102
+ "relations": relations,
103
+ }
104
+ elif kind in ("Grouping", "Category", "View"):
105
+ others[elem.get("ID")] = {
106
+ "kind": "Grouping" if kind == "Category" else kind,
107
+ "name": elem.get("Name", ""),
108
+ "status": elem.get("Status", ""),
109
+ }
110
+
111
+ return {
112
+ "format": CACHE_FORMAT,
113
+ "version": root.get("Version", "?"),
114
+ "date": root.get("Date", "?"),
115
+ "weaknesses": weaknesses,
116
+ "others": others,
117
+ }
118
+
119
+
120
+ def download_catalog(dest: Path, url: str = CATALOG_URL) -> Path:
121
+ dest.parent.mkdir(parents=True, exist_ok=True)
122
+ tmp = dest.with_suffix(".part")
123
+ print(f"Downloading the CWE catalog from {url} ...", file=sys.stderr)
124
+ try:
125
+ with urllib.request.urlopen(url, timeout=60) as resp, open(tmp, "wb") as fh:
126
+ shutil.copyfileobj(resp, fh)
127
+ except (urllib.error.URLError, OSError) as exc:
128
+ # Algunos Python de macOS no traen certificados raíz: se recurre a curl.
129
+ if not shutil.which("curl"):
130
+ raise RuntimeError(f"could not download the catalog: {exc}") from exc
131
+ result = subprocess.run(["curl", "-fsSL", "-o", str(tmp), url], capture_output=True, text=True)
132
+ if result.returncode != 0:
133
+ raise RuntimeError(f"could not download the catalog: {result.stderr.strip() or exc}") from exc
134
+ tmp.replace(dest)
135
+ return dest
136
+
137
+
138
+ def load_catalog(xml_path: str | os.PathLike | None = None, refresh: bool = False) -> dict:
139
+ """Devuelve el catálogo parseado, usando la caché JSON siempre que sea posible.
140
+
141
+ - ``xml_path``: XML o .zip local (no se descarga nada ni se toca la caché).
142
+ - ``refresh``: fuerza una nueva descarga del catálogo más reciente.
143
+ """
144
+ if xml_path:
145
+ return parse_catalog(Path(xml_path).expanduser())
146
+
147
+ cdir = cache_dir()
148
+ json_path = cdir / "catalog.json"
149
+ zip_path = cdir / "cwec_latest.xml.zip"
150
+
151
+ if not refresh and json_path.exists():
152
+ try:
153
+ data = json.loads(json_path.read_text(encoding="utf-8"))
154
+ if data.get("format") == CACHE_FORMAT:
155
+ return data
156
+ except (OSError, ValueError):
157
+ pass
158
+
159
+ if refresh or not zip_path.exists():
160
+ download_catalog(zip_path)
161
+ data = parse_catalog(zip_path)
162
+ json_path.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")), encoding="utf-8")
163
+ return data