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/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