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.
@@ -0,0 +1,49 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ DEFAULT_GLOBAL_CACHE_DIR = Path.home() / ".local" / "share" / "vex"
5
+
6
+
7
+ def get_global_cache_dir() -> Path:
8
+ """Return default global user cache directory for vex catalog."""
9
+
10
+ return DEFAULT_GLOBAL_CACHE_DIR
11
+
12
+
13
+ def find_repo_root() -> Path:
14
+ """Find catalog root directory.
15
+
16
+ Priority:
17
+ 1. VEX_CATALOG_PATH environment variable (if directory exists and contains data/catalog or is catalog root)
18
+ 2. CWD and parent directories walking up to find data/catalog
19
+ 3. User global cache (~/.local/share/vex) if data/catalog exists there
20
+ 4. CWD as ultimate fallback
21
+ """
22
+
23
+ # 1. Check environment variable
24
+ env_path = os.getenv("VEX_CATALOG_PATH")
25
+
26
+ if env_path:
27
+ candidate = Path(env_path).resolve()
28
+
29
+ if (candidate / "data" / "catalog").is_dir():
30
+ return candidate
31
+
32
+ if candidate.is_dir():
33
+ return candidate
34
+
35
+ # 2. Walk up from CWD
36
+ path = Path.cwd().resolve()
37
+
38
+ for candidate in [path] + list(path.parents):
39
+ if (candidate / "data" / "catalog").is_dir():
40
+ return candidate
41
+
42
+ # 3. User global cache directory (~/.local/share/vex)
43
+ global_cache = get_global_cache_dir()
44
+
45
+ if (global_cache / "data" / "catalog").is_dir():
46
+ return global_cache
47
+
48
+ # 4. Default to CWD
49
+ return path
vex_lint/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Vulnerability Explorer Linter Package."""
2
+
3
+ from vex_lint.service import lint_catalog
4
+
5
+ __all__ = ["lint_catalog"]
@@ -0,0 +1,25 @@
1
+ """Linting execution context holding shared state, taxonomy, and errors."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from vex.core.models import LintError
8
+ from vex_lint.core.models import ParsedDocument
9
+
10
+
11
+ @dataclass
12
+ class LintContext:
13
+ """Holds repository root, loaded metadata, parsed documents, and violations."""
14
+
15
+ root: Path
16
+ vocab: dict[str, Any] = field(default_factory=dict)
17
+ taxonomy_categories: set[str] = field(default_factory=set)
18
+ documents: list[ParsedDocument] = field(default_factory=list)
19
+ seen_ids: dict[str, Path] = field(default_factory=dict)
20
+ errors: list[LintError] = field(default_factory=list)
21
+
22
+ def add_error(self, path: Path | str, message: str) -> None:
23
+ """Helper to append a LintError to context."""
24
+
25
+ self.errors.append(LintError(path=path, message=message))
@@ -0,0 +1,36 @@
1
+ """Execution engine for registered lint rules."""
2
+
3
+ from vex.core.models import LintError
4
+ from vex_lint.core.context import LintContext
5
+ from vex_lint.core.rule import BaseRule
6
+
7
+
8
+ class LintEngine:
9
+ """Orchestrates rule execution against a LintContext."""
10
+
11
+ def __init__(self) -> None:
12
+ self.rules: list[BaseRule] = []
13
+
14
+ def register_rule(self, rule: BaseRule) -> None:
15
+ """Register a new lint rule."""
16
+
17
+ self.rules.append(rule)
18
+
19
+ def run(self, context: LintContext) -> tuple[list[LintError], int, int]:
20
+ """Execute setup, evaluate, and finish stages for all registered rules.
21
+
22
+ Returns:
23
+ (errors, files_checked_count, unique_ids_count)
24
+ """
25
+
26
+ for rule in self.rules:
27
+ rule.setup(context)
28
+
29
+ for doc in context.documents:
30
+ for rule in self.rules:
31
+ rule.evaluate_document(doc, context)
32
+
33
+ for rule in self.rules:
34
+ rule.finish(context)
35
+
36
+ return context.errors, len(context.documents), len(context.seen_ids)
@@ -0,0 +1,42 @@
1
+ """Data models for vex_lint engine."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class Heading:
10
+ """Represents a Markdown heading extracted from a document AST."""
11
+
12
+ level: int
13
+ text: str
14
+ line: int
15
+ raw_markdown: str
16
+
17
+
18
+ @dataclass
19
+ class ParsedDocument:
20
+ """Represents a parsed Markdown catalog document."""
21
+
22
+ path: Path
23
+ rel_path: Path
24
+ raw_text: str
25
+ frontmatter_raw: dict[str, Any] | None = None
26
+ frontmatter_error: str | None = None
27
+ body: str = ""
28
+ headings: list[Heading] = field(default_factory=list)
29
+
30
+ @property
31
+ def doc_type(self) -> str | None:
32
+ if self.frontmatter_raw and isinstance(self.frontmatter_raw.get("type"), str):
33
+ return str(self.frontmatter_raw["type"])
34
+
35
+ return None
36
+
37
+ @property
38
+ def doc_id(self) -> str | None:
39
+ if self.frontmatter_raw and isinstance(self.frontmatter_raw.get("id"), str):
40
+ return str(self.frontmatter_raw["id"])
41
+
42
+ return None
vex_lint/core/rule.py ADDED
@@ -0,0 +1,30 @@
1
+ """Abstract Base Rule interface for vex-lint."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+ from vex_lint.core.context import LintContext
6
+ from vex_lint.core.models import ParsedDocument
7
+
8
+
9
+ class BaseRule(ABC):
10
+ """Abstract base class for all catalog linting rules."""
11
+
12
+ @property
13
+ @abstractmethod
14
+ def name(self) -> str:
15
+ """Unique identifier for the rule."""
16
+
17
+ @property
18
+ def description(self) -> str:
19
+ """Brief summary of what this rule validates."""
20
+
21
+ return ""
22
+
23
+ def setup(self, context: LintContext) -> None:
24
+ """Optional hook executed before per-document evaluation."""
25
+
26
+ def evaluate_document(self, doc: ParsedDocument, context: LintContext) -> None:
27
+ """Evaluate a single parsed document."""
28
+
29
+ def finish(self, context: LintContext) -> None:
30
+ """Optional hook executed after all documents have been evaluated."""
vex_lint/linter.py ADDED
@@ -0,0 +1,5 @@
1
+ """Catalog validation logic and linting entry point."""
2
+
3
+ from vex_lint.service import lint_catalog
4
+
5
+ __all__ = ["lint_catalog"]
@@ -0,0 +1,88 @@
1
+ """Markdown parser using markdown-it-py AST to extract frontmatter and top-level headings."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+ from markdown_it import MarkdownIt
9
+
10
+ from vex_lint.core.models import Heading, ParsedDocument
11
+
12
+ FRONTMATTER_RE = re.compile(r"\A---\n(.*?\n)---\n?(.*)\Z", re.DOTALL)
13
+
14
+ _md_parser = MarkdownIt("commonmark")
15
+
16
+
17
+ def parse_markdown_file(path: Path, root: Path) -> ParsedDocument:
18
+ """Parse a Markdown file, splitting YAML frontmatter and extracting AST headings."""
19
+
20
+ rel_path = path.relative_to(root)
21
+ text = path.read_text(encoding="utf-8")
22
+
23
+ match = FRONTMATTER_RE.match(text)
24
+
25
+ if not match:
26
+ return ParsedDocument(
27
+ path=path,
28
+ rel_path=rel_path,
29
+ raw_text=text,
30
+ frontmatter_error="no valid --- frontmatter block found",
31
+ )
32
+
33
+ fm_text, body = match.group(1), match.group(2)
34
+
35
+ try:
36
+ fm_data: Any = yaml.safe_load(fm_text)
37
+ except yaml.YAMLError as err:
38
+ return ParsedDocument(
39
+ path=path,
40
+ rel_path=rel_path,
41
+ raw_text=text,
42
+ frontmatter_error=f"frontmatter is not valid YAML: {err}",
43
+ body=body,
44
+ )
45
+
46
+ if not isinstance(fm_data, dict):
47
+ return ParsedDocument(
48
+ path=path,
49
+ rel_path=rel_path,
50
+ raw_text=text,
51
+ frontmatter_raw=None,
52
+ frontmatter_error="frontmatter did not parse to a mapping",
53
+ body=body,
54
+ )
55
+
56
+ # Extract headings using AST tokens
57
+ tokens = _md_parser.parse(body)
58
+ headings: list[Heading] = []
59
+
60
+ for i, token in enumerate(tokens):
61
+ if token.type == "heading_open":
62
+ level = int(token.tag[1:]) if (token.tag and len(token.tag) > 1) else 1
63
+ line = token.map[0] if token.map else 0
64
+
65
+ # The next token is usually inline containing children/text
66
+ heading_text = ""
67
+
68
+ if i + 1 < len(tokens) and tokens[i + 1].type == "inline":
69
+ heading_text = tokens[i + 1].content.strip()
70
+
71
+ raw_heading = f"{'#' * level} {heading_text}"
72
+ headings.append(
73
+ Heading(
74
+ level=level,
75
+ text=heading_text,
76
+ line=line,
77
+ raw_markdown=raw_heading,
78
+ )
79
+ )
80
+
81
+ return ParsedDocument(
82
+ path=path,
83
+ rel_path=rel_path,
84
+ raw_text=text,
85
+ frontmatter_raw=fm_data,
86
+ body=body,
87
+ headings=headings,
88
+ )
@@ -0,0 +1,32 @@
1
+ """Loaders for controlled vocabularies and taxonomy markdown specifications."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ TAXONOMY_HEADING_RE = re.compile(r"^###\s+`([a-z0-9-]+)`\s+—", re.MULTILINE)
10
+
11
+
12
+ def load_vocab(contributing_dir: Path) -> dict[str, Any]:
13
+ """Load controlled vocabularies from YAML file."""
14
+
15
+ vocab_path = contributing_dir / "references" / "controlled-vocabularies.yaml"
16
+
17
+ with open(vocab_path, encoding="utf-8") as f:
18
+ data: Any = yaml.safe_load(f)
19
+
20
+ if isinstance(data, dict):
21
+ return data
22
+
23
+ return {}
24
+
25
+
26
+ def load_taxonomy_categories(contributing_dir: Path) -> set[str]:
27
+ """Load category headings from the taxonomy Markdown file."""
28
+
29
+ taxonomy_path = contributing_dir / "references" / "taxonomy.md"
30
+ text = taxonomy_path.read_text(encoding="utf-8")
31
+
32
+ return set(TAXONOMY_HEADING_RE.findall(text))
vex_lint/main.py ADDED
@@ -0,0 +1,34 @@
1
+ """Main entrypoint for vex-lint CLI app."""
2
+
3
+ import typer
4
+
5
+ from vex.commands.options import OptRoot
6
+ from vex.utils.filesystem import find_repo_root
7
+ from vex_lint.linter import lint_catalog
8
+
9
+ app = typer.Typer(name="vex-lint", help="Structural and referential integrity linter for the vulnerability catalog.")
10
+
11
+
12
+ @app.callback(invoke_without_command=True)
13
+ def main(root: OptRoot = None) -> None:
14
+ """Lint the vulnerability catalog structure and references."""
15
+
16
+ repo_root = root.resolve() if root else find_repo_root()
17
+
18
+ errors, files_count, ids_count = lint_catalog(repo_root)
19
+
20
+ if errors:
21
+ typer.echo(f"lint_catalog: {len(errors)} violation(s) found\n", err=True)
22
+
23
+ for err in errors:
24
+ typer.echo(f" - {err}", err=True)
25
+
26
+ raise typer.Exit(code=1)
27
+
28
+ typer.echo(f"lint_catalog: OK ({files_count} files checked, {ids_count} ids)")
29
+
30
+ raise typer.Exit(code=0)
31
+
32
+
33
+ if __name__ == "__main__":
34
+ app()
@@ -0,0 +1,47 @@
1
+ """Rule validating cross-document references (parent, related_files, applies_to_mechanisms)."""
2
+
3
+ from vex_lint.core.context import LintContext
4
+ from vex_lint.core.rule import BaseRule
5
+
6
+
7
+ class CrossReferenceRule(BaseRule):
8
+ """Validates that referenced IDs exist in the parsed catalog."""
9
+
10
+ @property
11
+ def name(self) -> str:
12
+ return "cross-reference"
13
+
14
+ @property
15
+ def description(self) -> str:
16
+ return "Validates that IDs in parent, related_files, and applies_to_mechanisms exist."
17
+
18
+ def finish(self, context: LintContext) -> None:
19
+ seen_ids = context.seen_ids
20
+
21
+ for doc in context.documents:
22
+ if doc.frontmatter_raw is None:
23
+ continue
24
+
25
+ fm = doc.frontmatter_raw
26
+
27
+ parent = fm.get("parent")
28
+
29
+ if parent and parent not in seen_ids:
30
+ context.add_error(
31
+ doc.rel_path,
32
+ f"`parent: {parent}` does not match any existing `id`",
33
+ )
34
+
35
+ related_files = fm.get("related_files")
36
+
37
+ if isinstance(related_files, list):
38
+ for ref in related_files:
39
+ if ref not in seen_ids:
40
+ context.add_error(doc.rel_path, f"`related_files` entry '{ref}' does not match any existing `id`")
41
+
42
+ applies_to = fm.get("applies_to_mechanisms")
43
+
44
+ if isinstance(applies_to, list):
45
+ for ref in applies_to:
46
+ if ref not in seen_ids:
47
+ context.add_error(doc.rel_path, f"`applies_to_mechanisms` entry '{ref}' does not match any existing `id`")
@@ -0,0 +1,68 @@
1
+ """Rule validating YAML frontmatter syntax, required fields, and controlled vocabulary values."""
2
+
3
+ from vex_lint.core.context import LintContext
4
+ from vex_lint.core.models import ParsedDocument
5
+ from vex_lint.core.rule import BaseRule
6
+ from vex_lint.schemas.frontmatter import validate_frontmatter_schema
7
+
8
+ ENUM_FIELDS: dict[str, str] = {
9
+ "type": "type",
10
+ "status": "status",
11
+ "category": "category",
12
+ "severity_baseline": "severity_baseline",
13
+ }
14
+
15
+ LIST_ENUM_FIELDS: dict[str, str] = {
16
+ "analysis_modes": "analysis_modes",
17
+ "architectures": "architectures",
18
+ }
19
+
20
+
21
+ class FrontmatterSchemaRule(BaseRule):
22
+ """Rule that validates frontmatter YAML structure and vocabulary fields."""
23
+
24
+ @property
25
+ def name(self) -> str:
26
+ return "frontmatter-schema"
27
+
28
+ @property
29
+ def description(self) -> str:
30
+ return "Validates that frontmatter is valid YAML, matches Pydantic schema, and complies with controlled vocabularies."
31
+
32
+ def evaluate_document(self, doc: ParsedDocument, context: LintContext) -> None:
33
+ if doc.frontmatter_error:
34
+ context.add_error(doc.rel_path, doc.frontmatter_error)
35
+ return
36
+
37
+ fm = doc.frontmatter_raw
38
+
39
+ if fm is None:
40
+ return
41
+
42
+ schema_err = validate_frontmatter_schema(fm)
43
+
44
+ if schema_err:
45
+ context.add_error(doc.rel_path, schema_err)
46
+
47
+ # Validate controlled vocabulary enums
48
+ vocab = context.vocab
49
+
50
+ for field, vocab_key in ENUM_FIELDS.items():
51
+ if field in fm and fm[field] is not None:
52
+ allowed = set(vocab.get(vocab_key, []))
53
+ val = fm[field]
54
+
55
+ if val not in allowed:
56
+ context.add_error(doc.rel_path, f"`{field}` value '{val}' not in controlled vocabulary `{vocab_key}`")
57
+
58
+ for field, vocab_key in LIST_ENUM_FIELDS.items():
59
+ if field in fm and fm[field] is not None:
60
+ allowed = set(vocab.get(vocab_key, []))
61
+ val = fm[field]
62
+
63
+ if not isinstance(val, list):
64
+ context.add_error(doc.rel_path, f"`{field}` must be a list")
65
+ else:
66
+ for item in val:
67
+ if item not in allowed:
68
+ context.add_error(doc.rel_path, f"`{field}` entry '{item}' not in controlled vocabulary `{vocab_key}`")
@@ -0,0 +1,49 @@
1
+ """Rule validating presence and sequential ordering of required Markdown headings and subheadings."""
2
+
3
+ from vex_lint.core.context import LintContext
4
+ from vex_lint.core.models import ParsedDocument
5
+ from vex_lint.core.rule import BaseRule
6
+ from vex_lint.schemas.sections import REQUIRED_SECTIONS, REQUIRED_SUBSECTIONS
7
+
8
+
9
+ class MarkdownStructureRule(BaseRule):
10
+ """Validates that documents contain all required sections and subsections in correct order."""
11
+
12
+ @property
13
+ def name(self) -> str:
14
+ return "markdown-structure"
15
+
16
+ @property
17
+ def description(self) -> str:
18
+ return "Checks that Markdown documents possess required headings (h2) and subheadings (h3) in prescribed order."
19
+
20
+ def evaluate_document(self, doc: ParsedDocument, context: LintContext) -> None:
21
+ ftype = doc.doc_type
22
+
23
+ if not ftype:
24
+ return
25
+
26
+ if ftype in REQUIRED_SECTIONS:
27
+ self._check_sections(doc, REQUIRED_SECTIONS[ftype], context)
28
+
29
+ if ftype in REQUIRED_SUBSECTIONS:
30
+ self._check_sections(doc, REQUIRED_SUBSECTIONS[ftype], context)
31
+
32
+ def _check_sections(self, doc: ParsedDocument, required: list[str], context: LintContext) -> None:
33
+ heading_positions: dict[str, int] = {}
34
+
35
+ for idx, heading in enumerate(doc.headings):
36
+ raw = heading.raw_markdown
37
+
38
+ if raw in required and raw not in heading_positions:
39
+ heading_positions[raw] = idx
40
+
41
+ for req in required:
42
+ if req not in heading_positions:
43
+ context.add_error(doc.rel_path, f"missing required section '{req}'")
44
+
45
+ found_order = [h for h in required if h in heading_positions]
46
+ positions = [heading_positions[h] for h in found_order]
47
+
48
+ if positions != sorted(positions):
49
+ context.add_error(doc.rel_path, "required sections are present but out of order")
@@ -0,0 +1,72 @@
1
+ """Rule validating path naming conventions, path-derived IDs/types, and ID uniqueness."""
2
+
3
+ from pathlib import Path
4
+
5
+ from vex_lint.core.context import LintContext
6
+ from vex_lint.core.models import ParsedDocument
7
+ from vex_lint.core.rule import BaseRule
8
+
9
+
10
+ def expected_id_and_type(rel_path: Path) -> tuple[str | None, str | None]:
11
+ """Given a path relative to repo root, return (expected_id, expected_type)."""
12
+ parts = rel_path.parts
13
+
14
+ if len(parts) >= 2 and parts[0] == "data" and parts[1] == "catalog":
15
+ if len(parts) == 5 and parts[4] == "README.md":
16
+ category, mechanism = parts[2], parts[3]
17
+ return f"{category}.{mechanism}", "concept"
18
+
19
+ if len(parts) == 5 and parts[4].endswith(".md"):
20
+ category, mechanism, filename = parts[2], parts[3], parts[4][:-3]
21
+
22
+ if "_" not in filename:
23
+ return None, None
24
+
25
+ variant, context = filename.split("_", 1)
26
+
27
+ return f"{category}.{mechanism}.{variant}.{context}", "manifestation"
28
+
29
+ if len(parts) >= 2 and parts[0] == "data" and parts[1] == "signatures" and len(parts) == 3 and parts[2].endswith(".md"):
30
+ name = parts[2][:-3]
31
+
32
+ return f"runtime-signature.{name.replace('_', '.')}", "runtime-signature"
33
+
34
+ return None, None
35
+
36
+
37
+ class PathConventionRule(BaseRule):
38
+ """Validates file path naming conventions, derived ID matching, and unique IDs."""
39
+
40
+ @property
41
+ def name(self) -> str:
42
+ return "path-convention"
43
+
44
+ @property
45
+ def description(self) -> str:
46
+ return "Validates that file paths follow standard hierarchy and match internal frontmatter IDs and types."
47
+
48
+ def evaluate_document(self, doc: ParsedDocument, context: LintContext) -> None:
49
+ if doc.frontmatter_raw is None:
50
+ return
51
+
52
+ fm = doc.frontmatter_raw
53
+ ftype = fm.get("type")
54
+
55
+ expected_id, expected_type = expected_id_and_type(doc.rel_path)
56
+
57
+ if expected_id is None:
58
+ context.add_error(doc.rel_path, "path does not match any recognized naming pattern")
59
+ else:
60
+ if ftype != expected_type:
61
+ context.add_error(doc.rel_path, f"`type: {ftype}` does not match path pattern (expected `{expected_type}`)")
62
+
63
+ if fm.get("id") != expected_id:
64
+ context.add_error(doc.rel_path, f"`id: {fm.get('id')}` does not match path-derived id `{expected_id}`")
65
+
66
+ fid = fm.get("id")
67
+
68
+ if isinstance(fid, str) and fid:
69
+ if fid in context.seen_ids:
70
+ context.add_error(doc.rel_path, f"duplicate `id` '{fid}', already used by {context.seen_ids[fid]}")
71
+ else:
72
+ context.seen_ids[fid] = doc.rel_path
@@ -0,0 +1,30 @@
1
+ """Rule validating consistency between controlled vocabularies and taxonomy documentation."""
2
+
3
+ from vex_lint.core.context import LintContext
4
+ from vex_lint.core.rule import BaseRule
5
+
6
+
7
+ class TaxonomyRule(BaseRule):
8
+ """Ensures category lists match between controlled-vocabularies.yaml and taxonomy.md."""
9
+
10
+ @property
11
+ def name(self) -> str:
12
+ return "taxonomy-consistency"
13
+
14
+ @property
15
+ def description(self) -> str:
16
+ return "Checks that taxonomy categories in docs/contributing/references match controlled vocabularies."
17
+
18
+ def setup(self, context: LintContext) -> None:
19
+ vocab_categories = set(context.vocab.get("category", []))
20
+ taxonomy_categories = context.taxonomy_categories
21
+
22
+ if vocab_categories != taxonomy_categories:
23
+ only_vocab = vocab_categories - taxonomy_categories
24
+ only_taxonomy = taxonomy_categories - vocab_categories
25
+
26
+ if only_vocab:
27
+ context.add_error("docs/contributing/references/controlled-vocabularies.yaml", f"categories not documented in references/taxonomy.md: {sorted(only_vocab)}")
28
+
29
+ if only_taxonomy:
30
+ context.add_error("docs/contributing/references/taxonomy.md", f"categories not registered in references/controlled-vocabularies.yaml: {sorted(only_taxonomy)}")