qgis-plugin-analyzer 1.4.0__py3-none-any.whl → 1.5.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.
- analyzer/__init__.py +2 -1
- analyzer/cli.py +49 -146
- analyzer/commands.py +163 -0
- analyzer/engine.py +121 -58
- analyzer/reporters/markdown_reporter.py +41 -0
- analyzer/reporters/summary_reporter.py +67 -3
- analyzer/rules/qgis_rules.py +3 -1
- analyzer/scanner.py +31 -603
- analyzer/secrets.py +84 -0
- analyzer/security_checker.py +85 -0
- analyzer/security_rules.py +127 -0
- analyzer/visitors.py +455 -0
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.5.0.dist-info}/METADATA +20 -7
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.5.0.dist-info}/RECORD +18 -13
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.5.0.dist-info}/WHEEL +1 -1
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.5.0.dist-info}/entry_points.txt +0 -0
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.5.0.dist-info}/top_level.txt +0 -0
analyzer/secrets.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Secret detection logic for QGIS Plugin Analyzer.
|
|
2
|
+
|
|
3
|
+
Handles regex-based matching of API keys and high-entropy string detection.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import List
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SecretFinding:
|
|
14
|
+
"""Represents a detected secret or sensitive string."""
|
|
15
|
+
|
|
16
|
+
type: str
|
|
17
|
+
message: str
|
|
18
|
+
line: int
|
|
19
|
+
confidence: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SecretScanner:
|
|
23
|
+
"""Scanner for hardcoded secrets and high-entropy strings."""
|
|
24
|
+
|
|
25
|
+
# Common patterns for API keys and tokens
|
|
26
|
+
PATTERNS = {
|
|
27
|
+
"AWS_KEY": r"(?i)AKIA[0-9A-Z]{16}",
|
|
28
|
+
"GOOGLE_API_KEY": r"(?i)AIza[0-9A-Za-z\\-_]{35}",
|
|
29
|
+
"TWILIO_KEY": r"(?i)SK[a-z0-9]{32}",
|
|
30
|
+
"GENERIC_SECRET": r"(?i)(password|secret|passwd|api_key|token|access_key)[\"']?\s*[:=]\s*[\"']?([A-Za-z0-9/+=]{16,})[\"']?",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.compiled_patterns = {k: re.compile(v) for k, v in self.PATTERNS.items()}
|
|
35
|
+
|
|
36
|
+
def scan_text(self, text: str) -> List[SecretFinding]:
|
|
37
|
+
"""Scans a file's content for secrets line by line."""
|
|
38
|
+
findings = []
|
|
39
|
+
lines = text.splitlines()
|
|
40
|
+
|
|
41
|
+
for i, line in enumerate(lines, 1):
|
|
42
|
+
# 1. Regex Pattern Matching
|
|
43
|
+
for p_name, pattern in self.compiled_patterns.items():
|
|
44
|
+
match = pattern.search(line)
|
|
45
|
+
if match:
|
|
46
|
+
findings.append(
|
|
47
|
+
SecretFinding(
|
|
48
|
+
type=p_name,
|
|
49
|
+
message=f"Possible hardcoded secret detected: {p_name}",
|
|
50
|
+
line=i,
|
|
51
|
+
confidence="HIGH" if p_name != "GENERIC_SECRET" else "MEDIUM",
|
|
52
|
+
)
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# 2. Entropy Analysis for long strings (heuristic)
|
|
56
|
+
# Find strings in double or single quotes
|
|
57
|
+
str_matches = re.finditer(r"[\"']([A-Za-z0-9/+=]{20,})[\"']", line)
|
|
58
|
+
for m in str_matches:
|
|
59
|
+
candidate = m.group(1)
|
|
60
|
+
entropy = self.calculate_entropy(candidate)
|
|
61
|
+
# Shanon entropy: random strings usually > 3.5 - 4.0
|
|
62
|
+
if entropy > 4.5:
|
|
63
|
+
findings.append(
|
|
64
|
+
SecretFinding(
|
|
65
|
+
type="HIGH_ENTROPY",
|
|
66
|
+
message=f"High-entropy string detected (possible token/key): {candidate[:8]}...",
|
|
67
|
+
line=i,
|
|
68
|
+
confidence="MEDIUM",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return findings
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def calculate_entropy(data: str) -> float:
|
|
76
|
+
"""Calculates Shannon entropy of a string."""
|
|
77
|
+
if not data:
|
|
78
|
+
return 0.0
|
|
79
|
+
entropy = 0.0
|
|
80
|
+
for x in range(256):
|
|
81
|
+
p_x = float(data.count(chr(x))) / len(data)
|
|
82
|
+
if p_x > 0:
|
|
83
|
+
entropy += -p_x * math.log(p_x, 2)
|
|
84
|
+
return entropy
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Core infrastructure for security scanning in QGIS Plugin Analyzer.
|
|
2
|
+
|
|
3
|
+
Inspired by Bandit's architecture, this module provides a decorator-based
|
|
4
|
+
registry for security checks and a context helper for AST analysis.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Callable, Dict, List, Optional, Type
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SecurityFinding:
|
|
14
|
+
"""Represents a detected security vulnerability."""
|
|
15
|
+
|
|
16
|
+
id: str
|
|
17
|
+
severity: str # LOW, MEDIUM, HIGH
|
|
18
|
+
confidence: str # LOW, MEDIUM, HIGH
|
|
19
|
+
message: str
|
|
20
|
+
line: int
|
|
21
|
+
code_snippet: Optional[str] = None
|
|
22
|
+
cwe: Optional[int] = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SecurityContext:
|
|
26
|
+
"""Helper class providing easy access to AST node information for security checks."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, node: ast.AST, filename: str):
|
|
29
|
+
self.node = node
|
|
30
|
+
self.filename = filename
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def call_function_name(self) -> Optional[str]:
|
|
34
|
+
"""Returns the name of the function being called if the node is a Call."""
|
|
35
|
+
if isinstance(self.node, ast.Call):
|
|
36
|
+
if isinstance(self.node.func, ast.Name):
|
|
37
|
+
return self.node.func.id
|
|
38
|
+
if isinstance(self.node.func, ast.Attribute):
|
|
39
|
+
return self.node.func.attr
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def call_args_count(self) -> int:
|
|
44
|
+
"""Returns the number of positional arguments in a Call node."""
|
|
45
|
+
if isinstance(self.node, ast.Call):
|
|
46
|
+
return len(self.node.args)
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
def get_call_keyword_value(self, keyword_name: str) -> Any:
|
|
50
|
+
"""Returns the value of a specific keyword argument in a Call node."""
|
|
51
|
+
if isinstance(self.node, ast.Call):
|
|
52
|
+
for kw in self.node.keywords:
|
|
53
|
+
if kw.arg == keyword_name:
|
|
54
|
+
if isinstance(kw.value, ast.Constant):
|
|
55
|
+
return kw.value.value
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SecurityRegistry:
|
|
60
|
+
"""Registry for security checks managed by decorators."""
|
|
61
|
+
|
|
62
|
+
_checks: Dict[Type[ast.AST], List[Callable[[SecurityContext], Optional[SecurityFinding]]]] = {}
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def register(cls, node_type: Type[ast.AST]) -> Callable:
|
|
66
|
+
"""Decorator to register a function as a security check for a specific node type."""
|
|
67
|
+
|
|
68
|
+
def decorator(func: Callable[[SecurityContext], Optional[SecurityFinding]]):
|
|
69
|
+
if node_type not in cls._checks:
|
|
70
|
+
cls._checks[node_type] = []
|
|
71
|
+
cls._checks[node_type].append(func)
|
|
72
|
+
return func
|
|
73
|
+
|
|
74
|
+
return decorator
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def get_checks_for_node(
|
|
78
|
+
cls, node_type: Type[ast.AST]
|
|
79
|
+
) -> List[Callable[[SecurityContext], Optional[SecurityFinding]]]:
|
|
80
|
+
"""Returns all registered checks for a given AST node type."""
|
|
81
|
+
return cls._checks.get(node_type, [])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# Decorator alias
|
|
85
|
+
security_check = SecurityRegistry.register
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Security rules for QGIS Plugin Analyzer.
|
|
2
|
+
|
|
3
|
+
Each check is registered for a specific AST node type and performs a focused
|
|
4
|
+
security audit.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
from typing import Optional, cast
|
|
9
|
+
|
|
10
|
+
from .security_checker import SecurityContext, SecurityFinding, security_check
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@security_check(node_type=ast.Call)
|
|
14
|
+
def check_exec_eval(context: SecurityContext) -> Optional[SecurityFinding]:
|
|
15
|
+
"""B102/B307: Detect use of exec or eval."""
|
|
16
|
+
func_name = context.call_function_name
|
|
17
|
+
if func_name in ("exec", "eval"):
|
|
18
|
+
node = cast(ast.Call, context.node)
|
|
19
|
+
return SecurityFinding(
|
|
20
|
+
id="B102" if func_name == "exec" else "B307",
|
|
21
|
+
severity="HIGH",
|
|
22
|
+
confidence="HIGH",
|
|
23
|
+
message=f"Use of '{func_name}' detected. This can lead to arbitrary code execution.",
|
|
24
|
+
line=node.lineno,
|
|
25
|
+
code_snippet=ast.unparse(node),
|
|
26
|
+
cwe=95 if func_name == "eval" else 78,
|
|
27
|
+
)
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@security_check(node_type=ast.Call)
|
|
32
|
+
def check_insecure_deserialization(context: SecurityContext) -> Optional[SecurityFinding]:
|
|
33
|
+
"""B301: Detect unsafe pickle.load()."""
|
|
34
|
+
if context.call_function_name == "load":
|
|
35
|
+
# Check if it's from 'pickle'
|
|
36
|
+
node = cast(ast.Call, context.node)
|
|
37
|
+
if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
|
|
38
|
+
if node.func.value.id == "pickle":
|
|
39
|
+
return SecurityFinding(
|
|
40
|
+
id="B301",
|
|
41
|
+
severity="HIGH",
|
|
42
|
+
confidence="HIGH",
|
|
43
|
+
message="Use of 'pickle.load()' detected. Deserializing untrusted data can lead to remote code execution.",
|
|
44
|
+
line=node.lineno,
|
|
45
|
+
code_snippet=ast.unparse(node),
|
|
46
|
+
cwe=502,
|
|
47
|
+
)
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@security_check(node_type=ast.Call)
|
|
52
|
+
def check_subprocess_shell(context: SecurityContext) -> Optional[SecurityFinding]:
|
|
53
|
+
"""B602: Subprocess call with shell=True."""
|
|
54
|
+
func_name = context.call_function_name
|
|
55
|
+
subprocess_funcs = {"run", "call", "Popen", "check_call", "check_output"}
|
|
56
|
+
|
|
57
|
+
if func_name in subprocess_funcs:
|
|
58
|
+
shell_val = context.get_call_keyword_value("shell")
|
|
59
|
+
if shell_val is True:
|
|
60
|
+
node = cast(ast.Call, context.node)
|
|
61
|
+
return SecurityFinding(
|
|
62
|
+
id="B602",
|
|
63
|
+
severity="HIGH",
|
|
64
|
+
confidence="HIGH",
|
|
65
|
+
message=f"Subprocess call '{func_name}' with 'shell=True' detected. This is a primary source of shell injection.",
|
|
66
|
+
line=node.lineno,
|
|
67
|
+
code_snippet=ast.unparse(node),
|
|
68
|
+
cwe=78,
|
|
69
|
+
)
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@security_check(node_type=ast.Call)
|
|
74
|
+
def check_sql_injection(context: SecurityContext) -> Optional[SecurityFinding]:
|
|
75
|
+
"""B608: Basic detection of SQL injection via string formatting."""
|
|
76
|
+
if context.call_function_name == "execute":
|
|
77
|
+
if context.call_args_count > 0:
|
|
78
|
+
node = cast(ast.Call, context.node)
|
|
79
|
+
sql_arg = node.args[0]
|
|
80
|
+
# Check for f-strings or .format() or % formatting in the first argument
|
|
81
|
+
is_unsafe = False
|
|
82
|
+
if isinstance(sql_arg, ast.JoinedStr):
|
|
83
|
+
is_unsafe = True
|
|
84
|
+
elif isinstance(sql_arg, ast.BinOp) and isinstance(sql_arg.op, ast.Mod):
|
|
85
|
+
is_unsafe = True
|
|
86
|
+
elif isinstance(sql_arg, ast.Call) and isinstance(sql_arg.func, ast.Attribute):
|
|
87
|
+
if sql_arg.func.attr == "format":
|
|
88
|
+
is_unsafe = True
|
|
89
|
+
|
|
90
|
+
if is_unsafe:
|
|
91
|
+
return SecurityFinding(
|
|
92
|
+
id="B608",
|
|
93
|
+
severity="HIGH",
|
|
94
|
+
confidence="MEDIUM",
|
|
95
|
+
message="Possible SQL injection detected. Use parameterized queries instead of string formatting.",
|
|
96
|
+
line=node.lineno,
|
|
97
|
+
code_snippet=ast.unparse(node),
|
|
98
|
+
cwe=89,
|
|
99
|
+
)
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@security_check(node_type=ast.Assign)
|
|
104
|
+
def check_hardcoded_secrets(context: SecurityContext) -> Optional[SecurityFinding]:
|
|
105
|
+
"""Detect assignments of sensitive names to constants."""
|
|
106
|
+
node = context.node
|
|
107
|
+
if not isinstance(node, ast.Assign):
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
sensitive_names = {"password", "token", "api_key", "secret", "access_key"}
|
|
111
|
+
|
|
112
|
+
for target in node.targets:
|
|
113
|
+
if isinstance(target, ast.Name) and any(s in target.id.lower() for s in sensitive_names):
|
|
114
|
+
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
|
|
115
|
+
# Only flag if it's not empty and looks like a secret
|
|
116
|
+
val = node.value.value
|
|
117
|
+
if len(val) > 8:
|
|
118
|
+
cast_node = cast(ast.Assign, node)
|
|
119
|
+
return SecurityFinding(
|
|
120
|
+
id="HARDCODED_SECRET",
|
|
121
|
+
severity="MEDIUM",
|
|
122
|
+
confidence="MEDIUM",
|
|
123
|
+
message=f"Possible hardcoded secret in assignment to '{target.id}'.",
|
|
124
|
+
line=cast_node.lineno,
|
|
125
|
+
code_snippet=ast.unparse(cast_node),
|
|
126
|
+
)
|
|
127
|
+
return None
|