vulnerability-explorer 1.0.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.
- vex/__init__.py +3 -0
- vex/commands/__init__.py +1 -0
- vex/commands/interactive.py +14 -0
- vex/commands/list.py +29 -0
- vex/commands/options.py +78 -0
- vex/commands/read.py +42 -0
- vex/commands/search.py +27 -0
- vex/commands/sync.py +50 -0
- vex/commands/tree.py +14 -0
- vex/core/__init__.py +1 -0
- vex/core/catalog.py +59 -0
- vex/core/models.py +24 -0
- vex/main.py +38 -0
- vex/utils/__init__.py +1 -0
- vex/utils/display.py +264 -0
- vex/utils/filesystem.py +49 -0
- vex_lint/__init__.py +5 -0
- vex_lint/core/context.py +25 -0
- vex_lint/core/engine.py +36 -0
- vex_lint/core/models.py +42 -0
- vex_lint/core/rule.py +30 -0
- vex_lint/linter.py +5 -0
- vex_lint/loaders/markdown.py +88 -0
- vex_lint/loaders/taxonomy.py +32 -0
- vex_lint/main.py +34 -0
- vex_lint/rules/cross_reference_rule.py +47 -0
- vex_lint/rules/frontmatter_schema_rule.py +68 -0
- vex_lint/rules/markdown_structure_rule.py +49 -0
- vex_lint/rules/path_convention_rule.py +72 -0
- vex_lint/rules/taxonomy_rule.py +30 -0
- vex_lint/schemas/frontmatter.py +111 -0
- vex_lint/schemas/sections.py +55 -0
- vex_lint/service.py +70 -0
- vulnerability_explorer-1.0.0.dist-info/METADATA +149 -0
- vulnerability_explorer-1.0.0.dist-info/RECORD +38 -0
- vulnerability_explorer-1.0.0.dist-info/WHEEL +5 -0
- vulnerability_explorer-1.0.0.dist-info/entry_points.txt +3 -0
- vulnerability_explorer-1.0.0.dist-info/top_level.txt +2 -0
vex/__init__.py
ADDED
vex/commands/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Typer command definitions for vex CLI."""
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Command to navigate the catalog interactively."""
|
|
2
|
+
|
|
3
|
+
from vex.commands.options import OptRich
|
|
4
|
+
from vex.core.catalog import collect_entries
|
|
5
|
+
from vex.utils.display import interactive_navigation
|
|
6
|
+
from vex.utils.filesystem import find_repo_root
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cmd_interactive(rich: OptRich = True) -> None:
|
|
10
|
+
"""Interactive navigation menu."""
|
|
11
|
+
|
|
12
|
+
root = find_repo_root()
|
|
13
|
+
entries = collect_entries(root)
|
|
14
|
+
interactive_navigation(entries, use_rich=rich)
|
vex/commands/list.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Command to list catalog entries with optional filters."""
|
|
2
|
+
|
|
3
|
+
from vex.commands.options import OptCategory, OptLanguage, OptMode, OptRich, OptType
|
|
4
|
+
from vex.core.catalog import collect_entries
|
|
5
|
+
from vex.utils.display import display_list
|
|
6
|
+
from vex.utils.filesystem import find_repo_root
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cmd_list(category: OptCategory = None, language: OptLanguage = None, mode: OptMode = None, entry_type: OptType = None, rich: OptRich = True) -> None:
|
|
10
|
+
"""List entries with optional filters."""
|
|
11
|
+
|
|
12
|
+
root = find_repo_root()
|
|
13
|
+
entries = collect_entries(root)
|
|
14
|
+
|
|
15
|
+
filtered = entries
|
|
16
|
+
|
|
17
|
+
if category:
|
|
18
|
+
filtered = [e for e in filtered if e["fm"].get("category") == category]
|
|
19
|
+
|
|
20
|
+
if language:
|
|
21
|
+
filtered = [e for e in filtered if language in (e["fm"].get("languages") or [])]
|
|
22
|
+
|
|
23
|
+
if mode:
|
|
24
|
+
filtered = [e for e in filtered if mode in (e["fm"].get("analysis_modes") or [])]
|
|
25
|
+
|
|
26
|
+
if entry_type:
|
|
27
|
+
filtered = [e for e in filtered if e["fm"].get("type") == entry_type]
|
|
28
|
+
|
|
29
|
+
display_list(filtered, use_rich=rich)
|
vex/commands/options.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Reusable Typer options and arguments definitions using Annotated."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
# Global / Shared Options
|
|
9
|
+
OptRich = Annotated[
|
|
10
|
+
bool,
|
|
11
|
+
typer.Option("--rich/--no-rich", help="Enable or disable Rich formatted output", rich_help_panel="Global Options"),
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
OptVersion = Annotated[
|
|
15
|
+
bool | None,
|
|
16
|
+
typer.Option(
|
|
17
|
+
"--version",
|
|
18
|
+
"-V",
|
|
19
|
+
help="Print version and exit.",
|
|
20
|
+
rich_help_panel="Global Options",
|
|
21
|
+
),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
# Explore Command Options & Arguments
|
|
25
|
+
OptCategory = Annotated[
|
|
26
|
+
str | None,
|
|
27
|
+
typer.Option("--category", "-c", help="Filter by category slug"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
OptLanguage = Annotated[
|
|
31
|
+
str | None,
|
|
32
|
+
typer.Option("--language", "-l", help="Filter by language slug"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
OptMode = Annotated[
|
|
36
|
+
str | None,
|
|
37
|
+
typer.Option("--mode", "-m", help="Filter by analysis mode"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
OptType = Annotated[
|
|
41
|
+
str | None,
|
|
42
|
+
typer.Option("--type", "-t", help="Filter by entry type (concept, manifestation, runtime-signature)"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
OptNoPager = Annotated[
|
|
46
|
+
bool,
|
|
47
|
+
typer.Option("--no-pager", help="Disable pager and print directly to terminal"),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
ArgEntryId = Annotated[
|
|
51
|
+
str,
|
|
52
|
+
typer.Argument(help="Entry ID (exact or prefix match)"),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
ArgQuery = Annotated[
|
|
56
|
+
str,
|
|
57
|
+
typer.Argument(help="Search query string"),
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
# Linter Options
|
|
61
|
+
OptRoot = Annotated[
|
|
62
|
+
Path | None,
|
|
63
|
+
typer.Option("--root", "-r", help="Repository root path (default: auto-detected)"),
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# Sync Command Options
|
|
67
|
+
DEFAULT_REMOTE_URL = "https://github.com/othonhugo/vulnerability-explorer.git"
|
|
68
|
+
DEFAULT_BRANCH = "unstable"
|
|
69
|
+
|
|
70
|
+
OptRemote = Annotated[
|
|
71
|
+
str,
|
|
72
|
+
typer.Option("--remote", "-r", help="Remote Git repository URL"),
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
OptBranch = Annotated[
|
|
76
|
+
str,
|
|
77
|
+
typer.Option("--branch", "-b", help="Git branch to pull/clone"),
|
|
78
|
+
]
|
vex/commands/read.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Command to read a specific catalog entry by ID."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from vex.commands.options import ArgEntryId, OptNoPager, OptRich
|
|
6
|
+
from vex.core.catalog import collect_entries
|
|
7
|
+
from vex.core.models import CatalogEntry
|
|
8
|
+
from vex.utils.display import display_read
|
|
9
|
+
from vex.utils.filesystem import find_repo_root
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cmd_read(entry_id: ArgEntryId, no_pager: OptNoPager = False, rich: OptRich = True) -> None:
|
|
13
|
+
"""Read an entry by ID using Pager."""
|
|
14
|
+
|
|
15
|
+
root = find_repo_root()
|
|
16
|
+
entries = collect_entries(root)
|
|
17
|
+
|
|
18
|
+
entry_match: CatalogEntry | None = None
|
|
19
|
+
|
|
20
|
+
for e in entries:
|
|
21
|
+
if e["fm"].get("id") == entry_id:
|
|
22
|
+
entry_match = e
|
|
23
|
+
break
|
|
24
|
+
|
|
25
|
+
if not entry_match:
|
|
26
|
+
candidates = [e for e in entries if str(e["fm"].get("id", "")).startswith(entry_id)]
|
|
27
|
+
|
|
28
|
+
if len(candidates) == 1:
|
|
29
|
+
entry_match = candidates[0]
|
|
30
|
+
elif len(candidates) > 1:
|
|
31
|
+
typer.echo(f"Ambiguous ID '{entry_id}'. Matches:")
|
|
32
|
+
|
|
33
|
+
for c in candidates:
|
|
34
|
+
typer.echo(f" {c['fm'].get('id')}")
|
|
35
|
+
|
|
36
|
+
raise typer.Exit(code=1)
|
|
37
|
+
else:
|
|
38
|
+
typer.echo(f"No entry found with ID '{entry_id}'.")
|
|
39
|
+
|
|
40
|
+
raise typer.Exit(code=1)
|
|
41
|
+
|
|
42
|
+
display_read(entry_match, use_pager=not no_pager, use_rich=rich)
|
vex/commands/search.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Command to search across the vulnerability catalog."""
|
|
2
|
+
|
|
3
|
+
from vex.commands.options import ArgQuery, OptRich
|
|
4
|
+
from vex.core.catalog import collect_entries
|
|
5
|
+
from vex.core.models import CatalogEntry
|
|
6
|
+
from vex.utils.display import display_search
|
|
7
|
+
from vex.utils.filesystem import find_repo_root
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cmd_search(query: ArgQuery, rich: OptRich = True) -> None:
|
|
11
|
+
"""Search entries for query term."""
|
|
12
|
+
|
|
13
|
+
root = find_repo_root()
|
|
14
|
+
entries = collect_entries(root)
|
|
15
|
+
q = query.lower()
|
|
16
|
+
|
|
17
|
+
results: list[tuple[int, CatalogEntry]] = []
|
|
18
|
+
|
|
19
|
+
for e in entries:
|
|
20
|
+
full_text = e["path"].read_text(encoding="utf-8").lower()
|
|
21
|
+
|
|
22
|
+
if q in full_text:
|
|
23
|
+
count = full_text.count(q)
|
|
24
|
+
results.append((count, e))
|
|
25
|
+
|
|
26
|
+
results.sort(key=lambda x: x[0], reverse=True)
|
|
27
|
+
display_search(results, query, use_rich=rich)
|
vex/commands/sync.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Command for syncing/updating global vulnerability catalog from remote Git repository."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from vex.commands.options import OptBranch, OptRemote
|
|
9
|
+
from vex.utils.filesystem import get_global_cache_dir
|
|
10
|
+
|
|
11
|
+
console = Console()
|
|
12
|
+
stderr_console = Console(stderr=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cmd_sync(
|
|
16
|
+
remote: OptRemote,
|
|
17
|
+
branch: OptBranch,
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Download or update the global vulnerability catalog cache."""
|
|
20
|
+
cache_dir = get_global_cache_dir()
|
|
21
|
+
|
|
22
|
+
# Check if git is available in PATH
|
|
23
|
+
try:
|
|
24
|
+
subprocess.run(["git", "--version"], capture_output=True, check=True)
|
|
25
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
26
|
+
stderr_console.print("[bold red]Error:[/] `git` command not found. Please install Git to use `vex sync`.")
|
|
27
|
+
raise typer.Exit(code=1)
|
|
28
|
+
|
|
29
|
+
cache_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
if (cache_dir / ".git").is_dir():
|
|
32
|
+
console.print(f"[bold cyan]Updating global catalog cache at:[/] {cache_dir}")
|
|
33
|
+
res = subprocess.run(["git", "pull", "origin", branch], cwd=cache_dir, capture_output=True, text=True, check=False)
|
|
34
|
+
|
|
35
|
+
if res.returncode != 0:
|
|
36
|
+
stderr_console.print(f"[bold red]Failed to update catalog:[/] {res.stderr.strip()}")
|
|
37
|
+
raise typer.Exit(code=1)
|
|
38
|
+
|
|
39
|
+
console.print("[bold green]✔ Catalog successfully updated![/]")
|
|
40
|
+
else:
|
|
41
|
+
console.print(f"[bold cyan]Cloning catalog from:[/] {remote}")
|
|
42
|
+
console.print(f"[bold cyan]Target directory:[/] {cache_dir}")
|
|
43
|
+
|
|
44
|
+
res = subprocess.run(["git", "clone", "--depth", "1", "-b", branch, remote, str(cache_dir)], capture_output=True, text=True, check=False)
|
|
45
|
+
|
|
46
|
+
if res.returncode != 0:
|
|
47
|
+
stderr_console.print(f"[bold red]Failed to clone catalog:[/] {res.stderr.strip()}")
|
|
48
|
+
raise typer.Exit(code=1)
|
|
49
|
+
|
|
50
|
+
console.print("[bold green]✔ Catalog successfully downloaded![/]")
|
vex/commands/tree.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Command to show the catalog tree structure."""
|
|
2
|
+
|
|
3
|
+
from vex.commands.options import OptRich
|
|
4
|
+
from vex.core.catalog import collect_entries
|
|
5
|
+
from vex.utils.display import display_tree
|
|
6
|
+
from vex.utils.filesystem import find_repo_root
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cmd_tree(rich: OptRich = True) -> None:
|
|
10
|
+
"""Show the catalog tree structure."""
|
|
11
|
+
|
|
12
|
+
root = find_repo_root()
|
|
13
|
+
entries = collect_entries(root)
|
|
14
|
+
display_tree(entries, use_rich=rich)
|
vex/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core catalog models and parsing logic."""
|
vex/core/catalog.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Catalog loading and parsing utilities."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from vex.core.models import CatalogEntry
|
|
10
|
+
|
|
11
|
+
FRONTMATTER_RE = re.compile(r"\A---\n(.*?\n)---\n?(.*)\Z", re.DOTALL)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_file(path: Path) -> tuple[dict[str, Any] | None, str | None]:
|
|
15
|
+
"""Parse a catalog file, returning (frontmatter_dict, body_str) or (None, None)."""
|
|
16
|
+
|
|
17
|
+
text = path.read_text(encoding="utf-8")
|
|
18
|
+
m = FRONTMATTER_RE.match(text)
|
|
19
|
+
|
|
20
|
+
if not m:
|
|
21
|
+
return None, None
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
fm: Any = yaml.safe_load(m.group(1))
|
|
25
|
+
except yaml.YAMLError:
|
|
26
|
+
return None, None
|
|
27
|
+
|
|
28
|
+
if not isinstance(fm, dict):
|
|
29
|
+
return None, None
|
|
30
|
+
|
|
31
|
+
return fm, m.group(2)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def collect_entries(root: Path) -> list[CatalogEntry]:
|
|
35
|
+
"""Collect all catalog entries and runtime-signatures."""
|
|
36
|
+
|
|
37
|
+
entries: list[CatalogEntry] = []
|
|
38
|
+
catalog_dir = root / "data" / "catalog"
|
|
39
|
+
signatures_dir = root / "data" / "signatures"
|
|
40
|
+
|
|
41
|
+
dirs: list[Path] = []
|
|
42
|
+
|
|
43
|
+
if catalog_dir.exists():
|
|
44
|
+
dirs.append(catalog_dir)
|
|
45
|
+
|
|
46
|
+
if signatures_dir.exists():
|
|
47
|
+
dirs.append(signatures_dir)
|
|
48
|
+
|
|
49
|
+
for d in dirs:
|
|
50
|
+
for path in sorted(d.rglob("*.md")):
|
|
51
|
+
if path.name == "README.md" and (path.parent == signatures_dir or path.parent == catalog_dir or path.parent.parent == catalog_dir):
|
|
52
|
+
continue # Skip documentation READMEs
|
|
53
|
+
|
|
54
|
+
fm, body = parse_file(path)
|
|
55
|
+
|
|
56
|
+
if fm is not None and body is not None and "id" in fm:
|
|
57
|
+
entries.append({"path": path, "fm": fm, "body": body})
|
|
58
|
+
|
|
59
|
+
return entries
|
vex/core/models.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Data models and type definitions for Vulnerability Explorer."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, TypedDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CatalogEntry(TypedDict):
|
|
9
|
+
"""Data structure representing a parsed catalog entry."""
|
|
10
|
+
|
|
11
|
+
path: Path
|
|
12
|
+
fm: dict[str, Any]
|
|
13
|
+
body: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class LintError:
|
|
18
|
+
"""Represents a catalog validation violation."""
|
|
19
|
+
|
|
20
|
+
path: Path | str
|
|
21
|
+
message: str
|
|
22
|
+
|
|
23
|
+
def __str__(self) -> str:
|
|
24
|
+
return f"{self.path}: {self.message}"
|
vex/main.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Main entrypoint for vex CLI app."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
import vex
|
|
6
|
+
from vex.commands.interactive import cmd_interactive
|
|
7
|
+
from vex.commands.list import cmd_list
|
|
8
|
+
from vex.commands.options import OptRich, OptVersion
|
|
9
|
+
from vex.commands.read import cmd_read
|
|
10
|
+
from vex.commands.search import cmd_search
|
|
11
|
+
from vex.commands.sync import cmd_sync
|
|
12
|
+
from vex.commands.tree import cmd_tree
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(name="vex", help="A terminal reference for software vulnerabilities.", no_args_is_help=False)
|
|
15
|
+
|
|
16
|
+
app.command(name="tree", help="Show the catalog tree structure")(cmd_tree)
|
|
17
|
+
app.command(name="list", help="List entries with filters")(cmd_list)
|
|
18
|
+
app.command(name="read", help="Read an entry by ID")(cmd_read)
|
|
19
|
+
app.command(name="search", help="Full-text search across entries")(cmd_search)
|
|
20
|
+
app.command(name="interactive", help="Interactive navigation menu")(cmd_interactive)
|
|
21
|
+
app.command(name="sync", help="Download or update the global catalog cache")(cmd_sync)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.callback(invoke_without_command=True)
|
|
25
|
+
def main(ctx: typer.Context, version: OptVersion = None, rich: OptRich = True) -> None:
|
|
26
|
+
"""Vulnerability Explorer CLI."""
|
|
27
|
+
|
|
28
|
+
if version:
|
|
29
|
+
typer.echo(f"vex version {vex.__version__}")
|
|
30
|
+
raise typer.Exit()
|
|
31
|
+
|
|
32
|
+
if ctx.invoked_subcommand is None:
|
|
33
|
+
# Default to interactive if no subcommand passed
|
|
34
|
+
cmd_interactive(rich=rich)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
app()
|
vex/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility modules for filesystem and display operations."""
|
vex/utils/display.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Display and UI utilities using Rich, Questionary, and Pager."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
import questionary
|
|
7
|
+
|
|
8
|
+
QUESTIONARY_AVAILABLE = True
|
|
9
|
+
except ImportError:
|
|
10
|
+
QUESTIONARY_AVAILABLE = False
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.markdown import Markdown
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from rich.tree import Tree as RichTree
|
|
17
|
+
|
|
18
|
+
RICH_AVAILABLE = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
RICH_AVAILABLE = False
|
|
21
|
+
|
|
22
|
+
from vex.core.models import CatalogEntry
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_rich_console(*args: Any, **kwargs: Any) -> "Console | None":
|
|
26
|
+
"""Safely return a Rich Console instance if available."""
|
|
27
|
+
|
|
28
|
+
if RICH_AVAILABLE:
|
|
29
|
+
return Console(*args, **kwargs) # type: ignore
|
|
30
|
+
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_rich_tree(*args: Any, **kwargs: Any) -> "RichTree | None":
|
|
35
|
+
"""Safely return a Rich Tree instance if available."""
|
|
36
|
+
|
|
37
|
+
if RICH_AVAILABLE:
|
|
38
|
+
return RichTree(*args, **kwargs) # type: ignore
|
|
39
|
+
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_rich_table(*args: Any, **kwargs: Any) -> "Table | None":
|
|
44
|
+
"""Safely return a Rich Table instance if available."""
|
|
45
|
+
|
|
46
|
+
if RICH_AVAILABLE:
|
|
47
|
+
return Table(*args, **kwargs) # type: ignore
|
|
48
|
+
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_markdown(*args: Any, **kwargs: Any) -> "Markdown | None":
|
|
53
|
+
"""Safely return a Rich Markdown instance if available."""
|
|
54
|
+
|
|
55
|
+
if RICH_AVAILABLE:
|
|
56
|
+
return Markdown(*args, **kwargs) # type: ignore
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def display_tree(entries: list[CatalogEntry], use_rich: bool = True) -> None:
|
|
62
|
+
"""Display catalog tree structure."""
|
|
63
|
+
|
|
64
|
+
if not entries:
|
|
65
|
+
print("Catalog is empty. No entries found.")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
console = get_rich_console() if use_rich else None
|
|
69
|
+
tree = get_rich_tree("[bold]vulnerability-explorer[/bold]") if use_rich else None
|
|
70
|
+
|
|
71
|
+
if console is not None and tree is not None:
|
|
72
|
+
rich_categories: dict[str, Any] = {}
|
|
73
|
+
|
|
74
|
+
for e in entries:
|
|
75
|
+
cat = str(e["fm"].get("category", "unknown"))
|
|
76
|
+
ftype = str(e["fm"].get("type", "unknown"))
|
|
77
|
+
fid = str(e["fm"].get("id", "?"))
|
|
78
|
+
|
|
79
|
+
if cat not in rich_categories:
|
|
80
|
+
rich_categories[cat] = tree.add(f"[bold cyan]{cat}[/bold cyan]")
|
|
81
|
+
|
|
82
|
+
if ftype == "concept":
|
|
83
|
+
rich_categories[cat].add(f"[green]{fid}[/green] (concept)")
|
|
84
|
+
elif ftype == "manifestation":
|
|
85
|
+
rich_categories[cat].add(f" {fid}")
|
|
86
|
+
else:
|
|
87
|
+
rich_categories[cat].add(f" [yellow]{fid}[/yellow] (runtime-sig)")
|
|
88
|
+
|
|
89
|
+
console.print(tree)
|
|
90
|
+
else:
|
|
91
|
+
plain_categories: dict[str, list[tuple[str, str]]] = {}
|
|
92
|
+
|
|
93
|
+
for e in entries:
|
|
94
|
+
cat = str(e["fm"].get("category", "unknown"))
|
|
95
|
+
ftype = str(e["fm"].get("type", "unknown"))
|
|
96
|
+
fid = str(e["fm"].get("id", "?"))
|
|
97
|
+
|
|
98
|
+
plain_categories.setdefault(cat, []).append((ftype, fid))
|
|
99
|
+
|
|
100
|
+
for cat, items in sorted(plain_categories.items()):
|
|
101
|
+
print(f"\n{cat}/")
|
|
102
|
+
|
|
103
|
+
for ftype, fid in items:
|
|
104
|
+
prefix = " " if ftype == "concept" else " "
|
|
105
|
+
suffix = f" ({ftype})" if ftype != "manifestation" else ""
|
|
106
|
+
print(f"{prefix}{fid}{suffix}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def display_list(entries: list[CatalogEntry], use_rich: bool = True) -> None:
|
|
110
|
+
"""Display catalog entries table."""
|
|
111
|
+
|
|
112
|
+
if not entries:
|
|
113
|
+
print("No matching catalog entries found.")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
console = get_rich_console() if use_rich else None
|
|
117
|
+
table = get_rich_table(title="Catalog Entries") if use_rich else None
|
|
118
|
+
|
|
119
|
+
if console is not None and table is not None:
|
|
120
|
+
table.add_column("ID", style="cyan")
|
|
121
|
+
table.add_column("Type", style="green")
|
|
122
|
+
table.add_column("Title")
|
|
123
|
+
table.add_column("Languages")
|
|
124
|
+
table.add_column("Severity")
|
|
125
|
+
|
|
126
|
+
for e in entries:
|
|
127
|
+
fm = e["fm"]
|
|
128
|
+
raw_langs = fm.get("languages")
|
|
129
|
+
langs = ", ".join(raw_langs) if isinstance(raw_langs, list) else "-"
|
|
130
|
+
sev = str(fm.get("severity_baseline", "-"))
|
|
131
|
+
|
|
132
|
+
table.add_row(
|
|
133
|
+
str(fm.get("id", "?")),
|
|
134
|
+
str(fm.get("type", "?")),
|
|
135
|
+
str(fm.get("title", "?")),
|
|
136
|
+
langs,
|
|
137
|
+
sev,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
console.print(table)
|
|
141
|
+
else:
|
|
142
|
+
for e in entries:
|
|
143
|
+
fm = e["fm"]
|
|
144
|
+
raw_langs = fm.get("languages")
|
|
145
|
+
langs = ", ".join(raw_langs) if isinstance(raw_langs, list) else "-"
|
|
146
|
+
sev = str(fm.get("severity_baseline", "-"))
|
|
147
|
+
|
|
148
|
+
print(f" {fm.get('id', '?')!s:60s} {fm.get('type', '?')!s:15s} {langs:15s} {sev}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def display_read(entry: CatalogEntry, use_pager: bool = True, use_rich: bool = True) -> None:
|
|
152
|
+
"""Display catalog entry using Pager."""
|
|
153
|
+
|
|
154
|
+
fm = entry["fm"]
|
|
155
|
+
body = entry["body"]
|
|
156
|
+
|
|
157
|
+
console = get_rich_console() if use_rich else None
|
|
158
|
+
|
|
159
|
+
if console is not None:
|
|
160
|
+
|
|
161
|
+
def _render_content(target_console: "Console") -> None:
|
|
162
|
+
target_console.print(f"\n[bold cyan]{fm.get('title', '?')}[/bold cyan]")
|
|
163
|
+
target_console.print(f"[dim]{fm.get('id', '?')} — {fm.get('type', '?')}[/dim]\n")
|
|
164
|
+
|
|
165
|
+
meta_parts: list[str] = []
|
|
166
|
+
raw_langs = fm.get("languages")
|
|
167
|
+
|
|
168
|
+
if isinstance(raw_langs, list):
|
|
169
|
+
meta_parts.append(f"Languages: {', '.join(raw_langs)}")
|
|
170
|
+
|
|
171
|
+
raw_modes = fm.get("analysis_modes")
|
|
172
|
+
|
|
173
|
+
if isinstance(raw_modes, list):
|
|
174
|
+
meta_parts.append(f"Modes: {', '.join(raw_modes)}")
|
|
175
|
+
|
|
176
|
+
if fm.get("severity_baseline"):
|
|
177
|
+
meta_parts.append(f"Severity: {fm['severity_baseline']}")
|
|
178
|
+
|
|
179
|
+
if meta_parts:
|
|
180
|
+
target_console.print("[dim]" + " | ".join(meta_parts) + "[/dim]\n")
|
|
181
|
+
|
|
182
|
+
md = get_markdown(body)
|
|
183
|
+
if md is not None:
|
|
184
|
+
target_console.print(md)
|
|
185
|
+
else:
|
|
186
|
+
target_console.print(body)
|
|
187
|
+
|
|
188
|
+
if use_pager:
|
|
189
|
+
with console.pager(styles=True):
|
|
190
|
+
_render_content(console)
|
|
191
|
+
else:
|
|
192
|
+
_render_content(console)
|
|
193
|
+
else:
|
|
194
|
+
content = f"\n{'=' * 72}\n {fm.get('title', '?')}\n {fm.get('id', '?')} — {fm.get('type', '?')}\n{'=' * 72}\n\n{body}"
|
|
195
|
+
print(content)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def display_search(results: list[tuple[int, CatalogEntry]], query: str, use_rich: bool = True) -> None:
|
|
199
|
+
"""Display search results."""
|
|
200
|
+
|
|
201
|
+
if not results:
|
|
202
|
+
print(f"No results for '{query}'.")
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
print(f"\n{len(results)} result(s) for '{query}':\n")
|
|
206
|
+
|
|
207
|
+
console = get_rich_console() if use_rich else None
|
|
208
|
+
table = get_rich_table() if use_rich else None
|
|
209
|
+
|
|
210
|
+
if console is not None and table is not None:
|
|
211
|
+
table.add_column("Hits", justify="right", style="yellow")
|
|
212
|
+
table.add_column("ID", style="cyan")
|
|
213
|
+
table.add_column("Title")
|
|
214
|
+
|
|
215
|
+
for count, e in results:
|
|
216
|
+
fm = e["fm"]
|
|
217
|
+
table.add_row(str(count), str(fm.get("id", "?")), str(fm.get("title", "?")))
|
|
218
|
+
|
|
219
|
+
console.print(table)
|
|
220
|
+
else:
|
|
221
|
+
for count, e in results:
|
|
222
|
+
fm = e["fm"]
|
|
223
|
+
print(f" [{count:3d}] {fm.get('id', '?')!s:55s} {fm.get('title', '?')!s}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def interactive_navigation(entries: list[CatalogEntry], use_rich: bool = True) -> None:
|
|
227
|
+
"""Interactive navigation menu using Arrow Keys with Questionary."""
|
|
228
|
+
|
|
229
|
+
if not QUESTIONARY_AVAILABLE:
|
|
230
|
+
print("Error: 'questionary' package is required for interactive navigation.")
|
|
231
|
+
print("Install it via: pip install questionary")
|
|
232
|
+
return
|
|
233
|
+
|
|
234
|
+
if not entries:
|
|
235
|
+
print("Catalog is empty.")
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
while True:
|
|
239
|
+
categories: dict[str, list[CatalogEntry]] = {}
|
|
240
|
+
|
|
241
|
+
for e in entries:
|
|
242
|
+
cat = str(e["fm"].get("category", "Uncategorized"))
|
|
243
|
+
categories.setdefault(cat, []).append(e)
|
|
244
|
+
|
|
245
|
+
cat_choices = sorted(categories.keys()) + ["[ Exit ]"]
|
|
246
|
+
selected_cat = questionary.select("Select a vulnerability category:", choices=cat_choices).ask()
|
|
247
|
+
|
|
248
|
+
if not selected_cat or selected_cat == "[ Exit ]":
|
|
249
|
+
break
|
|
250
|
+
|
|
251
|
+
cat_entries = categories[selected_cat]
|
|
252
|
+
entry_choices: list[questionary.Choice] = []
|
|
253
|
+
|
|
254
|
+
for e in cat_entries:
|
|
255
|
+
title = str(e["fm"].get("title", "Untitled"))
|
|
256
|
+
entry_id = str(e["fm"].get("id", "no-id"))
|
|
257
|
+
entry_type = str(e["fm"].get("type", "unknown"))
|
|
258
|
+
entry_choices.append(questionary.Choice(title=f"{entry_id} — {title} ({entry_type})", value=e))
|
|
259
|
+
|
|
260
|
+
entry_choices.append(questionary.Choice(title="[ Back to Categories ]", value=None))
|
|
261
|
+
selected_entry: CatalogEntry | None = questionary.select(f"Select an entry in '{selected_cat}':", choices=entry_choices).ask()
|
|
262
|
+
|
|
263
|
+
if selected_entry:
|
|
264
|
+
display_read(selected_entry, use_pager=True, use_rich=use_rich)
|