qgis-plugin-analyzer 1.4.0__py3-none-any.whl → 1.6.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/__init__.py +14 -0
- analyzer/cli/app.py +147 -0
- analyzer/cli/base.py +93 -0
- analyzer/cli/commands/__init__.py +19 -0
- analyzer/cli/commands/analyze.py +47 -0
- analyzer/cli/commands/fix.py +58 -0
- analyzer/cli/commands/init.py +41 -0
- analyzer/cli/commands/list_rules.py +41 -0
- analyzer/cli/commands/security.py +46 -0
- analyzer/cli/commands/summary.py +52 -0
- analyzer/cli/commands/version.py +41 -0
- analyzer/cli.py +4 -281
- analyzer/commands.py +163 -0
- analyzer/engine.py +491 -245
- analyzer/fixer.py +206 -130
- analyzer/reporters/markdown_reporter.py +88 -14
- analyzer/reporters/summary_reporter.py +226 -49
- analyzer/rules/qgis_rules.py +3 -1
- analyzer/scanner.py +219 -711
- analyzer/secrets.py +84 -0
- analyzer/security_checker.py +85 -0
- analyzer/security_rules.py +127 -0
- analyzer/transformers.py +29 -8
- analyzer/utils/__init__.py +2 -0
- analyzer/utils/path_utils.py +53 -1
- analyzer/validators.py +90 -55
- analyzer/visitors/__init__.py +19 -0
- analyzer/visitors/base.py +75 -0
- analyzer/visitors/composite_visitor.py +73 -0
- analyzer/visitors/imports_visitor.py +85 -0
- analyzer/visitors/metrics_visitor.py +158 -0
- analyzer/visitors/security_visitor.py +52 -0
- analyzer/visitors/standards_visitor.py +284 -0
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/METADATA +32 -10
- qgis_plugin_analyzer-1.6.0.dist-info/RECORD +52 -0
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/WHEEL +1 -1
- qgis_plugin_analyzer-1.4.0.dist-info/RECORD +0 -30
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/entry_points.txt +0 -0
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.0.dist-info}/licenses/LICENSE +0 -0
- {qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.6.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
|
analyzer/transformers.py
CHANGED
|
@@ -154,26 +154,24 @@ class I18nTransformer(ast.NodeTransformer):
|
|
|
154
154
|
return node
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
def
|
|
158
|
-
|
|
157
|
+
def apply_transformation_to_content(
|
|
158
|
+
content: str, transformer: ast.NodeTransformer
|
|
159
|
+
) -> Optional[str]:
|
|
160
|
+
"""Applies an AST transformation to code content string.
|
|
159
161
|
|
|
160
162
|
Args:
|
|
161
|
-
|
|
163
|
+
content: The Python source code.
|
|
162
164
|
transformer: The AST node transformer to apply.
|
|
163
165
|
|
|
164
166
|
Returns:
|
|
165
|
-
|
|
167
|
+
The transformed code string if changes were made, None otherwise.
|
|
166
168
|
"""
|
|
167
169
|
try:
|
|
168
|
-
content = file_path.read_text(encoding="utf-8")
|
|
169
170
|
tree = ast.parse(content)
|
|
170
|
-
|
|
171
|
-
# Apply transformation
|
|
172
171
|
new_tree = transformer.visit(tree)
|
|
173
172
|
ast.fix_missing_locations(new_tree)
|
|
174
173
|
|
|
175
174
|
if hasattr(transformer, "changes_made") and transformer.changes_made:
|
|
176
|
-
# Unparse back to code
|
|
177
175
|
new_code = ast.unparse(new_tree)
|
|
178
176
|
|
|
179
177
|
# Add necessary imports if needed
|
|
@@ -181,6 +179,29 @@ def apply_transformation(file_path: pathlib.Path, transformer: ast.NodeTransform
|
|
|
181
179
|
if "from qgis.core import QgsMessageLog, Qgis" not in new_code:
|
|
182
180
|
new_code = "from qgis.core import QgsMessageLog, Qgis\n\n" + new_code
|
|
183
181
|
|
|
182
|
+
return new_code
|
|
183
|
+
|
|
184
|
+
return None
|
|
185
|
+
except Exception as e:
|
|
186
|
+
print(f"Error transforming content: {e}")
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def apply_transformation(file_path: pathlib.Path, transformer: ast.NodeTransformer) -> bool:
|
|
191
|
+
"""Applies an AST transformation to a file and writes back the modified code.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
file_path: Path to the Python file to transform.
|
|
195
|
+
transformer: The AST node transformer to apply.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
True if the file was modified, False otherwise.
|
|
199
|
+
"""
|
|
200
|
+
try:
|
|
201
|
+
content = file_path.read_text(encoding="utf-8")
|
|
202
|
+
new_code = apply_transformation_to_content(content, transformer)
|
|
203
|
+
|
|
204
|
+
if new_code is not None:
|
|
184
205
|
file_path.write_text(new_code, encoding="utf-8")
|
|
185
206
|
return True
|
|
186
207
|
|
analyzer/utils/__init__.py
CHANGED
|
@@ -13,6 +13,7 @@ from .logging_utils import logger, setup_logger
|
|
|
13
13
|
from .path_utils import (
|
|
14
14
|
DEFAULT_EXCLUDE,
|
|
15
15
|
IgnoreMatcher,
|
|
16
|
+
discover_project_files,
|
|
16
17
|
load_ignore_patterns,
|
|
17
18
|
safe_path_resolve,
|
|
18
19
|
)
|
|
@@ -29,6 +30,7 @@ __all__ = [
|
|
|
29
30
|
"logger",
|
|
30
31
|
"safe_path_resolve",
|
|
31
32
|
"IgnoreMatcher",
|
|
33
|
+
"discover_project_files",
|
|
32
34
|
"load_ignore_patterns",
|
|
33
35
|
"DEFAULT_EXCLUDE",
|
|
34
36
|
"load_profile_config",
|
analyzer/utils/path_utils.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fnmatch
|
|
4
4
|
import os
|
|
5
5
|
import pathlib
|
|
6
|
-
from typing import Dict, List
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
7
|
|
|
8
8
|
# Default patterns to ignore if not specified
|
|
9
9
|
DEFAULT_EXCLUDE = {
|
|
@@ -21,6 +21,9 @@ DEFAULT_EXCLUDE = {
|
|
|
21
21
|
"analysis_results/",
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
# Prohibited binary extensions per QGIS repository policy
|
|
25
|
+
BINARY_EXTENSIONS = {".exe", ".dll", ".so", ".dylib", ".pyd", ".bin", ".a", ".lib"}
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
def safe_path_resolve(base_path: pathlib.Path, target_path_str: str) -> pathlib.Path:
|
|
26
29
|
"""Resolves a target path safely relative to a base path.
|
|
@@ -133,3 +136,52 @@ def load_ignore_patterns(ignore_file: pathlib.Path) -> List[str]:
|
|
|
133
136
|
return []
|
|
134
137
|
with open(ignore_file) as f:
|
|
135
138
|
return f.readlines()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def discover_project_files(project_path: pathlib.Path, matcher: IgnoreMatcher) -> Dict[str, Any]:
|
|
142
|
+
"""Scans the project directory once to discover all relevant files and metrics.
|
|
143
|
+
This replaces multiple redundant rglob calls, optimizing I/O performance.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
project_path: Root path of the project.
|
|
147
|
+
matcher: IgnoreMatcher instance for filtering.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
A dictionary with:
|
|
151
|
+
- python_files: List of Paths to .py files.
|
|
152
|
+
- binaries: List of relative paths to binary files.
|
|
153
|
+
- total_size_mb: Total size of non-ignored files in MB.
|
|
154
|
+
- has_metadata: Boolean indicating if metadata.txt exists.
|
|
155
|
+
"""
|
|
156
|
+
python_files = []
|
|
157
|
+
binaries = []
|
|
158
|
+
total_bytes = 0
|
|
159
|
+
has_metadata = False
|
|
160
|
+
|
|
161
|
+
for file_path in project_path.rglob("*"):
|
|
162
|
+
if not file_path.is_file():
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
if matcher.is_ignored(file_path):
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
# Basics
|
|
169
|
+
total_bytes += file_path.stat().st_size
|
|
170
|
+
|
|
171
|
+
# Check metadata
|
|
172
|
+
if file_path.name == "metadata.txt" and file_path.parent == project_path:
|
|
173
|
+
has_metadata = True
|
|
174
|
+
|
|
175
|
+
# Classify by extension
|
|
176
|
+
ext = file_path.suffix.lower()
|
|
177
|
+
if ext == ".py":
|
|
178
|
+
python_files.append(file_path)
|
|
179
|
+
elif ext in BINARY_EXTENSIONS:
|
|
180
|
+
binaries.append(str(file_path.relative_to(project_path)))
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"python_files": python_files,
|
|
184
|
+
"binaries": binaries,
|
|
185
|
+
"total_size_mb": total_bytes / (1024 * 1024),
|
|
186
|
+
"has_metadata": has_metadata,
|
|
187
|
+
}
|
analyzer/validators.py
CHANGED
|
@@ -6,66 +6,13 @@
|
|
|
6
6
|
|
|
7
7
|
import ipaddress
|
|
8
8
|
import pathlib
|
|
9
|
+
import re # Added for folder name validation
|
|
9
10
|
import socket
|
|
10
11
|
import urllib.error
|
|
11
12
|
import urllib.parse
|
|
12
13
|
import urllib.request
|
|
13
14
|
from typing import Any, Dict, List
|
|
14
15
|
|
|
15
|
-
# Prohibited binary extensions per QGIS repository policy
|
|
16
|
-
BINARY_EXTENSIONS = {".exe", ".dll", ".so", ".dylib", ".pyd", ".bin", ".a", ".lib"}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def scan_for_binaries(project_path: pathlib.Path, ignore_matcher: Any = None) -> List[str]:
|
|
20
|
-
"""Scans the project for prohibited binary files per QGIS policies.
|
|
21
|
-
|
|
22
|
-
Args:
|
|
23
|
-
project_path: Root path of the project.
|
|
24
|
-
ignore_matcher: Optional object to determine if a path should be ignored.
|
|
25
|
-
|
|
26
|
-
Returns:
|
|
27
|
-
A list of relative paths to any binary files found.
|
|
28
|
-
"""
|
|
29
|
-
binaries = []
|
|
30
|
-
|
|
31
|
-
for file_path in project_path.rglob("*"):
|
|
32
|
-
if file_path.is_file():
|
|
33
|
-
# Skip if matches ignore pattern
|
|
34
|
-
if ignore_matcher and ignore_matcher.is_ignored(file_path):
|
|
35
|
-
continue
|
|
36
|
-
|
|
37
|
-
if file_path.suffix.lower() in BINARY_EXTENSIONS:
|
|
38
|
-
rel_path = str(file_path.relative_to(project_path))
|
|
39
|
-
binaries.append(rel_path)
|
|
40
|
-
|
|
41
|
-
return binaries
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def calculate_package_size(project_path: pathlib.Path, ignore_matcher: Any = None) -> float:
|
|
45
|
-
"""Calculates the total package size in Megabytes (MB).
|
|
46
|
-
|
|
47
|
-
Args:
|
|
48
|
-
project_path: Root path of the project.
|
|
49
|
-
ignore_matcher: Optional object to determine if a path should be ignored.
|
|
50
|
-
|
|
51
|
-
Returns:
|
|
52
|
-
The total size of the plugin package in MB.
|
|
53
|
-
"""
|
|
54
|
-
total_size = 0
|
|
55
|
-
|
|
56
|
-
for file_path in project_path.rglob("*"):
|
|
57
|
-
if file_path.is_file():
|
|
58
|
-
# Skip if matches ignore pattern
|
|
59
|
-
if ignore_matcher:
|
|
60
|
-
str(file_path.relative_to(project_path))
|
|
61
|
-
if ignore_matcher.is_ignored(file_path):
|
|
62
|
-
continue
|
|
63
|
-
|
|
64
|
-
total_size += file_path.stat().st_size
|
|
65
|
-
|
|
66
|
-
# Convert bytes to MB
|
|
67
|
-
return total_size / (1024 * 1024)
|
|
68
|
-
|
|
69
16
|
|
|
70
17
|
def is_ssrf_safe(url: str) -> bool:
|
|
71
18
|
"""Checks if a URL is safe from Server-Side Request Forgery (SSRF).
|
|
@@ -167,6 +114,39 @@ def validate_metadata_urls(metadata: Dict[str, str]) -> Dict[str, str]:
|
|
|
167
114
|
return results
|
|
168
115
|
|
|
169
116
|
|
|
117
|
+
def validate_package_constraints(total_size_mb: float, binaries: List[str]) -> Dict[str, Any]:
|
|
118
|
+
"""Validates package size and binary constraints against Official Repository rules.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
total_size_mb: Total size of the package in MB.
|
|
122
|
+
binaries: List of detected binary files.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Validation results including error messages.
|
|
126
|
+
"""
|
|
127
|
+
errors = []
|
|
128
|
+
|
|
129
|
+
# Rule: Max 20MB
|
|
130
|
+
if total_size_mb > 20:
|
|
131
|
+
errors.append(f"Package size ({total_size_mb:.2f}MB) exceeds the 20MB limit.")
|
|
132
|
+
|
|
133
|
+
# Rule: No Binaries
|
|
134
|
+
if binaries:
|
|
135
|
+
count = len(binaries)
|
|
136
|
+
shown = ", ".join(binaries[:3])
|
|
137
|
+
suffix = "..." if count > 3 else ""
|
|
138
|
+
errors.append(
|
|
139
|
+
f"Detected {count} binary file(s): {shown}{suffix}. Binaries are not allowed."
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"is_valid": len(errors) == 0,
|
|
144
|
+
"errors": errors,
|
|
145
|
+
"total_size_mb": total_size_mb,
|
|
146
|
+
"binary_count": len(binaries),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
170
150
|
def validate_plugin_structure(project_path: pathlib.Path) -> Dict[str, Any]:
|
|
171
151
|
"""Validates that the plugin following the required QGIS file structure.
|
|
172
152
|
|
|
@@ -193,12 +173,29 @@ def validate_plugin_structure(project_path: pathlib.Path) -> Dict[str, Any]:
|
|
|
193
173
|
py_files = list(project_path.glob("*.py"))
|
|
194
174
|
has_python = len(py_files) > 0
|
|
195
175
|
|
|
176
|
+
# Check Folder Name (Official Repo Rule: ASCII, alphanumeric, no start with digit)
|
|
177
|
+
folder_name = project_path.name
|
|
178
|
+
# Regex explanation:
|
|
179
|
+
# ^[a-zA-Z_] : Must start with letter or underscore (cannot start with digit/hyphen)
|
|
180
|
+
# [a-zA-Z0-9_-]*$ : Followed by alphanumeric, underscore, or hyphen
|
|
181
|
+
# Note: Official repo is strictly ASCII.
|
|
182
|
+
folder_valid = False
|
|
183
|
+
try:
|
|
184
|
+
# Check ASCII encoding implicitly by regex matching on string, or strict encode check
|
|
185
|
+
folder_name.encode("ascii")
|
|
186
|
+
if re.match(r"^[a-zA-Z_][a-zA-Z0-9_-]*$", folder_name):
|
|
187
|
+
folder_valid = True
|
|
188
|
+
except UnicodeEncodeError:
|
|
189
|
+
folder_valid = False
|
|
190
|
+
|
|
196
191
|
return {
|
|
197
192
|
"files": found,
|
|
198
193
|
"missing_files": missing,
|
|
199
194
|
"has_class_factory": has_factory,
|
|
200
195
|
"has_python_files": has_python,
|
|
201
|
-
"
|
|
196
|
+
"folder_name": folder_name,
|
|
197
|
+
"folder_name_valid": folder_valid,
|
|
198
|
+
"is_valid": all(found.values()) and has_factory and has_python and folder_valid,
|
|
202
199
|
}
|
|
203
200
|
|
|
204
201
|
|
|
@@ -218,6 +215,7 @@ def validate_metadata(metadata_path: pathlib.Path) -> Dict[str, Any]:
|
|
|
218
215
|
"qgisMinimumVersion",
|
|
219
216
|
"author",
|
|
220
217
|
"email",
|
|
218
|
+
"about",
|
|
221
219
|
]
|
|
222
220
|
|
|
223
221
|
recommended_fields = [
|
|
@@ -261,3 +259,40 @@ def validate_metadata(metadata_path: pathlib.Path) -> Dict[str, Any]:
|
|
|
261
259
|
"recommended_missing": recommended_missing,
|
|
262
260
|
"metadata": metadata,
|
|
263
261
|
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def calculate_package_size(directory: pathlib.Path) -> float:
|
|
265
|
+
"""Calculates the total size of a directory in megabytes.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
directory: The directory path.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
The total size in MB.
|
|
272
|
+
"""
|
|
273
|
+
total_size = 0
|
|
274
|
+
for file in directory.rglob("*"):
|
|
275
|
+
if file.is_file():
|
|
276
|
+
total_size += file.stat().st_size
|
|
277
|
+
return total_size / (1024 * 1024)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def scan_for_binaries(directory: pathlib.Path) -> List[str]:
|
|
281
|
+
"""Scans for binary files in the directory.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
directory: The directory path.
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
A list of relative paths to detected binary files.
|
|
288
|
+
"""
|
|
289
|
+
binary_extensions = {".dll", ".so", ".exe", ".dylib", ".pyd", ".o", ".a"}
|
|
290
|
+
binaries = []
|
|
291
|
+
for file in directory.rglob("*"):
|
|
292
|
+
if file.suffix.lower() in binary_extensions:
|
|
293
|
+
try:
|
|
294
|
+
rel_path = file.relative_to(directory)
|
|
295
|
+
binaries.append(str(rel_path))
|
|
296
|
+
except ValueError:
|
|
297
|
+
binaries.append(str(file))
|
|
298
|
+
return binaries
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""AST Visitors for QGIS Plugin Analysis.
|
|
2
|
+
|
|
3
|
+
This package provides modular AST visitors for analyzing QGIS plugin code.
|
|
4
|
+
Each visitor is specialized for a specific concern (imports, metrics, standards, security).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .composite_visitor import CompositeVisitor
|
|
8
|
+
from .security_visitor import SecurityVisitor
|
|
9
|
+
|
|
10
|
+
# Maintain backward compatibility
|
|
11
|
+
QGISASTVisitor = CompositeVisitor
|
|
12
|
+
QGISSecurityVisitor = SecurityVisitor
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"QGISASTVisitor",
|
|
16
|
+
"QGISSecurityVisitor",
|
|
17
|
+
"CompositeVisitor",
|
|
18
|
+
"SecurityVisitor",
|
|
19
|
+
]
|