vigilo 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.
vigilo/__init__.py ADDED
@@ -0,0 +1,50 @@
1
+ """Vigilo — A static, security-focused code scanner for Python."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+
8
+ from vigilo.models import DetectorMeta, Finding, Location, Severity
9
+ from vigilo.scanner import ScanConfig, Scanner
10
+
11
+ __version__ = "0.1.0"
12
+
13
+
14
+ def scan(
15
+ path: Path | str = ".",
16
+ min_severity: Severity | str = Severity.LOW,
17
+ exclude_patterns: Sequence[str] | None = None,
18
+ ) -> list[Finding]:
19
+ """Scan a target path for known security vulnerabilities.
20
+
21
+ Args:
22
+ path: Directory or file path to scan (defaults to current directory).
23
+ min_severity: Minimum severity threshold ("low", "medium", "high").
24
+ exclude_patterns: Glob patterns to exclude during file discovery.
25
+
26
+ Returns:
27
+ List of Finding objects discovered during scan.
28
+ """
29
+ if isinstance(min_severity, str):
30
+ min_severity = Severity(min_severity.lower())
31
+
32
+ config = ScanConfig(
33
+ paths=[path],
34
+ min_severity=min_severity,
35
+ exclude_patterns=exclude_patterns,
36
+ )
37
+ scanner = Scanner(config)
38
+ return scanner.scan()
39
+
40
+
41
+ __all__ = [
42
+ "__version__",
43
+ "scan",
44
+ "Scanner",
45
+ "ScanConfig",
46
+ "Finding",
47
+ "Severity",
48
+ "Location",
49
+ "DetectorMeta",
50
+ ]
vigilo/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entrypoint for python -m vigilo."""
2
+
3
+ from vigilo.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
vigilo/cli.py ADDED
@@ -0,0 +1,130 @@
1
+ """CLI entry point and command-line parser for Vigilo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+
10
+ from vigilo import __version__
11
+ from vigilo.models import Severity
12
+ from vigilo.reporter import format_report
13
+ from vigilo.scanner import ScanConfig, Scanner
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ """Construct the command-line argument parser."""
18
+ parser = argparse.ArgumentParser(
19
+ prog="vigilo",
20
+ description="Vigilo — A static, security-focused code scanner for Python.",
21
+ )
22
+ parser.add_argument(
23
+ "--version",
24
+ "-V",
25
+ action="version",
26
+ version=f"vigilo {__version__}",
27
+ )
28
+
29
+ subparsers = parser.add_subparsers(dest="command", help="Available subcommands")
30
+
31
+ scan_parser = subparsers.add_parser(
32
+ "scan",
33
+ help="Scan a directory or file for security vulnerabilities",
34
+ )
35
+ scan_parser.add_argument(
36
+ "target",
37
+ nargs="?",
38
+ default=".",
39
+ help="Path to directory or file to scan (default: '.')",
40
+ )
41
+ scan_parser.add_argument(
42
+ "--format",
43
+ "-f",
44
+ choices=["text", "json"],
45
+ default="text",
46
+ help="Output report format (default: 'text')",
47
+ )
48
+ scan_parser.add_argument(
49
+ "--min-severity",
50
+ "-s",
51
+ choices=["low", "medium", "high"],
52
+ default="low",
53
+ help="Minimum severity threshold to report (default: 'low')",
54
+ )
55
+ scan_parser.add_argument(
56
+ "--exclude",
57
+ "-e",
58
+ action="append",
59
+ default=[],
60
+ help="Exclude files/directories matching glob pattern (repeatable)",
61
+ )
62
+ scan_parser.add_argument(
63
+ "--no-color",
64
+ action="store_true",
65
+ default=False,
66
+ help="Disable ANSI color codes in output",
67
+ )
68
+
69
+ return parser
70
+
71
+
72
+ def main(argv: Sequence[str] | None = None) -> int:
73
+ """Main execution entrypoint for Vigilo CLI."""
74
+ if argv is None:
75
+ argv = sys.argv[1:]
76
+
77
+ args_list = list(argv)
78
+
79
+ # Normalize alias: `vigilo <path>` -> `vigilo scan <path>`
80
+ if not args_list:
81
+ args_list = ["scan", "."]
82
+ elif args_list[0] not in ("scan", "-h", "--help", "-V", "--version"):
83
+ args_list = ["scan"] + args_list
84
+
85
+ parser = build_parser()
86
+
87
+ try:
88
+ args = parser.parse_args(args_list)
89
+ except SystemExit as e:
90
+ return int(e.code) if isinstance(e.code, int) else 2
91
+
92
+ if getattr(args, "command", None) != "scan":
93
+ parser.print_help()
94
+ return 0
95
+
96
+ target_str = getattr(args, "target", ".")
97
+ target_path = Path(target_str)
98
+ if not target_path.exists():
99
+ sys.stderr.write(f"Error: Target path does not exist: {target_str}\n")
100
+ return 2
101
+
102
+ out_format = getattr(args, "format", "text")
103
+ min_sev_str = getattr(args, "min_severity", "low")
104
+ excludes = getattr(args, "exclude", [])
105
+ no_color = getattr(args, "no_color", False)
106
+
107
+ min_severity = Severity(min_sev_str.lower())
108
+ use_color = not no_color and sys.stdout.isatty() and out_format == "text"
109
+
110
+ try:
111
+ config = ScanConfig(
112
+ paths=[target_path],
113
+ min_severity=min_severity,
114
+ exclude_patterns=excludes if excludes else None,
115
+ )
116
+ scanner = Scanner(config)
117
+ findings = scanner.scan()
118
+ except Exception as e:
119
+ sys.stderr.write(f"Scan error: {e}\n")
120
+ return 2
121
+
122
+ report_str = format_report(findings, output_format=out_format, use_color=use_color)
123
+ print(report_str)
124
+
125
+ # Exit code: 1 if findings present, 0 if clean
126
+ return 1 if findings else 0
127
+
128
+
129
+ if __name__ == "__main__":
130
+ sys.exit(main())
@@ -0,0 +1,28 @@
1
+ """Detector registry and public interface for Vigilo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from vigilo.detectors.base import BaseDetector
6
+ from vigilo.detectors.code_injection import CodeInjectionDetector
7
+ from vigilo.detectors.command_injection import CommandInjectionDetector
8
+ from vigilo.detectors.path_traversal import PathTraversalDetector
9
+ from vigilo.detectors.sql_injection import SQLInjectionDetector
10
+ from vigilo.detectors.unsafe_deserialization import UnsafeDeserializationDetector
11
+
12
+ ALL_DETECTORS: list[type[BaseDetector]] = [
13
+ SQLInjectionDetector,
14
+ CommandInjectionDetector,
15
+ CodeInjectionDetector,
16
+ UnsafeDeserializationDetector,
17
+ PathTraversalDetector,
18
+ ]
19
+
20
+ __all__ = [
21
+ "BaseDetector",
22
+ "ALL_DETECTORS",
23
+ "SQLInjectionDetector",
24
+ "CommandInjectionDetector",
25
+ "CodeInjectionDetector",
26
+ "UnsafeDeserializationDetector",
27
+ "PathTraversalDetector",
28
+ ]
@@ -0,0 +1,89 @@
1
+ """Base classes and interfaces for vulnerability detectors in Vigilo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from abc import ABC, abstractmethod
7
+ from pathlib import Path
8
+
9
+ from vigilo.models import DetectorMeta, Finding, Location, Severity
10
+
11
+
12
+ class BaseDetector(ABC):
13
+ """Abstract base class for all Vigilo vulnerability detectors."""
14
+
15
+ meta: DetectorMeta
16
+
17
+ @abstractmethod
18
+ def run(self, tree: ast.Module, file_path: Path, source: str) -> list[Finding]:
19
+ """Scan a parsed Python AST module for vulnerability patterns.
20
+
21
+ Args:
22
+ tree: Parsed AST module node.
23
+ file_path: Path to the scanned file.
24
+ source: Raw string content of the source file.
25
+
26
+ Returns:
27
+ List of findings discovered in this file.
28
+ """
29
+ ...
30
+
31
+ def create_finding(
32
+ self,
33
+ node: ast.AST,
34
+ file_path: Path,
35
+ source: str,
36
+ message: str,
37
+ fix_hint: str,
38
+ severity: Severity | None = None,
39
+ confidence: str = "high",
40
+ ) -> Finding:
41
+ """Create a standardized Finding instance from an AST node."""
42
+ lineno = getattr(node, "lineno", 1)
43
+ col_offset = getattr(node, "col_offset", 0)
44
+ end_lineno = getattr(node, "end_lineno", None)
45
+ end_col_offset = getattr(node, "end_col_offset", None)
46
+
47
+ source_lines = source.splitlines()
48
+ source_line = ""
49
+ if 1 <= lineno <= len(source_lines):
50
+ source_line = source_lines[lineno - 1]
51
+
52
+ location = Location(
53
+ file=file_path,
54
+ line=lineno,
55
+ col=col_offset,
56
+ end_line=end_lineno,
57
+ end_col=end_col_offset,
58
+ )
59
+
60
+ return Finding(
61
+ detector=self.meta,
62
+ location=location,
63
+ message=message,
64
+ fix_hint=fix_hint,
65
+ severity=severity or self.meta.severity,
66
+ confidence=confidence,
67
+ source_line=source_line,
68
+ )
69
+
70
+ @staticmethod
71
+ def build_parent_map(tree: ast.Module) -> dict[ast.AST, ast.AST]:
72
+ """Build a mapping of child AST nodes to their parent nodes."""
73
+ parent_map: dict[ast.AST, ast.AST] = {}
74
+ for parent in ast.walk(tree):
75
+ for child in ast.iter_child_nodes(parent):
76
+ parent_map[child] = parent
77
+ return parent_map
78
+
79
+ @staticmethod
80
+ def get_enclosing_function(
81
+ node: ast.AST, parent_map: dict[ast.AST, ast.AST]
82
+ ) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
83
+ """Find the enclosing function definition for a given AST node."""
84
+ curr: ast.AST | None = node
85
+ while curr is not None:
86
+ if isinstance(curr, (ast.FunctionDef, ast.AsyncFunctionDef)):
87
+ return curr
88
+ curr = parent_map.get(curr)
89
+ return None
@@ -0,0 +1,63 @@
1
+ """Detector for Code Injection vulnerabilities (CWE-94 / VIGILO-003)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+
8
+ from vigilo.detectors.base import BaseDetector
9
+ from vigilo.flow import FlowAnalyzer
10
+ from vigilo.models import DetectorMeta, Finding, Severity
11
+
12
+ CODE_EXEC_FUNCTIONS = {"eval", "exec", "compile"}
13
+
14
+
15
+ class CodeInjectionDetector(BaseDetector):
16
+ """Detects dangerous evaluation or execution of dynamically constructed code."""
17
+
18
+ meta = DetectorMeta(
19
+ id="VIGILO-003",
20
+ name="Code Injection",
21
+ cwe=94,
22
+ description="Detects dynamic expressions passed to eval(), exec(), or compile()",
23
+ severity=Severity.HIGH,
24
+ )
25
+
26
+ def run(self, tree: ast.Module, file_path: Path, source: str) -> list[Finding]:
27
+ findings: list[Finding] = []
28
+ parent_map = self.build_parent_map(tree)
29
+
30
+ for node in ast.walk(tree):
31
+ if not isinstance(node, ast.Call):
32
+ continue
33
+
34
+ func_name = None
35
+ if isinstance(node.func, ast.Name):
36
+ func_name = node.func.id
37
+ elif isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
38
+ if node.func.value.id == "builtins":
39
+ func_name = node.func.attr
40
+
41
+ if func_name in CODE_EXEC_FUNCTIONS and node.args:
42
+ code_arg = node.args[0]
43
+ scope = self.get_enclosing_function(node, parent_map)
44
+
45
+ if FlowAnalyzer.is_dynamic(code_arg, scope):
46
+ msg = f"Possible code injection: dynamic expression passed to `{func_name}()`."
47
+ hint = (
48
+ "Avoid executing arbitrary code. Use `ast.literal_eval()` for safely "
49
+ "parsing literals, or use `json.loads()` for structured data."
50
+ )
51
+ findings.append(
52
+ self.create_finding(
53
+ node=node,
54
+ file_path=file_path,
55
+ source=source,
56
+ message=msg,
57
+ fix_hint=hint,
58
+ severity=self.meta.severity,
59
+ confidence="high",
60
+ )
61
+ )
62
+
63
+ return findings
@@ -0,0 +1,107 @@
1
+ """Detector for OS Command Injection vulnerabilities (CWE-78 / VIGILO-002)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+
8
+ from vigilo.detectors.base import BaseDetector
9
+ from vigilo.flow import FlowAnalyzer
10
+ from vigilo.models import DetectorMeta, Finding, Severity
11
+
12
+ SUBPROCESS_METHODS = {
13
+ "run",
14
+ "Popen",
15
+ "call",
16
+ "check_call",
17
+ "check_output",
18
+ }
19
+
20
+
21
+ class CommandInjectionDetector(BaseDetector):
22
+ """Detects dangerous OS command executions with dynamic or unsanitized input."""
23
+
24
+ meta = DetectorMeta(
25
+ id="VIGILO-002",
26
+ name="OS Command Injection",
27
+ cwe=78,
28
+ description="Detects dynamic shell commands passed to execution functions",
29
+ severity=Severity.HIGH,
30
+ )
31
+
32
+ def _has_shell_true(self, node: ast.Call) -> bool:
33
+ """Check if shell=True is present in keyword arguments."""
34
+ for kw in node.keywords:
35
+ if kw.arg == "shell":
36
+ val = FlowAnalyzer.get_constant_value(kw.value)
37
+ return bool(val is True)
38
+ return False
39
+
40
+ def run(self, tree: ast.Module, file_path: Path, source: str) -> list[Finding]:
41
+ findings: list[Finding] = []
42
+ parent_map = self.build_parent_map(tree)
43
+
44
+ for node in ast.walk(tree):
45
+ if not isinstance(node, ast.Call):
46
+ continue
47
+
48
+ scope = self.get_enclosing_function(node, parent_map)
49
+
50
+ # Check os.system(...) and os.popen(...)
51
+ if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
52
+ if node.func.value.id == "os" and node.func.attr in ("system", "popen"):
53
+ if node.args and FlowAnalyzer.is_dynamic(node.args[0], scope):
54
+ attr = node.func.attr
55
+ msg = f"Possible OS command injection: dynamic command in `os.{attr}()`."
56
+ hint = (
57
+ "Use `subprocess.run([...], shell=False)` with an argument list "
58
+ "instead of invoking shell commands."
59
+ )
60
+ findings.append(
61
+ self.create_finding(
62
+ node=node,
63
+ file_path=file_path,
64
+ source=source,
65
+ message=msg,
66
+ fix_hint=hint,
67
+ severity=self.meta.severity,
68
+ confidence="high",
69
+ )
70
+ )
71
+ continue
72
+
73
+ # Check subprocess methods with shell=True and dynamic input
74
+ is_subprocess = False
75
+ func_name = ""
76
+ if isinstance(node.func, ast.Attribute):
77
+ if isinstance(node.func.value, ast.Name) and node.func.value.id == "subprocess":
78
+ is_subprocess = True
79
+ func_name = node.func.attr
80
+ elif isinstance(node.func, ast.Name) and node.func.id in SUBPROCESS_METHODS:
81
+ is_subprocess = True
82
+ func_name = node.func.id
83
+
84
+ if is_subprocess and func_name in SUBPROCESS_METHODS and node.args:
85
+ cmd_arg = node.args[0]
86
+ if self._has_shell_true(node) and FlowAnalyzer.is_dynamic(cmd_arg, scope):
87
+ msg = (
88
+ f"Possible OS command injection: dynamic command in "
89
+ f"`subprocess.{func_name}(shell=True)`."
90
+ )
91
+ hint = (
92
+ "Avoid `shell=True`. Pass a list of command arguments with `shell=False` "
93
+ "(e.g., subprocess.run(['cmd', arg], shell=False))."
94
+ )
95
+ findings.append(
96
+ self.create_finding(
97
+ node=node,
98
+ file_path=file_path,
99
+ source=source,
100
+ message=msg,
101
+ fix_hint=hint,
102
+ severity=self.meta.severity,
103
+ confidence="high",
104
+ )
105
+ )
106
+
107
+ return findings
@@ -0,0 +1,103 @@
1
+ """Detector for Path Traversal vulnerabilities (CWE-22 / VIGILO-005)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ from pathlib import Path
7
+
8
+ from vigilo.detectors.base import BaseDetector
9
+ from vigilo.flow import FlowAnalyzer
10
+ from vigilo.models import DetectorMeta, Finding, Severity
11
+
12
+ PATH_OPEN_FUNCTIONS = {"open"}
13
+ PATH_MODULE_FUNCTIONS = {("os", "open"), ("io", "open")}
14
+
15
+
16
+ class PathTraversalDetector(BaseDetector):
17
+ """Detects unsafe file path construction that may allow directory traversal."""
18
+
19
+ meta = DetectorMeta(
20
+ id="VIGILO-005",
21
+ name="Path Traversal",
22
+ cwe=22,
23
+ description="Detects dynamic or user-controlled paths passed to file open functions",
24
+ severity=Severity.HIGH,
25
+ )
26
+
27
+ def _is_dynamic_path(
28
+ self,
29
+ node: ast.AST,
30
+ scope: ast.FunctionDef | ast.AsyncFunctionDef | None,
31
+ ) -> bool:
32
+ """Check if an expression represents a dynamic path."""
33
+ if FlowAnalyzer.is_constant(node):
34
+ return False
35
+
36
+ # Dynamic string concatenation (e.g., "/base/" + filename)
37
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
38
+ return FlowAnalyzer.is_dynamic(node, scope)
39
+
40
+ # Dynamic f-string (e.g., f"/base/{filename}")
41
+ if isinstance(node, ast.JoinedStr):
42
+ return FlowAnalyzer.is_dynamic(node, scope)
43
+
44
+ # String formatting with % or .format()
45
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Mod):
46
+ return FlowAnalyzer.is_dynamic(node, scope)
47
+
48
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
49
+ if node.func.attr == "format":
50
+ return any(FlowAnalyzer.is_dynamic(arg, scope) for arg in node.args)
51
+
52
+ # Path passed directly from dynamic variable / parameter
53
+ if isinstance(node, ast.Name):
54
+ return FlowAnalyzer.is_dynamic(node, scope)
55
+
56
+ return False
57
+
58
+ def run(self, tree: ast.Module, file_path: Path, source: str) -> list[Finding]:
59
+ findings: list[Finding] = []
60
+ parent_map = self.build_parent_map(tree)
61
+
62
+ for node in ast.walk(tree):
63
+ if not isinstance(node, ast.Call):
64
+ continue
65
+
66
+ scope = self.get_enclosing_function(node, parent_map)
67
+ is_file_open = False
68
+ func_name = ""
69
+
70
+ # Check built-in open(...)
71
+ if isinstance(node.func, ast.Name) and node.func.id in PATH_OPEN_FUNCTIONS:
72
+ is_file_open = True
73
+ func_name = node.func.id
74
+
75
+ # Check os.open(...), io.open(...)
76
+ elif isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
77
+ module_name = node.func.value.id
78
+ method_name = node.func.attr
79
+ if (module_name, method_name) in PATH_MODULE_FUNCTIONS:
80
+ is_file_open = True
81
+ func_name = f"{module_name}.{method_name}"
82
+
83
+ if is_file_open and node.args:
84
+ path_arg = node.args[0]
85
+ if self._is_dynamic_path(path_arg, scope):
86
+ msg = f"Possible path traversal: dynamic path in `{func_name}()`."
87
+ hint = (
88
+ "Sanitize file paths using `os.path.basename()` or verify that "
89
+ "the resolved path starts with the intended base directory."
90
+ )
91
+ findings.append(
92
+ self.create_finding(
93
+ node=node,
94
+ file_path=file_path,
95
+ source=source,
96
+ message=msg,
97
+ fix_hint=hint,
98
+ severity=self.meta.severity,
99
+ confidence="high",
100
+ )
101
+ )
102
+
103
+ return findings