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 +50 -0
- vigilo/__main__.py +6 -0
- vigilo/cli.py +130 -0
- vigilo/detectors/__init__.py +28 -0
- vigilo/detectors/base.py +89 -0
- vigilo/detectors/code_injection.py +63 -0
- vigilo/detectors/command_injection.py +107 -0
- vigilo/detectors/path_traversal.py +103 -0
- vigilo/detectors/sql_injection.py +123 -0
- vigilo/detectors/unsafe_deserialization.py +110 -0
- vigilo/discovery.py +96 -0
- vigilo/flow.py +193 -0
- vigilo/models.py +95 -0
- vigilo/reporter.py +122 -0
- vigilo/scanner.py +113 -0
- vigilo-0.1.0.dist-info/METADATA +170 -0
- vigilo-0.1.0.dist-info/RECORD +20 -0
- vigilo-0.1.0.dist-info/WHEEL +4 -0
- vigilo-0.1.0.dist-info/entry_points.txt +2 -0
- vigilo-0.1.0.dist-info/licenses/LICENSE +21 -0
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
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
|
+
]
|
vigilo/detectors/base.py
ADDED
|
@@ -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
|