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.
- cwe_explorer/__init__.py +3 -0
- cwe_explorer/__main__.py +3 -0
- cwe_explorer/cli.py +190 -0
- cwe_explorer/data.py +163 -0
- cwe_explorer/details.py +379 -0
- cwe_explorer/hierarchy.py +297 -0
- cwe_explorer/render_html.py +73 -0
- cwe_explorer/render_term.py +327 -0
- cwe_explorer/tui.py +269 -0
- cwe_explorer/web/logo.png +0 -0
- cwe_explorer/web/viewer.css +679 -0
- cwe_explorer/web/viewer.html +60 -0
- cwe_explorer/web/viewer.js +842 -0
- cwe_explorer-0.1.1.dist-info/METADATA +356 -0
- cwe_explorer-0.1.1.dist-info/RECORD +19 -0
- cwe_explorer-0.1.1.dist-info/WHEEL +5 -0
- cwe_explorer-0.1.1.dist-info/entry_points.txt +3 -0
- cwe_explorer-0.1.1.dist-info/licenses/LICENSE +21 -0
- cwe_explorer-0.1.1.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,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
|