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/visitors.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""AST Visitors for QGIS Plugin Analysis."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import re
|
|
5
|
+
from typing import Any, Dict, List, Optional, Set, cast
|
|
6
|
+
|
|
7
|
+
# Import to trigger registration of checks
|
|
8
|
+
from .rules.qgis_rules import I18N_METHODS
|
|
9
|
+
from .security_checker import SecurityContext, SecurityRegistry
|
|
10
|
+
from .utils.ast_utils import calculate_complexity
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QGISASTVisitor(ast.NodeVisitor):
|
|
14
|
+
"""AST visitor to detect QGIS-specific issues."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, rel_path: str, rules_config: Optional[Dict[str, Any]] = None) -> None:
|
|
17
|
+
"""Initializes the AST visitor for a specific file.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
rel_path: Relative path to the file being analyzed.
|
|
21
|
+
rules_config: Optional configuration for audit rules and severities.
|
|
22
|
+
"""
|
|
23
|
+
self.rel_path = rel_path
|
|
24
|
+
self.issues: List[Dict[str, Any]] = []
|
|
25
|
+
self.rules_config = rules_config or {}
|
|
26
|
+
self.class_methods_stack: List[Set[str]] = []
|
|
27
|
+
|
|
28
|
+
# New metrics for research-based scoring
|
|
29
|
+
self.docstring_styles: List[str] = []
|
|
30
|
+
self.type_hint_stats = {
|
|
31
|
+
"total_parameters": 0,
|
|
32
|
+
"annotated_parameters": 0,
|
|
33
|
+
"has_return_hint": 0,
|
|
34
|
+
"total_functions": 0,
|
|
35
|
+
}
|
|
36
|
+
self.docstring_stats = {"total_public_items": 0, "has_docstring": 0}
|
|
37
|
+
self.i18n_methods = I18N_METHODS
|
|
38
|
+
|
|
39
|
+
def _should_report(self, rule_id: str) -> bool:
|
|
40
|
+
"""Check if rule should be reported based on config."""
|
|
41
|
+
severity = self.rules_config.get(rule_id, "warning")
|
|
42
|
+
return bool(severity != "ignore")
|
|
43
|
+
|
|
44
|
+
def _get_severity(self, rule_id: str) -> str:
|
|
45
|
+
"""Get configured severity for rule (maps to 'high', 'medium', 'low')."""
|
|
46
|
+
config_severity = self.rules_config.get(rule_id, "warning")
|
|
47
|
+
severity_map = {
|
|
48
|
+
"error": "high",
|
|
49
|
+
"warning": "medium",
|
|
50
|
+
"info": "low",
|
|
51
|
+
}
|
|
52
|
+
return severity_map.get(config_severity, "medium")
|
|
53
|
+
|
|
54
|
+
def _check_docstring_style(self, doc: Optional[str]) -> None:
|
|
55
|
+
"""Identifies Google or NumPy docstring styles within a string."""
|
|
56
|
+
if not doc:
|
|
57
|
+
return
|
|
58
|
+
# Google: Args: or Returns: or Raises: as headers
|
|
59
|
+
if re.search(r"\n\s*(Args|Returns|Raises|Yields):\s*\n", doc):
|
|
60
|
+
self.docstring_styles.append("Google")
|
|
61
|
+
# NumPy: Underlined headers
|
|
62
|
+
elif re.search(r"\n(Parameters|Returns|Raises|Yields)\n\s*-{3,}", doc):
|
|
63
|
+
self.docstring_styles.append("NumPy")
|
|
64
|
+
|
|
65
|
+
def _report_issue(self, rule_id: str, line: int, message: str, code: str = "") -> None:
|
|
66
|
+
"""Helper to report an issue if enabled."""
|
|
67
|
+
if self._should_report(rule_id):
|
|
68
|
+
self.issues.append(
|
|
69
|
+
{
|
|
70
|
+
"file": self.rel_path,
|
|
71
|
+
"line": line,
|
|
72
|
+
"type": rule_id,
|
|
73
|
+
"severity": self._get_severity(rule_id),
|
|
74
|
+
"message": message,
|
|
75
|
+
"code": code,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def visit_Module(self, node: ast.Module) -> None:
|
|
80
|
+
"""Analyzes a module-level AST node."""
|
|
81
|
+
doc = ast.get_docstring(node)
|
|
82
|
+
self.docstring_stats["total_public_items"] += 1
|
|
83
|
+
if doc:
|
|
84
|
+
self.docstring_stats["has_docstring"] += 1
|
|
85
|
+
self._check_docstring_style(doc)
|
|
86
|
+
else:
|
|
87
|
+
self._report_issue(
|
|
88
|
+
"MISSING_DOCSTRING",
|
|
89
|
+
1,
|
|
90
|
+
"Module is missing a docstring (PEP 257).",
|
|
91
|
+
f"Module: {self.rel_path}",
|
|
92
|
+
)
|
|
93
|
+
self.generic_visit(node)
|
|
94
|
+
|
|
95
|
+
def _check_import_name(self, name: str, node: ast.AST, code_snippet: str) -> None:
|
|
96
|
+
"""Checks a single import name for violations."""
|
|
97
|
+
# 5. Detect QGIS_PROTECTED_MEMBER
|
|
98
|
+
if name.startswith("qgis._") and not name.startswith("qgis._3d"):
|
|
99
|
+
self._report_issue(
|
|
100
|
+
"QGIS_PROTECTED_MEMBER",
|
|
101
|
+
cast(Any, node).lineno,
|
|
102
|
+
f"Protected member import detected: '{name}'. Protected members are unstable.",
|
|
103
|
+
code_snippet,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# 6. Detect GDAL_DIRECT_IMPORT
|
|
107
|
+
if name == "gdal":
|
|
108
|
+
self._report_issue(
|
|
109
|
+
"GDAL_DIRECT_IMPORT",
|
|
110
|
+
cast(Any, node).lineno,
|
|
111
|
+
"Direct 'gdal' import detected. Use 'from osgeo import gdal'.",
|
|
112
|
+
code_snippet,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Detect QGIS_LEGACY_IMPORT
|
|
116
|
+
if name.startswith(("PyQt4", "PyQt5")):
|
|
117
|
+
self._report_issue(
|
|
118
|
+
"QGIS_LEGACY_IMPORT",
|
|
119
|
+
cast(Any, node).lineno,
|
|
120
|
+
f"Legacy import detected: '{name}'. Use 'qgis.PyQt' for compatibility.",
|
|
121
|
+
code_snippet,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def visit_Import(self, node: ast.Import) -> None:
|
|
125
|
+
"""Analyzes import nodes."""
|
|
126
|
+
for alias in node.names:
|
|
127
|
+
self._check_import_name(alias.name, node, ast.unparse(node))
|
|
128
|
+
self.generic_visit(node)
|
|
129
|
+
|
|
130
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
|
131
|
+
"""Analyzes 'from import' nodes."""
|
|
132
|
+
if node.module:
|
|
133
|
+
self._check_import_name(node.module, node, ast.unparse(node))
|
|
134
|
+
|
|
135
|
+
# 7. Detect HEAVY_LOGIC_UI
|
|
136
|
+
heavy_libs = {"pandas", "numpy", "scipy", "sklearn", "matplotlib"}
|
|
137
|
+
is_ui_file = "gui" in self.rel_path.lower() or "ui" in self.rel_path.lower()
|
|
138
|
+
if is_ui_file and (
|
|
139
|
+
node.module in heavy_libs or node.module.split(".")[0] in heavy_libs
|
|
140
|
+
):
|
|
141
|
+
self._report_issue(
|
|
142
|
+
"HEAVY_LOGIC_UI",
|
|
143
|
+
node.lineno,
|
|
144
|
+
f"Heavy dependency '{node.module}' detected in UI file. Move logic to core.",
|
|
145
|
+
ast.unparse(node),
|
|
146
|
+
)
|
|
147
|
+
self.generic_visit(node)
|
|
148
|
+
|
|
149
|
+
def _check_docstring_and_metrics(self, node: ast.FunctionDef) -> None:
|
|
150
|
+
"""Checks docstrings and collects metrics."""
|
|
151
|
+
if not node.name.startswith("_") and node.name != "__init__":
|
|
152
|
+
doc = ast.get_docstring(node)
|
|
153
|
+
self.docstring_stats["total_public_items"] += 1
|
|
154
|
+
if doc:
|
|
155
|
+
self.docstring_stats["has_docstring"] += 1
|
|
156
|
+
self._check_docstring_style(doc)
|
|
157
|
+
else:
|
|
158
|
+
self._report_issue(
|
|
159
|
+
"MISSING_DOCSTRING",
|
|
160
|
+
node.lineno,
|
|
161
|
+
f"Public function '{node.name}' is missing a docstring.",
|
|
162
|
+
f"def {node.name}...",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def _check_type_hints(self, node: ast.FunctionDef) -> None:
|
|
166
|
+
"""Checks for type hints."""
|
|
167
|
+
if node.name == "__init__":
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
self.type_hint_stats["total_functions"] += 1
|
|
171
|
+
params = [a for a in node.args.args if a.arg != "self" and a.arg != "cls"]
|
|
172
|
+
self.type_hint_stats["total_parameters"] += len(params)
|
|
173
|
+
annotated = [a for a in params if a.annotation]
|
|
174
|
+
self.type_hint_stats["annotated_parameters"] += len(annotated)
|
|
175
|
+
if node.returns:
|
|
176
|
+
self.type_hint_stats["has_return_hint"] += 1
|
|
177
|
+
|
|
178
|
+
if params and not annotated and not node.returns:
|
|
179
|
+
self._report_issue(
|
|
180
|
+
"MISSING_TYPE_HINTS",
|
|
181
|
+
node.lineno,
|
|
182
|
+
f"Function '{node.name}' has no type annotations.",
|
|
183
|
+
f"def {node.name}...",
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
187
|
+
"""Analyzes function definitions."""
|
|
188
|
+
# IFACE_AS_ARGUMENT
|
|
189
|
+
for arg in node.args.args:
|
|
190
|
+
if arg.annotation and isinstance(arg.annotation, ast.Name):
|
|
191
|
+
if arg.annotation.id == "QgisInterface":
|
|
192
|
+
self._report_issue(
|
|
193
|
+
"IFACE_AS_ARGUMENT",
|
|
194
|
+
node.lineno,
|
|
195
|
+
f"Function '{node.name}' receives 'QgisInterface' as an argument. Use the global 'iface' or Singleton pattern.",
|
|
196
|
+
ast.unparse(node).split("\n")[0],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# HIGH_COMPLEXITY
|
|
200
|
+
complexity = calculate_complexity(node)
|
|
201
|
+
if complexity > 15:
|
|
202
|
+
self._report_issue(
|
|
203
|
+
"HIGH_COMPLEXITY",
|
|
204
|
+
node.lineno,
|
|
205
|
+
f"Function '{node.name}' is too complex (CC={complexity} > 15). Consider extracting methods.",
|
|
206
|
+
f"def {node.name}...",
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
self._check_docstring_and_metrics(node)
|
|
210
|
+
self._check_type_hints(node)
|
|
211
|
+
self.generic_visit(node)
|
|
212
|
+
|
|
213
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
214
|
+
"""Analyzes class definitions."""
|
|
215
|
+
methods = {
|
|
216
|
+
item.name
|
|
217
|
+
for item in node.body
|
|
218
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
219
|
+
}
|
|
220
|
+
self.class_methods_stack.append(methods)
|
|
221
|
+
|
|
222
|
+
# MANDATORY_CLEANUP
|
|
223
|
+
has_init_gui = "initGui" in methods
|
|
224
|
+
has_unload = "unload" in methods
|
|
225
|
+
|
|
226
|
+
if has_init_gui and not has_unload:
|
|
227
|
+
self._report_issue(
|
|
228
|
+
"MANDATORY_CLEANUP",
|
|
229
|
+
node.lineno,
|
|
230
|
+
f"Class '{node.name}' implements 'initGui()' but is missing 'unload()'.",
|
|
231
|
+
f"class {node.name}...",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Missing Docstring
|
|
235
|
+
if not node.name.startswith("_"):
|
|
236
|
+
doc = ast.get_docstring(node)
|
|
237
|
+
self.docstring_stats["total_public_items"] += 1
|
|
238
|
+
if doc:
|
|
239
|
+
self.docstring_stats["has_docstring"] += 1
|
|
240
|
+
self._check_docstring_style(doc)
|
|
241
|
+
else:
|
|
242
|
+
self._report_issue(
|
|
243
|
+
"MISSING_DOCSTRING",
|
|
244
|
+
node.lineno,
|
|
245
|
+
f"Public class '{node.name}' is missing a docstring.",
|
|
246
|
+
f"class {node.name}...",
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
self.generic_visit(node)
|
|
250
|
+
self.class_methods_stack.pop()
|
|
251
|
+
|
|
252
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
253
|
+
"""Analyzes function calls."""
|
|
254
|
+
self._check_obsolete_api(node)
|
|
255
|
+
self._check_missing_i18n(node)
|
|
256
|
+
self._check_missing_slot(node)
|
|
257
|
+
self._check_unsafe_subprocess(node)
|
|
258
|
+
self._check_blocking_network(node)
|
|
259
|
+
self.generic_visit(node)
|
|
260
|
+
|
|
261
|
+
# ... Helper check methods (simplified) ...
|
|
262
|
+
def _check_obsolete_api(self, node: ast.Call) -> None:
|
|
263
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == "writeAsVectorFormat":
|
|
264
|
+
self._report_issue(
|
|
265
|
+
"OBSOLETE_API",
|
|
266
|
+
node.lineno,
|
|
267
|
+
"Obsolete writeAsVectorFormat() usage. Use writeAsVectorFormatV3().",
|
|
268
|
+
ast.unparse(node),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
def _check_missing_i18n(self, node: ast.Call) -> None:
|
|
272
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr in self.i18n_methods:
|
|
273
|
+
if (
|
|
274
|
+
node.args
|
|
275
|
+
and isinstance(node.args[0], ast.Constant)
|
|
276
|
+
and isinstance(node.args[0].value, str)
|
|
277
|
+
):
|
|
278
|
+
val = node.args[0].value
|
|
279
|
+
if val.strip() and not val.startswith("%"):
|
|
280
|
+
self._report_issue(
|
|
281
|
+
"MISSING_I18N",
|
|
282
|
+
node.lineno,
|
|
283
|
+
f"Untranslated UI text string in '{node.func.attr}': '{val}'. Use self.tr().",
|
|
284
|
+
ast.unparse(node),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def _check_missing_slot(self, node: ast.Call) -> None:
|
|
288
|
+
if isinstance(node.func, ast.Attribute) and node.func.attr == "connect" and node.args:
|
|
289
|
+
arg = node.args[0]
|
|
290
|
+
if (
|
|
291
|
+
isinstance(arg, ast.Attribute)
|
|
292
|
+
and isinstance(arg.value, ast.Name)
|
|
293
|
+
and arg.value.id == "self"
|
|
294
|
+
):
|
|
295
|
+
slot = arg.attr
|
|
296
|
+
if self.class_methods_stack and slot not in self.class_methods_stack[-1]:
|
|
297
|
+
self._report_issue(
|
|
298
|
+
"POTENTIAL_MISSING_SLOT",
|
|
299
|
+
node.lineno,
|
|
300
|
+
f"Connected slot 'self.{slot}' not found in class definitions.",
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def _check_unsafe_subprocess(self, node: ast.Call) -> None:
|
|
304
|
+
is_subprocess = False
|
|
305
|
+
if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
|
|
306
|
+
if node.func.value.id == "subprocess" and node.func.attr in {
|
|
307
|
+
"run",
|
|
308
|
+
"call",
|
|
309
|
+
"Popen",
|
|
310
|
+
"check_call",
|
|
311
|
+
"check_output",
|
|
312
|
+
}:
|
|
313
|
+
is_subprocess = True
|
|
314
|
+
|
|
315
|
+
if not is_subprocess:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
shell_true = False
|
|
319
|
+
for kw in node.keywords:
|
|
320
|
+
if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
|
|
321
|
+
shell_true = True
|
|
322
|
+
break
|
|
323
|
+
|
|
324
|
+
if shell_true:
|
|
325
|
+
self._report_issue(
|
|
326
|
+
"UNSAFE_SUBPROCESS",
|
|
327
|
+
node.lineno,
|
|
328
|
+
"Subprocess called with 'shell=True'.",
|
|
329
|
+
ast.unparse(node),
|
|
330
|
+
)
|
|
331
|
+
return
|
|
332
|
+
|
|
333
|
+
if node.args:
|
|
334
|
+
cmd_arg = node.args[0]
|
|
335
|
+
if isinstance(cmd_arg, (ast.JoinedStr, ast.BinOp)):
|
|
336
|
+
self._report_issue(
|
|
337
|
+
"UNSAFE_SUBPROCESS",
|
|
338
|
+
node.lineno,
|
|
339
|
+
"Possible unquoted variable injection.",
|
|
340
|
+
ast.unparse(node),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
def _check_blocking_network(self, node: ast.Call) -> None:
|
|
344
|
+
is_network = False
|
|
345
|
+
if isinstance(node.func, ast.Attribute) and isinstance(node.func.value, ast.Name):
|
|
346
|
+
if node.func.value.id == "requests" and node.func.attr in {
|
|
347
|
+
"get",
|
|
348
|
+
"post",
|
|
349
|
+
"put",
|
|
350
|
+
"delete",
|
|
351
|
+
"patch",
|
|
352
|
+
}:
|
|
353
|
+
is_network = True
|
|
354
|
+
|
|
355
|
+
if not is_network:
|
|
356
|
+
# Heuristic for urlopen
|
|
357
|
+
attr_chain = []
|
|
358
|
+
curr = node.func
|
|
359
|
+
while isinstance(curr, ast.Attribute):
|
|
360
|
+
attr_chain.append(curr.attr)
|
|
361
|
+
curr = curr.value
|
|
362
|
+
if isinstance(curr, ast.Name):
|
|
363
|
+
attr_chain.append(curr.id)
|
|
364
|
+
if attr_chain == ["urlopen", "request", "urllib"]:
|
|
365
|
+
is_network = True
|
|
366
|
+
|
|
367
|
+
if is_network:
|
|
368
|
+
is_ui_file = any(
|
|
369
|
+
kw in self.rel_path.lower() for kw in ["gui", "ui", "dialog", "widget"]
|
|
370
|
+
)
|
|
371
|
+
if is_ui_file:
|
|
372
|
+
self._report_issue(
|
|
373
|
+
"BLOCKING_NETWORK_CALL",
|
|
374
|
+
node.lineno,
|
|
375
|
+
"Synchronous network call detected in UI file.",
|
|
376
|
+
ast.unparse(node),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
def visit_For(self, node: ast.For) -> None:
|
|
380
|
+
"""Analyzes loop nodes."""
|
|
381
|
+
# SPATIAL_INDEX check
|
|
382
|
+
if (
|
|
383
|
+
isinstance(node.iter, ast.Call)
|
|
384
|
+
and isinstance(node.iter.func, ast.Attribute)
|
|
385
|
+
and node.iter.func.attr == "getFeatures"
|
|
386
|
+
):
|
|
387
|
+
warn = False
|
|
388
|
+
if not node.iter.args:
|
|
389
|
+
warn = True
|
|
390
|
+
elif len(node.iter.args) == 1:
|
|
391
|
+
arg = node.iter.args[0]
|
|
392
|
+
if (
|
|
393
|
+
isinstance(arg, ast.Call)
|
|
394
|
+
and isinstance(arg.func, ast.Name)
|
|
395
|
+
and arg.func.id == "QgsFeatureRequest"
|
|
396
|
+
):
|
|
397
|
+
if not arg.args and not arg.keywords:
|
|
398
|
+
warn = True
|
|
399
|
+
|
|
400
|
+
if warn:
|
|
401
|
+
self._report_issue(
|
|
402
|
+
"SPATIAL_INDEX",
|
|
403
|
+
node.lineno,
|
|
404
|
+
"Iteration over features with getFeatures() and no filter.",
|
|
405
|
+
ast.unparse(node.iter),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# NON_PYTHONIC_LOOP
|
|
409
|
+
for body_node in ast.walk(node):
|
|
410
|
+
if isinstance(body_node, ast.AugAssign) and isinstance(body_node.op, ast.Add):
|
|
411
|
+
if (
|
|
412
|
+
isinstance(body_node.target, ast.Name)
|
|
413
|
+
and isinstance(body_node.value, ast.Constant)
|
|
414
|
+
and body_node.value.value == 1
|
|
415
|
+
):
|
|
416
|
+
self._report_issue(
|
|
417
|
+
"NON_PYTHONIC_LOOP",
|
|
418
|
+
body_node.lineno,
|
|
419
|
+
f"Manual counter '{body_node.target.id} += 1' detected inside loop.",
|
|
420
|
+
ast.unparse(body_node),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
self.generic_visit(node)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class QGISSecurityVisitor(ast.NodeVisitor):
|
|
427
|
+
"""AST visitor focused on security vulnerabilities (Bandit-inspired)."""
|
|
428
|
+
|
|
429
|
+
def __init__(self, rel_path: str):
|
|
430
|
+
self.rel_path = rel_path
|
|
431
|
+
self.findings: List[Dict[str, Any]] = []
|
|
432
|
+
|
|
433
|
+
def visit(self, node: ast.AST):
|
|
434
|
+
"""Dispatches security checks for the current node."""
|
|
435
|
+
checks = SecurityRegistry.get_checks_for_node(type(node))
|
|
436
|
+
context = SecurityContext(node, self.rel_path)
|
|
437
|
+
|
|
438
|
+
for check_func in checks:
|
|
439
|
+
finding = check_func(context)
|
|
440
|
+
if finding:
|
|
441
|
+
self.findings.append(
|
|
442
|
+
{
|
|
443
|
+
"file": self.rel_path,
|
|
444
|
+
"line": finding.line,
|
|
445
|
+
"type": finding.id,
|
|
446
|
+
"severity": finding.severity.lower(),
|
|
447
|
+
"message": finding.message,
|
|
448
|
+
"code": finding.code_snippet,
|
|
449
|
+
"confidence": finding.confidence.lower()
|
|
450
|
+
if hasattr(finding, "confidence")
|
|
451
|
+
else "medium",
|
|
452
|
+
}
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
super().generic_visit(node)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: qgis-plugin-analyzer
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: A professional static analysis tool for QGIS (PyQGIS) plugins
|
|
5
5
|
Author-email: geociencio <juanbernales@gmail.com>
|
|
6
6
|
License: GPL-3.0-or-later
|
|
@@ -34,13 +34,16 @@ Dynamic: license-file
|
|
|
34
34
|

|
|
35
35
|

|
|
36
36
|

|
|
37
|
-

|
|
38
|
+

|
|
39
|
+

|
|
39
40
|
|
|
40
41
|
The **QGIS Plugin Analyzer** is a static analysis tool designed specifically for QGIS (PyQGIS) plugin developers. Its goal is to elevate plugin quality by ensuring they follow community best practices and are optimized for AI-assisted development.
|
|
41
42
|
|
|
42
43
|
## ✨ Main Features
|
|
43
44
|
|
|
45
|
+
- **Security Core (Bandit-inspired)**: Professional vulnerability scanning detecting `eval`, `exec`, shell injections, and SQL injection risks.
|
|
46
|
+
- **Deep Entropy Secret Scanner**: Detects hardcoded API keys, passwords, and sensitive tokens using regex and information entropy.
|
|
44
47
|
- **High-Performance Engine**: Parallel analysis powered by `ProcessPoolExecutor` for ultra-fast execution on multi-core systems.
|
|
45
48
|
- **Project Auto-Detection**: Intelligently distinguishes between official QGIS Plugins and Generic Python Projects, tailoring validation logic accordingly.
|
|
46
49
|
- **Advanced Ignore Engine**: Robust `.analyzerignore` support with non-anchored patterns and smart default excludes (`.venv`, `build`, etc.).
|
|
@@ -63,9 +66,8 @@ The **QGIS Plugin Analyzer** is a static analysis tool designed specifically for
|
|
|
63
66
|
| **QGIS-Specific Rules**| ✅ (Precise AST) | ✅ (Regex/AST) | ❌ | ✅ |
|
|
64
67
|
| **Interactive Auto-Fix**| ✅ | ❌ | ❌ | ❌ |
|
|
65
68
|
| **Semantic Analysis** | ✅ | ❌ | ❌ | ❌ |
|
|
66
|
-
| **
|
|
67
|
-
| **
|
|
68
|
-
| **Architecture Audit** | ✅ (UI/Core) | ❌ | ❌ | ❌ |
|
|
69
|
+
| **Security Audit** | ✅ (Bandit-style) | ❌ | ❌ | ❌ |
|
|
70
|
+
| **Secret Scanning** | ✅ (Entropy) | ❌ | ❌ | ❌ |
|
|
69
71
|
| **HTML/MD Reports** | ✅ | ❌ | ❌ | ❌ |
|
|
70
72
|
| **AI Context Gen** | ✅ (Project Brain) | ❌ | ❌ | ❌ |
|
|
71
73
|
|
|
@@ -129,7 +131,7 @@ You can run `qgis-plugin-analyzer` automatically before every commit to ensure q
|
|
|
129
131
|
|
|
130
132
|
```yaml
|
|
131
133
|
- repo: https://github.com/geociencio/qgis-plugin-analyzer
|
|
132
|
-
rev: v1.
|
|
134
|
+
rev: v1.5.0 # Use the latest tag
|
|
133
135
|
hooks:
|
|
134
136
|
- id: qgis-plugin-analyzer
|
|
135
137
|
```
|
|
@@ -210,6 +212,17 @@ Shows a professional, color-coded summary of findings directly in your terminal.
|
|
|
210
212
|
| `-b`, `--by` | Granularity of the summary: `total`, `modules`, `functions`, `classes`. | `total` |
|
|
211
213
|
| `-i`, `--input` | Path to the `project_context.json` file to summarize. | `analysis_results/project_context.json` |
|
|
212
214
|
|
|
215
|
+
### `qgis-analyzer security`
|
|
216
|
+
Performs a focused security scan on a file or directory.
|
|
217
|
+
|
|
218
|
+
| Argument | Description | Default |
|
|
219
|
+
| :--- | :--- | :--- |
|
|
220
|
+
| `path` | **(Required)** Path to the file or directory to scan. | N/A |
|
|
221
|
+
| `-p`, `--profile`| Configuration profile. | `default` |
|
|
222
|
+
|
|
223
|
+
### `qgis-analyzer version`
|
|
224
|
+
Shows the current version of the analyzer.
|
|
225
|
+
|
|
213
226
|
**Example:**
|
|
214
227
|
```bash
|
|
215
228
|
# Executive summary
|
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
__init__.py,sha256=m27nXDOFpStOzfg3Qqeaw7yj1wNmpnOH7xk5svlCprA,1185
|
|
2
|
-
analyzer/__init__.py,sha256=
|
|
3
|
-
analyzer/cli.py,sha256=
|
|
4
|
-
analyzer/
|
|
2
|
+
analyzer/__init__.py,sha256=4cVdHyVkLCwrgmcijSYhVDiKxMJJFTeK4SOJXNXWJwc,1129
|
|
3
|
+
analyzer/cli.py,sha256=x3mEG8Y5m18RLswXUkaky_QoY5CR17DsXwyg-bSnvZ0,7077
|
|
4
|
+
analyzer/commands.py,sha256=cjXVtipPutYR7NCAzg9uxB1NZKkZ20GQpRseSPJER2A,5127
|
|
5
|
+
analyzer/engine.py,sha256=ls9WrjH5eFHt8xghQALGOmRgb_-VvBmnPuGC1ZCAUG8,24431
|
|
5
6
|
analyzer/fixer.py,sha256=6CujODr4pJgdR2XuJoBOCrQxBHjJfvVsBmg4YZdKJAM,10653
|
|
6
|
-
analyzer/scanner.py,sha256=
|
|
7
|
+
analyzer/scanner.py,sha256=3NUsEumloeND_Wd1V-fOnn3GklXDpIh31gN4Hhuf9AU,8154
|
|
8
|
+
analyzer/secrets.py,sha256=Ml-hRSFgLlAsi6KeD2V53OuQ1E8qWE_65yzDm_RD3AE,2899
|
|
9
|
+
analyzer/security_checker.py,sha256=YXQeIvtrtwvqHm5xGW8zXNrPycdsLyavCnxFOltf8Rs,2839
|
|
10
|
+
analyzer/security_rules.py,sha256=x95EweqgTOFNcM6In2aA37qOeqsv1PBYkCsRmwhpwGw,5182
|
|
7
11
|
analyzer/semantic.py,sha256=cVmSiTmI-b4DFZ5Q6kZUdqd6ZPH92pJmNAE60MIiZRI,8192
|
|
8
12
|
analyzer/transformers.py,sha256=tFmelReM8ILpFjMBSDIDMCXPSZlD9t8FD7lTXVgfOgA,7103
|
|
9
13
|
analyzer/validators.py,sha256=5JPwQYQ4KKThZchr34nV06-VToFhgBOtIp7lNhO5WTk,8344
|
|
14
|
+
analyzer/visitors.py,sha256=DRwqJal_pTtXs7zNq7iSKc7EvyDhTaXRAu1lvm05St0,17448
|
|
10
15
|
analyzer/models/__init__.py,sha256=NlDgdV1i9CoWJBgiENoJQuHnYCOzO6U_ui3wHOeT7xI,160
|
|
11
16
|
analyzer/models/analysis_models.py,sha256=WXsYgbjD4ngBoyaY-YkwMMl2Jpbn96hue6zuSERZRb0,2362
|
|
12
17
|
analyzer/reporters/__init__.py,sha256=fVsu2gCuBdDd_STD97Jku8otcO3j7NZrxxd_pGzf6Sw,280
|
|
13
18
|
analyzer/reporters/html_reporter.py,sha256=j64KkjiRwwRBzIPZE7bBJGsbJEYIoSnZeho63tI4c5A,14919
|
|
14
|
-
analyzer/reporters/markdown_reporter.py,sha256=
|
|
15
|
-
analyzer/reporters/summary_reporter.py,sha256=
|
|
19
|
+
analyzer/reporters/markdown_reporter.py,sha256=gNTP2K8yFlbTMceuxxTsxGyAkAUUxyGQD0KMyHzaQ2Q,9568
|
|
20
|
+
analyzer/reporters/summary_reporter.py,sha256=di_fNUcNgLPtSNQHuCj4J_jGVzQUZ5aqw_-BHhnhzBo,9572
|
|
16
21
|
analyzer/rules/__init__.py,sha256=UNZoY89SFQoUKYGj_rjwvv8md6vXSSM2pGPV6MqC2Fg,261
|
|
17
22
|
analyzer/rules/modernization_rules.py,sha256=IJw8c6v7Q_5z3xF9Dh0eBrproRFZFtIOyWcArAsSv18,1071
|
|
18
|
-
analyzer/rules/qgis_rules.py,sha256=
|
|
23
|
+
analyzer/rules/qgis_rules.py,sha256=vpFkdC3RnlkcSHqJB_Ec1wYLn8qV8xMjXZCgitslRSc,2578
|
|
19
24
|
analyzer/utils/__init__.py,sha256=7abAIymmrA8FlzqsM8JfKXnfx3HjX0r-8KdvmgKMRQA,1006
|
|
20
25
|
analyzer/utils/ast_utils.py,sha256=LZ9NLrbJlhFGM7LsX6YNexOi_avJ0Sxzf8GdZHvoXK4,3831
|
|
21
26
|
analyzer/utils/config_utils.py,sha256=reNyUonmik8Tpwx0RbaslsiienIqPDeiXBfh4Pv23_E,4690
|
|
22
27
|
analyzer/utils/logging_utils.py,sha256=V0z7XeF46tj2oI247j2fRRafClbtMDVscVmg2JFYHV4,1257
|
|
23
28
|
analyzer/utils/path_utils.py,sha256=BkffM1TRKAMPCzLx49rQzmYHYGbPwlI41LCK5QoN2oU,4097
|
|
24
29
|
analyzer/utils/performance_utils.py,sha256=M3mfMVrOvyfgQiCQB0R5IkkBnAx-L9er4fGR9UL5E-0,4253
|
|
25
|
-
qgis_plugin_analyzer-1.
|
|
26
|
-
qgis_plugin_analyzer-1.
|
|
27
|
-
qgis_plugin_analyzer-1.
|
|
28
|
-
qgis_plugin_analyzer-1.
|
|
29
|
-
qgis_plugin_analyzer-1.
|
|
30
|
-
qgis_plugin_analyzer-1.
|
|
30
|
+
qgis_plugin_analyzer-1.5.0.dist-info/licenses/LICENSE,sha256=bMxViCXCE9h3bzmw6oUvy4R_CvzOrV3aYhIvjBPx20A,35091
|
|
31
|
+
qgis_plugin_analyzer-1.5.0.dist-info/METADATA,sha256=x6KPAK7Rgr1K9lNCquZioxhZk9KHtQswmOF8RhRGq8g,13583
|
|
32
|
+
qgis_plugin_analyzer-1.5.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
33
|
+
qgis_plugin_analyzer-1.5.0.dist-info/entry_points.txt,sha256=1dwiqkZC4hGYExrOgyhZkxv7CbClSZqJkVk42nlw1IY,52
|
|
34
|
+
qgis_plugin_analyzer-1.5.0.dist-info/top_level.txt,sha256=i9U1DboCuTuaYpS-5GcgUeKIlSI00PoxYtnrfQWD8wo,18
|
|
35
|
+
qgis_plugin_analyzer-1.5.0.dist-info/RECORD,,
|
{qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.5.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{qgis_plugin_analyzer-1.4.0.dist-info → qgis_plugin_analyzer-1.5.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|