blinter 1.1.43__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.
- blinter/__init__.py +36 -0
- blinter/__main__.py +6 -0
- blinter/_version.py +101 -0
- blinter/checkers/__init__.py +5 -0
- blinter/checkers/advanced/__init__.py +81 -0
- blinter/checkers/advanced/enhanced.py +200 -0
- blinter/checkers/advanced/escaping.py +145 -0
- blinter/checkers/advanced/style_security_perf.py +980 -0
- blinter/checkers/advanced/vars_syntax.py +882 -0
- blinter/checkers/cmd_extended.py +922 -0
- blinter/checkers/globals/__init__.py +77 -0
- blinter/checkers/globals/analysis.py +821 -0
- blinter/checkers/globals/exit_flow.py +476 -0
- blinter/checkers/globals/for_scope.py +155 -0
- blinter/checkers/globals/style_globals.py +443 -0
- blinter/checkers/line_endings.py +156 -0
- blinter/checkers/orchestration.py +325 -0
- blinter/checkers/performance.py +254 -0
- blinter/checkers/security.py +450 -0
- blinter/checkers/style.py +134 -0
- blinter/checkers/syntax.py +1183 -0
- blinter/checkers/vars.py +137 -0
- blinter/checkers/warnings.py +918 -0
- blinter/checkers/warnings_compat.py +173 -0
- blinter/cli/__init__.py +1 -0
- blinter/cli/args.py +400 -0
- blinter/cli/main.py +768 -0
- blinter/config/__init__.py +1 -0
- blinter/config/loader.py +310 -0
- blinter/constants.py +207 -0
- blinter/engine/__init__.py +5 -0
- blinter/engine/dependencies.py +604 -0
- blinter/engine/lines_cache.py +33 -0
- blinter/engine/linter.py +204 -0
- blinter/io/__init__.py +1 -0
- blinter/io/discovery.py +125 -0
- blinter/io/encoding.py +429 -0
- blinter/logging_config.py +7 -0
- blinter/models.py +224 -0
- blinter/output/__init__.py +1 -0
- blinter/output/formatters.py +398 -0
- blinter/output/json_formatter.py +146 -0
- blinter/output/sarif_formatter.py +379 -0
- blinter/parsing/__init__.py +1 -0
- blinter/parsing/context.py +475 -0
- blinter/parsing/embedded.py +368 -0
- blinter/parsing/structure.py +635 -0
- blinter/paths.py +44 -0
- blinter/patterns.py +490 -0
- blinter/py.typed +0 -0
- blinter/rules/__init__.py +1 -0
- blinter/rules/expansion_data.py +52 -0
- blinter/rules/helpers.py +98 -0
- blinter/rules/registry.py +1371 -0
- blinter-1.1.43.dist-info/METADATA +485 -0
- blinter-1.1.43.dist-info/RECORD +59 -0
- blinter-1.1.43.dist-info/WHEEL +4 -0
- blinter-1.1.43.dist-info/entry_points.txt +3 -0
- blinter-1.1.43.dist-info/licenses/COPYING +661 -0
|
@@ -0,0 +1,980 @@
|
|
|
1
|
+
"""Advanced style, security, and performance rules."""
|
|
2
|
+
|
|
3
|
+
from contextvars import ContextVar
|
|
4
|
+
import re
|
|
5
|
+
from typing import (
|
|
6
|
+
Dict,
|
|
7
|
+
List,
|
|
8
|
+
Optional,
|
|
9
|
+
Tuple,
|
|
10
|
+
cast,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from blinter.checkers.cmd_extended import (
|
|
14
|
+
check_sec025_unquoted_cd,
|
|
15
|
+
has_unquoted_user_args,
|
|
16
|
+
)
|
|
17
|
+
from blinter.constants import MAGIC_NUMBER_EXCEPTIONS
|
|
18
|
+
from blinter.models import LintIssue
|
|
19
|
+
from blinter.parsing.context import _is_comment_line
|
|
20
|
+
from blinter.parsing.structure import _is_in_subroutine_context
|
|
21
|
+
from blinter.patterns import SAFE_COMMAND_INJECTION_PATTERNS
|
|
22
|
+
from blinter.rules.registry import RULES
|
|
23
|
+
|
|
24
|
+
_REDIRECT_MACRO_VARS: frozenset[str] = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"nul",
|
|
27
|
+
"nul1",
|
|
28
|
+
"nul2",
|
|
29
|
+
"nul3",
|
|
30
|
+
"nul6",
|
|
31
|
+
"_silent",
|
|
32
|
+
"psc",
|
|
33
|
+
"ps",
|
|
34
|
+
"red",
|
|
35
|
+
"white",
|
|
36
|
+
"gray",
|
|
37
|
+
"grey",
|
|
38
|
+
"blue",
|
|
39
|
+
"yellow",
|
|
40
|
+
"green",
|
|
41
|
+
"magenta",
|
|
42
|
+
"cyan",
|
|
43
|
+
"black",
|
|
44
|
+
"_red",
|
|
45
|
+
"_yellow",
|
|
46
|
+
"_gray",
|
|
47
|
+
"_grey",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_SEC011_PATH_OP_PATTERN = re.compile(r"\b(cd|copy|move|del)\b", re.IGNORECASE)
|
|
53
|
+
_SEC012_TEMP_FILE_PATTERN = re.compile(
|
|
54
|
+
r"(%temp%|%tmp%|\\temp\\|\bc:\\temp\\).*\.(tmp|bat|cmd|exe)\b"
|
|
55
|
+
r"|\.(tmp|bat|cmd|exe)\b.*(%temp%|%tmp%|\\temp\\|\bc:\\temp\\)",
|
|
56
|
+
re.IGNORECASE,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _has_path_traversal_risk(stripped: str) -> bool:
|
|
61
|
+
"""Return True when a line uses .. with a path-operation command."""
|
|
62
|
+
if ".." not in stripped or _is_comment_line(stripped):
|
|
63
|
+
return False
|
|
64
|
+
return _SEC011_PATH_OP_PATTERN.search(stripped) is not None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _has_unsafe_temp_creation(stripped: str) -> bool:
|
|
68
|
+
"""Return True when temp file creation lacks a randomness component."""
|
|
69
|
+
lower = stripped.lower()
|
|
70
|
+
if "%random%" in lower:
|
|
71
|
+
return False
|
|
72
|
+
return _SEC012_TEMP_FILE_PATTERN.search(stripped) is not None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _has_unescaped_user_args(stripped: str) -> bool:
|
|
76
|
+
"""Return True when unquoted %1-%9, %*, %~1 or delayed equivalents appear."""
|
|
77
|
+
if _is_comment_line(stripped):
|
|
78
|
+
return False
|
|
79
|
+
return has_unquoted_user_args(stripped)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _check_sec014_unescaped_input(
|
|
83
|
+
line: str, line_number: int, lines: List[str], labels: Dict[str, int]
|
|
84
|
+
) -> Optional[LintIssue]:
|
|
85
|
+
"""SEC014: Unescaped user input in command execution."""
|
|
86
|
+
if _is_in_subroutine_context(lines, line_number, labels):
|
|
87
|
+
return None
|
|
88
|
+
stripped = line.strip()
|
|
89
|
+
if not _has_unescaped_user_args(stripped):
|
|
90
|
+
return None
|
|
91
|
+
return LintIssue(
|
|
92
|
+
line_number,
|
|
93
|
+
RULES["SEC014"],
|
|
94
|
+
context='Quote user arguments, for example "%~1"',
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _check_sec017_predictable_temp(line: str, line_number: int) -> Optional[LintIssue]:
|
|
99
|
+
"""SEC017: Temporary file creation in predictable location."""
|
|
100
|
+
stripped = line.strip()
|
|
101
|
+
lowered = stripped.lower()
|
|
102
|
+
if "temp" not in lowered or (".tmp" not in stripped and ".temp" not in stripped):
|
|
103
|
+
return None
|
|
104
|
+
if "%random%" in lowered or "%time%" in lowered:
|
|
105
|
+
return None
|
|
106
|
+
return LintIssue(
|
|
107
|
+
line_number,
|
|
108
|
+
RULES["SEC017"],
|
|
109
|
+
context="Temp files should use %RANDOM% or timestamp",
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
_SEC018_REDIRECTION_PATTERNS: tuple[str, ...] = (
|
|
114
|
+
r">\s*c:\\temp",
|
|
115
|
+
r">\s*c:\\windows\\temp",
|
|
116
|
+
r">\s*\\\\.*\\share",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _check_sec018_insecure_redirection(
|
|
121
|
+
line: str, line_number: int
|
|
122
|
+
) -> Optional[LintIssue]:
|
|
123
|
+
"""SEC018: Command output redirection to insecure location."""
|
|
124
|
+
stripped_lower = line.strip().lower()
|
|
125
|
+
for pattern in _SEC018_REDIRECTION_PATTERNS:
|
|
126
|
+
if re.search(pattern, stripped_lower):
|
|
127
|
+
return LintIssue(
|
|
128
|
+
line_number,
|
|
129
|
+
RULES["SEC018"],
|
|
130
|
+
context="Output redirected to potentially insecure location",
|
|
131
|
+
)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _check_advanced_security(
|
|
136
|
+
line: str, line_number: int, lines: List[str], labels: Dict[str, int]
|
|
137
|
+
) -> List[LintIssue]:
|
|
138
|
+
"""Check for advanced security patterns."""
|
|
139
|
+
issues: List[LintIssue] = []
|
|
140
|
+
sec014 = _check_sec014_unescaped_input(line, line_number, lines, labels)
|
|
141
|
+
if sec014 is not None:
|
|
142
|
+
issues.append(sec014)
|
|
143
|
+
issues.extend(check_sec025_unquoted_cd(line, line_number))
|
|
144
|
+
sec017 = _check_sec017_predictable_temp(line, line_number)
|
|
145
|
+
if sec017 is not None:
|
|
146
|
+
issues.append(sec017)
|
|
147
|
+
sec018 = _check_sec018_insecure_redirection(line, line_number)
|
|
148
|
+
if sec018 is not None:
|
|
149
|
+
issues.append(sec018)
|
|
150
|
+
return issues
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _check_for_block_performance(
|
|
154
|
+
lines: List[str], line_number: int, line: str, stripped: str
|
|
155
|
+
) -> List[LintIssue]:
|
|
156
|
+
"""Check performance issues that apply inside FOR loop blocks."""
|
|
157
|
+
issues: List[LintIssue] = []
|
|
158
|
+
if not _line_is_inside_for_block(lines, line_number):
|
|
159
|
+
return issues
|
|
160
|
+
|
|
161
|
+
if re.search(r"set\s+\w+=.*%", stripped) and "set /a" not in stripped:
|
|
162
|
+
issues.append(
|
|
163
|
+
LintIssue(
|
|
164
|
+
line_number,
|
|
165
|
+
RULES["P016"],
|
|
166
|
+
context="String concatenation inside FOR loop is inefficient",
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
if re.search(r"![^!]+!", line) and stripped.count("!") >= 4:
|
|
170
|
+
issues.append(
|
|
171
|
+
LintIssue(
|
|
172
|
+
line_number,
|
|
173
|
+
RULES["P019"],
|
|
174
|
+
context="Excessive delayed expansion inside FOR loop",
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
if re.search(r">\s*nul", stripped) or re.search(r"2>\s*nul", stripped):
|
|
178
|
+
issues.append(
|
|
179
|
+
LintIssue(
|
|
180
|
+
line_number,
|
|
181
|
+
RULES["P022"],
|
|
182
|
+
context="Output redirection inside loop adds I/O overhead",
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
if stripped.startswith("set /a"):
|
|
186
|
+
issues.append(
|
|
187
|
+
LintIssue(
|
|
188
|
+
line_number,
|
|
189
|
+
RULES["P023"],
|
|
190
|
+
context="Complex SET /A inside loop; use intermediate variables",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
return issues
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _check_advanced_performance(
|
|
197
|
+
lines: List[str], line_number: int, line: str
|
|
198
|
+
) -> List[LintIssue]:
|
|
199
|
+
"""Check for performance patterns."""
|
|
200
|
+
issues: List[LintIssue] = []
|
|
201
|
+
stripped = line.strip().lower()
|
|
202
|
+
|
|
203
|
+
# P017: Repeated file existence checks
|
|
204
|
+
if stripped.startswith("if exist"):
|
|
205
|
+
filename_match = re.search(r'if exist\s+(["\']?)([^"\'\s]+)\1', stripped)
|
|
206
|
+
if filename_match:
|
|
207
|
+
filename = filename_match.group(2)
|
|
208
|
+
# Count occurrences of the same file check in surrounding lines
|
|
209
|
+
check_range = max(0, line_number - 5), min(len(lines), line_number + 5)
|
|
210
|
+
same_checks = 0
|
|
211
|
+
for i in range(check_range[0], check_range[1]):
|
|
212
|
+
if i != line_number - 1 and filename in lines[i].lower():
|
|
213
|
+
same_checks += 1
|
|
214
|
+
if same_checks >= 2:
|
|
215
|
+
issues.append(
|
|
216
|
+
LintIssue(
|
|
217
|
+
line_number,
|
|
218
|
+
RULES["P017"],
|
|
219
|
+
context=f"File '{filename}' checked multiple times",
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# P020: Redundant command echoing suppression
|
|
224
|
+
if stripped.startswith("@echo off") and line_number > 1:
|
|
225
|
+
issues.append(
|
|
226
|
+
LintIssue(
|
|
227
|
+
line_number,
|
|
228
|
+
RULES["P020"],
|
|
229
|
+
context="@ECHO OFF should only appear once at script start",
|
|
230
|
+
)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# P021: Inefficient process checking pattern
|
|
234
|
+
if stripped.startswith("tasklist") and "/fi" not in stripped:
|
|
235
|
+
issues.append(
|
|
236
|
+
LintIssue(
|
|
237
|
+
line_number,
|
|
238
|
+
RULES["P021"],
|
|
239
|
+
context="TASKLIST should use /FI filters for efficiency",
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
in_for_block = _line_is_inside_for_block(lines, line_number)
|
|
244
|
+
issues.extend(
|
|
245
|
+
_check_for_block_performance(lines, line_number, line, stripped)
|
|
246
|
+
if in_for_block
|
|
247
|
+
else []
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
if stripped.startswith("for /r") and "*.*" in stripped:
|
|
251
|
+
issues.append(
|
|
252
|
+
LintIssue(
|
|
253
|
+
line_number,
|
|
254
|
+
RULES["P018"],
|
|
255
|
+
context="FOR /R with *.* traverses unnecessary files",
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if "*.*" in stripped and any(
|
|
260
|
+
stripped.startswith(prefix) for prefix in ("del ", "copy ", "for ")
|
|
261
|
+
):
|
|
262
|
+
issues.append(
|
|
263
|
+
LintIssue(
|
|
264
|
+
line_number,
|
|
265
|
+
RULES["P025"],
|
|
266
|
+
context="Use specific extensions instead of *.* wildcard",
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
return issues
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
_ForBlockSlot = Tuple[List[str], List[bool]]
|
|
274
|
+
_for_block_cache_var: ContextVar[Optional[_ForBlockSlot]] = ContextVar(
|
|
275
|
+
"for_block_membership",
|
|
276
|
+
default=None,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _next_paren_depth(depth: int, line: str) -> int:
|
|
281
|
+
"""Return parenthesis depth after ``line``, never below zero."""
|
|
282
|
+
lowered = line.strip().lower()
|
|
283
|
+
return max(depth + lowered.count("(") - lowered.count(")"), 0)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _build_inside_for_block(lines: List[str]) -> List[bool]:
|
|
287
|
+
"""Mark lines that belong to a FOR statement.
|
|
288
|
+
|
|
289
|
+
A one-line ``for ... do`` command is inside only on that line. A
|
|
290
|
+
parenthesized body stays inside through its closing parenthesis.
|
|
291
|
+
Lines after the block are outside, including an else-branch that follows.
|
|
292
|
+
"""
|
|
293
|
+
flags: List[bool] = []
|
|
294
|
+
depth = 0
|
|
295
|
+
resume_depth: Optional[int] = None
|
|
296
|
+
for line in lines:
|
|
297
|
+
lowered = line.strip().lower()
|
|
298
|
+
if resume_depth is None and lowered.startswith("for "):
|
|
299
|
+
flags.append(True)
|
|
300
|
+
depth_before = depth
|
|
301
|
+
depth = _next_paren_depth(depth, line)
|
|
302
|
+
if depth > depth_before:
|
|
303
|
+
resume_depth = depth_before
|
|
304
|
+
continue
|
|
305
|
+
if resume_depth is None:
|
|
306
|
+
flags.append(False)
|
|
307
|
+
depth = _next_paren_depth(depth, line)
|
|
308
|
+
continue
|
|
309
|
+
flags.append(True)
|
|
310
|
+
depth = _next_paren_depth(depth, line)
|
|
311
|
+
if depth <= resume_depth:
|
|
312
|
+
resume_depth = None
|
|
313
|
+
return flags
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _inside_for_block_flags(lines: List[str]) -> List[bool]:
|
|
317
|
+
"""Return one scan of ``lines``, reused until a different list is checked.
|
|
318
|
+
|
|
319
|
+
The slot holds the list object so a recycled ``id`` cannot serve stale flags.
|
|
320
|
+
"""
|
|
321
|
+
cached = _for_block_cache_var.get()
|
|
322
|
+
if cached is not None and cached[0] is lines:
|
|
323
|
+
return cached[1]
|
|
324
|
+
flags = _build_inside_for_block(lines)
|
|
325
|
+
_for_block_cache_var.set((lines, flags))
|
|
326
|
+
return flags
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _line_is_inside_for_block(lines: List[str], line_number: int) -> bool:
|
|
330
|
+
"""Return True when line_number appears inside a FOR loop block."""
|
|
331
|
+
if line_number < 1:
|
|
332
|
+
return False
|
|
333
|
+
return _inside_for_block_flags(lines)[line_number - 1]
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _timeout_lacks_explanation(
|
|
337
|
+
lines: List[str], line_number: int, timeout_value: int
|
|
338
|
+
) -> bool:
|
|
339
|
+
"""Return True when a large TIMEOUT value has no nearby comment."""
|
|
340
|
+
if timeout_value <= 10:
|
|
341
|
+
return False
|
|
342
|
+
for check_line in (line_number - 2, line_number - 1, line_number):
|
|
343
|
+
if 0 <= check_line - 1 < len(lines) and _is_comment_line(lines[check_line - 1]):
|
|
344
|
+
return False
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _group_has_unquoted_chain(text: str) -> bool:
|
|
349
|
+
"""Return True when ``text`` contains ``&`` or ``|`` outside quotes."""
|
|
350
|
+
quote = ""
|
|
351
|
+
index = 0
|
|
352
|
+
while index < len(text):
|
|
353
|
+
char = text[index]
|
|
354
|
+
if quote:
|
|
355
|
+
if char == quote:
|
|
356
|
+
quote = ""
|
|
357
|
+
index += 1
|
|
358
|
+
continue
|
|
359
|
+
if char in '"`':
|
|
360
|
+
quote = char
|
|
361
|
+
index += 1
|
|
362
|
+
continue
|
|
363
|
+
if char == "^" and index + 1 < len(text):
|
|
364
|
+
index += 2
|
|
365
|
+
continue
|
|
366
|
+
if char in "&|":
|
|
367
|
+
return True
|
|
368
|
+
index += 1
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _is_simple_parenthesized_if(stripped: str) -> bool:
|
|
373
|
+
"""Return True for an IF whose only parentheses wrap one command."""
|
|
374
|
+
if not re.match(r"^\s*if\s+.+\)\s*$", stripped) or "(" not in stripped:
|
|
375
|
+
return False
|
|
376
|
+
inner = re.sub(r"^\s*if\s+", "", stripped, flags=re.IGNORECASE)
|
|
377
|
+
if inner.count("(") != 1:
|
|
378
|
+
return False
|
|
379
|
+
open_at = inner.find("(")
|
|
380
|
+
close_at = inner.rfind(")")
|
|
381
|
+
if close_at <= open_at:
|
|
382
|
+
return False
|
|
383
|
+
return not _group_has_unquoted_chain(inner[open_at + 1 : close_at])
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _check_advanced_style_patterns(
|
|
387
|
+
line: str, line_number: int, lines: List[str]
|
|
388
|
+
) -> List[LintIssue]:
|
|
389
|
+
"""Check for advanced style patterns."""
|
|
390
|
+
issues: List[LintIssue] = []
|
|
391
|
+
stripped = line.strip()
|
|
392
|
+
|
|
393
|
+
timeout_match = re.search(r"timeout\s+/t\s+(\d+)", stripped.lower())
|
|
394
|
+
if timeout_match and _timeout_lacks_explanation(
|
|
395
|
+
lines, line_number, int(timeout_match.group(1))
|
|
396
|
+
):
|
|
397
|
+
timeout_value = int(timeout_match.group(1))
|
|
398
|
+
issues.append(
|
|
399
|
+
LintIssue(
|
|
400
|
+
line_number,
|
|
401
|
+
RULES["S023"],
|
|
402
|
+
context=f"Timeout value {timeout_value} needs explanation",
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# S024: Complex one-liner should be split
|
|
407
|
+
if len(stripped) > 80 and ("&&" in stripped or "||" in stripped):
|
|
408
|
+
if "^" not in stripped: # No continuation used
|
|
409
|
+
issues.append(
|
|
410
|
+
LintIssue(
|
|
411
|
+
line_number,
|
|
412
|
+
RULES["S024"],
|
|
413
|
+
context="Complex command should be split using continuation character",
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# S026: Inconsistent continuation character usage
|
|
418
|
+
if "^" in stripped and not stripped.endswith("^"):
|
|
419
|
+
# Check for improper continuation usage (exclude escape sequences)
|
|
420
|
+
# In batch files, ^ is used for both line continuation AND escaping special chars
|
|
421
|
+
# Only flag if it appears to be a continuation character, not an escape character
|
|
422
|
+
if stripped.count("^") == 1 and not re.search(r"\^\s*$", line):
|
|
423
|
+
# Check if ^ is used as escape character (followed by special char)
|
|
424
|
+
# Special chars that can be escaped: & | ( ) < > ^ " space tab
|
|
425
|
+
if not re.search(r"\^[&|()<>^\"\s]", stripped):
|
|
426
|
+
issues.append(
|
|
427
|
+
LintIssue(
|
|
428
|
+
line_number,
|
|
429
|
+
RULES["S026"],
|
|
430
|
+
context="Continuation character should be at line end",
|
|
431
|
+
)
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
# S027: Missing blank lines around code blocks
|
|
435
|
+
if re.match(r"^\s*:[a-zA-Z_]", stripped):
|
|
436
|
+
prev_idx = line_number - 2
|
|
437
|
+
if 0 <= prev_idx < len(lines):
|
|
438
|
+
prev_line = lines[prev_idx].strip()
|
|
439
|
+
if prev_line and not _is_comment_line(lines[prev_idx]):
|
|
440
|
+
issues.append(
|
|
441
|
+
LintIssue(
|
|
442
|
+
line_number,
|
|
443
|
+
RULES["S027"],
|
|
444
|
+
context="Add a blank line before subroutine or block label",
|
|
445
|
+
)
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
# S028: Redundant parentheses in simple commands
|
|
449
|
+
if _is_simple_parenthesized_if(stripped):
|
|
450
|
+
issues.append(
|
|
451
|
+
LintIssue(
|
|
452
|
+
line_number,
|
|
453
|
+
RULES["S028"],
|
|
454
|
+
context="Simple IF command does not need parentheses",
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
return issues
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _check_variable_naming(
|
|
462
|
+
line: str, line_number: int, variables_seen: Dict[str, str]
|
|
463
|
+
) -> List[LintIssue]:
|
|
464
|
+
"""Check variable naming consistency (S017)."""
|
|
465
|
+
issues: List[LintIssue] = []
|
|
466
|
+
# Find SET commands with both quoted and unquoted variable names
|
|
467
|
+
var_matches: List[re.Match[str]] = []
|
|
468
|
+
set_patterns = [
|
|
469
|
+
r"set\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=", # Regular set: set VAR=value
|
|
470
|
+
r'set\s+"([a-zA-Z_][a-zA-Z0-9_]*)\s*=', # Quoted set: set "VAR=value"
|
|
471
|
+
]
|
|
472
|
+
for pattern in set_patterns:
|
|
473
|
+
matches = list(re.finditer(pattern, line, re.IGNORECASE))
|
|
474
|
+
var_matches.extend(matches)
|
|
475
|
+
|
|
476
|
+
for match in var_matches:
|
|
477
|
+
var_name = str(match.group(1))
|
|
478
|
+
if var_name.isupper():
|
|
479
|
+
case_style = "upper"
|
|
480
|
+
elif var_name.islower():
|
|
481
|
+
case_style = "lower"
|
|
482
|
+
else:
|
|
483
|
+
case_style = "mixed"
|
|
484
|
+
|
|
485
|
+
if var_name.upper() in variables_seen:
|
|
486
|
+
if variables_seen[var_name.upper()] != case_style:
|
|
487
|
+
issues.append(
|
|
488
|
+
LintIssue(
|
|
489
|
+
line_number=line_number,
|
|
490
|
+
rule=RULES["S017"],
|
|
491
|
+
context=f"Inconsistent case for variable {var_name}",
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
else:
|
|
495
|
+
variables_seen[var_name.upper()] = case_style
|
|
496
|
+
|
|
497
|
+
return issues
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _check_function_docs(
|
|
501
|
+
line: str, line_number: int, lines: List[str]
|
|
502
|
+
) -> List[LintIssue]:
|
|
503
|
+
"""Check for function documentation (S018) - hybrid implementation."""
|
|
504
|
+
issues: List[LintIssue] = []
|
|
505
|
+
|
|
506
|
+
stripped = line.strip()
|
|
507
|
+
# Match all labels (subroutines) - pattern: :LabelName
|
|
508
|
+
if re.match(r"\s*:[a-zA-Z_][a-zA-Z0-9_]*\s*$", stripped):
|
|
509
|
+
# Found a label that might be a subroutine
|
|
510
|
+
# Check if previous 3 lines have documentation (more focused than 5)
|
|
511
|
+
doc_found = False
|
|
512
|
+
for j in range(max(0, line_number - 3), line_number - 1):
|
|
513
|
+
if j < len(lines) and _is_comment_line(lines[j]):
|
|
514
|
+
doc_found = True
|
|
515
|
+
break
|
|
516
|
+
|
|
517
|
+
if not doc_found:
|
|
518
|
+
issues.append(
|
|
519
|
+
LintIssue(
|
|
520
|
+
line_number=line_number,
|
|
521
|
+
rule=RULES["S018"],
|
|
522
|
+
context="Function/subroutine lacks documentation",
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
return issues
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _find_set_exclusion_ranges(line: str) -> List[Tuple[int, int]]:
|
|
530
|
+
"""
|
|
531
|
+
Find exclusion ranges for SET statements in a line.
|
|
532
|
+
|
|
533
|
+
Args:
|
|
534
|
+
line: The line to analyze
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
List of (start, end) tuples representing character ranges to exclude from checks
|
|
538
|
+
"""
|
|
539
|
+
# Pattern matches: SET VAR=value, SET /A VAR=value, including in IF statements
|
|
540
|
+
# We want to skip checking the value part after the = sign
|
|
541
|
+
set_pattern = r"\bSET\s+(?:/A\s+)?([A-Z_@#$][A-Z0-9_@#$]*)\s*="
|
|
542
|
+
|
|
543
|
+
# Find all SET statement positions to create exclusion zones
|
|
544
|
+
exclusion_ranges: List[Tuple[int, int]] = []
|
|
545
|
+
for set_match in re.finditer(set_pattern, line, re.IGNORECASE):
|
|
546
|
+
# Find the equals sign position
|
|
547
|
+
equals_pos = set_match.end() - 1
|
|
548
|
+
|
|
549
|
+
# The exclusion zone starts right after the equals sign and goes to:
|
|
550
|
+
# 1. End of line
|
|
551
|
+
# 2. Next SET statement
|
|
552
|
+
# 3. Closing parenthesis (for IF statements)
|
|
553
|
+
# 4. Start of next command (via & or |)
|
|
554
|
+
|
|
555
|
+
search_start = equals_pos + 1
|
|
556
|
+
end_pos = len(line)
|
|
557
|
+
|
|
558
|
+
# Look for terminators after the equals sign
|
|
559
|
+
remainder = line[search_start:]
|
|
560
|
+
|
|
561
|
+
# Find the earliest terminator
|
|
562
|
+
# Check for command separators (but not in quoted strings)
|
|
563
|
+
# Simple heuristic: look for & or | that aren't inside quotes
|
|
564
|
+
for i, char in enumerate(remainder):
|
|
565
|
+
if char in ("&", "|", ")"):
|
|
566
|
+
# Check if we're inside quotes (simple check)
|
|
567
|
+
before = remainder[:i]
|
|
568
|
+
if before.count('"') % 2 == 0: # Even number of quotes = not in string
|
|
569
|
+
end_pos = search_start + i
|
|
570
|
+
break
|
|
571
|
+
|
|
572
|
+
exclusion_ranges.append((search_start, end_pos))
|
|
573
|
+
|
|
574
|
+
return exclusion_ranges
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _is_number_in_special_context(
|
|
578
|
+
immediate_before: str, immediate_after: str, context_before: str, context_after: str
|
|
579
|
+
) -> bool:
|
|
580
|
+
"""
|
|
581
|
+
Check if a number is in a special context (GUID, path, math expr) and should be skipped.
|
|
582
|
+
|
|
583
|
+
Args:
|
|
584
|
+
immediate_before: Last 2 chars before number (stripped)
|
|
585
|
+
immediate_after: First 2 chars after number (stripped)
|
|
586
|
+
context_before: Full text before number
|
|
587
|
+
context_after: Full text after number
|
|
588
|
+
|
|
589
|
+
Returns:
|
|
590
|
+
True if number should be skipped, False otherwise
|
|
591
|
+
"""
|
|
592
|
+
# Check for GUID or identifier pattern: dash/brace immediately adjacent
|
|
593
|
+
has_guid_before = immediate_before and immediate_before[-1] in ["-", "{"]
|
|
594
|
+
has_guid_after = immediate_after and immediate_after[0] in ["-", "}"]
|
|
595
|
+
|
|
596
|
+
# Check for file path: backslash or forward slash immediately adjacent
|
|
597
|
+
has_path_before = immediate_before and immediate_before[-1] in ["\\", "/"]
|
|
598
|
+
has_path_after = immediate_after and immediate_after[0] in ["\\", "/"]
|
|
599
|
+
|
|
600
|
+
# Check if it's in a PowerShell math expression context
|
|
601
|
+
context_lower = context_before.lower()
|
|
602
|
+
in_math_round = "round(" in context_lower and ")" in context_after
|
|
603
|
+
in_math_class = "[math]::" in context_lower
|
|
604
|
+
|
|
605
|
+
return (
|
|
606
|
+
has_guid_before
|
|
607
|
+
or has_guid_after
|
|
608
|
+
or has_path_before
|
|
609
|
+
or has_path_after
|
|
610
|
+
or in_math_round
|
|
611
|
+
or in_math_class
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _check_magic_numbers(line: str, line_number: int) -> List[LintIssue]:
|
|
616
|
+
"""Check for magic numbers (S019)."""
|
|
617
|
+
# Skip comment lines - magic numbers in comments are documentation, not code
|
|
618
|
+
if _is_comment_line(line):
|
|
619
|
+
return []
|
|
620
|
+
|
|
621
|
+
issues: List[LintIssue] = []
|
|
622
|
+
number_pattern = r"\b(?<!%)\d{2,}\b(?!%)"
|
|
623
|
+
|
|
624
|
+
# Find SET statement exclusion zones
|
|
625
|
+
exclusion_ranges = _find_set_exclusion_ranges(line)
|
|
626
|
+
|
|
627
|
+
for match in re.finditer(number_pattern, line):
|
|
628
|
+
number = match.group(0)
|
|
629
|
+
match_start = match.start()
|
|
630
|
+
|
|
631
|
+
# Skip if this number is within a SET statement's value assignment
|
|
632
|
+
if any(start <= match_start < end for start, end in exclusion_ranges):
|
|
633
|
+
continue
|
|
634
|
+
|
|
635
|
+
# Get context around the number
|
|
636
|
+
context_before = line[: match.start()]
|
|
637
|
+
context_after = line[match.end() :]
|
|
638
|
+
immediate_before = context_before[-2:].strip()
|
|
639
|
+
immediate_after = context_after[:2].strip()
|
|
640
|
+
|
|
641
|
+
# Skip if in special context (GUID, path, math expression)
|
|
642
|
+
if _is_number_in_special_context(
|
|
643
|
+
immediate_before, immediate_after, context_before, context_after
|
|
644
|
+
):
|
|
645
|
+
continue
|
|
646
|
+
|
|
647
|
+
# Check if number is a common exception
|
|
648
|
+
if number not in MAGIC_NUMBER_EXCEPTIONS:
|
|
649
|
+
issues.append(
|
|
650
|
+
LintIssue(
|
|
651
|
+
line_number=line_number,
|
|
652
|
+
rule=RULES["S019"],
|
|
653
|
+
context=f"Magic number {number} should be defined as constant",
|
|
654
|
+
)
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
return issues
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _check_line_length(
|
|
661
|
+
line: str, line_number: int, max_line_length: int = 100
|
|
662
|
+
) -> List[LintIssue]:
|
|
663
|
+
"""Check for long lines (S020)."""
|
|
664
|
+
issues: List[LintIssue] = []
|
|
665
|
+
|
|
666
|
+
line_body = line.rstrip("\r\n")
|
|
667
|
+
line_length = len(line_body)
|
|
668
|
+
if line_length > max_line_length and not line_body.rstrip().endswith("^"):
|
|
669
|
+
issues.append(
|
|
670
|
+
LintIssue(
|
|
671
|
+
line_number=line_number,
|
|
672
|
+
rule=RULES["S020"],
|
|
673
|
+
context=f"Line length {line_length} exceeds {max_line_length} characters",
|
|
674
|
+
)
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
return issues
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _check_advanced_style_rules(
|
|
681
|
+
lines: List[str], max_line_length: int = 100
|
|
682
|
+
) -> List[LintIssue]:
|
|
683
|
+
"""Check for advanced style and best practice issues (S017-S020)."""
|
|
684
|
+
issues: List[LintIssue] = []
|
|
685
|
+
variables_seen: Dict[str, str] = {} # var_name -> case_style
|
|
686
|
+
|
|
687
|
+
for i, line in enumerate(lines, start=1):
|
|
688
|
+
issues.extend(_check_variable_naming(line, i, variables_seen))
|
|
689
|
+
issues.extend(_check_function_docs(line, i, lines))
|
|
690
|
+
issues.extend(_check_magic_numbers(line, i))
|
|
691
|
+
issues.extend(_check_line_length(line, i, max_line_length))
|
|
692
|
+
|
|
693
|
+
return issues
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _get_safe_system_variables() -> List[str]:
|
|
697
|
+
"""Return list of safe system variables that don't pose injection risks."""
|
|
698
|
+
return [
|
|
699
|
+
"SystemDrive",
|
|
700
|
+
"SystemRoot",
|
|
701
|
+
"Windows",
|
|
702
|
+
"WinDir",
|
|
703
|
+
"ProgramFiles",
|
|
704
|
+
"ProgramData",
|
|
705
|
+
"CommonProgramFiles",
|
|
706
|
+
"UserProfile",
|
|
707
|
+
"AppData",
|
|
708
|
+
"LocalAppData",
|
|
709
|
+
"Temp",
|
|
710
|
+
"TMP",
|
|
711
|
+
"ComSpec",
|
|
712
|
+
"Path",
|
|
713
|
+
"PathExt",
|
|
714
|
+
"Processor_Architecture",
|
|
715
|
+
"Number_Of_Processors",
|
|
716
|
+
"OS",
|
|
717
|
+
"HomeDrive",
|
|
718
|
+
"HomePath",
|
|
719
|
+
"Public",
|
|
720
|
+
"AllUsersProfile",
|
|
721
|
+
"CommonProgramW6432",
|
|
722
|
+
"ProgramFiles(x86)",
|
|
723
|
+
"CommonProgramFiles(x86)",
|
|
724
|
+
]
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def _uses_only_system_variables(stripped: str) -> bool:
|
|
728
|
+
"""Return True when expanded variables are system or redirect macros."""
|
|
729
|
+
system_variables = {name.lower() for name in _get_safe_system_variables()}
|
|
730
|
+
variables_in_line: List[str] = cast(
|
|
731
|
+
List[str], re.findall(r"%([a-zA-Z_][a-zA-Z0-9_()]*)%", stripped)
|
|
732
|
+
)
|
|
733
|
+
return all(
|
|
734
|
+
var.lower() in system_variables
|
|
735
|
+
or var.lower() in _REDIRECT_MACRO_VARS
|
|
736
|
+
or var.startswith("~")
|
|
737
|
+
or var.isdigit()
|
|
738
|
+
for var in variables_in_line
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def _has_unsafe_command_chaining(stripped: str) -> bool:
|
|
743
|
+
"""Return True when & or | chain commands beyond I/O redirection."""
|
|
744
|
+
potential_chaining: List[str] = cast(List[str], re.findall(r"[&|]", stripped))
|
|
745
|
+
for match in potential_chaining:
|
|
746
|
+
match_pos = stripped.find(match)
|
|
747
|
+
context = stripped[max(0, match_pos - 3) : match_pos + 3]
|
|
748
|
+
if "2>&1" not in context and ">&1" not in context:
|
|
749
|
+
return True
|
|
750
|
+
return False
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _is_safe_file_redirection_only(stripped: str) -> bool:
|
|
754
|
+
"""Return True for file operations that only redirect output."""
|
|
755
|
+
is_file_operation = bool(
|
|
756
|
+
re.search(
|
|
757
|
+
r"\b(del|copy|move|type|xcopy|rd|md|mkdir|rmdir)\b", stripped, re.IGNORECASE
|
|
758
|
+
)
|
|
759
|
+
)
|
|
760
|
+
has_only_redirection = bool(re.search(r">.*$", stripped))
|
|
761
|
+
return (
|
|
762
|
+
is_file_operation
|
|
763
|
+
and has_only_redirection
|
|
764
|
+
and not _has_unsafe_command_chaining(stripped)
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _is_safe_installer_style_redirection(stripped: str) -> bool:
|
|
769
|
+
"""Return True for script-controlled commands that only redirect output."""
|
|
770
|
+
if re.search(r"\bfor\s+/f\b.*\bpowershell\b.*-file\b", stripped, re.IGNORECASE):
|
|
771
|
+
return True
|
|
772
|
+
if re.search(
|
|
773
|
+
r'^"[^"]*(?:%[a-zA-Z_][a-zA-Z0-9_]*%|![^!]+!)[^"]*\.exe"[^&|]*(?:>|\^>)',
|
|
774
|
+
stripped,
|
|
775
|
+
re.IGNORECASE,
|
|
776
|
+
):
|
|
777
|
+
return not _has_unsafe_command_chaining(stripped)
|
|
778
|
+
if re.search(r"\bcurl\b", stripped, re.IGNORECASE) and re.search(
|
|
779
|
+
r"(?:>|\^>|2>&1)", stripped
|
|
780
|
+
):
|
|
781
|
+
return not _has_unsafe_command_chaining(stripped)
|
|
782
|
+
if re.search(r"\bfor\s+/f\b.*\b(dir|powershell)\b", stripped, re.IGNORECASE):
|
|
783
|
+
return True
|
|
784
|
+
return False
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _unquoted_percent_var_spans(stripped: str) -> List[Tuple[int, int]]:
|
|
788
|
+
"""Return spans of ``%VAR%`` that are outside quotes."""
|
|
789
|
+
spans: List[Tuple[int, int]] = []
|
|
790
|
+
quote = ""
|
|
791
|
+
index = 0
|
|
792
|
+
length = len(stripped)
|
|
793
|
+
while index < length:
|
|
794
|
+
char = stripped[index]
|
|
795
|
+
if quote:
|
|
796
|
+
if char == quote:
|
|
797
|
+
quote = ""
|
|
798
|
+
index += 1
|
|
799
|
+
continue
|
|
800
|
+
if char == "^" and index + 1 < length:
|
|
801
|
+
index += 2
|
|
802
|
+
continue
|
|
803
|
+
if char in '"`':
|
|
804
|
+
quote = char
|
|
805
|
+
index += 1
|
|
806
|
+
continue
|
|
807
|
+
if char == "%":
|
|
808
|
+
match = re.match(r"%[A-Za-z_][A-Za-z0-9_]*%", stripped[index:])
|
|
809
|
+
if match:
|
|
810
|
+
end = index + match.end()
|
|
811
|
+
spans.append((index, end))
|
|
812
|
+
index = end
|
|
813
|
+
continue
|
|
814
|
+
index += 1
|
|
815
|
+
return spans
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def _percent_var_is_command(stripped: str, start: int) -> bool:
|
|
819
|
+
"""Return True when ``%VAR%`` is the command token, not an argument."""
|
|
820
|
+
prefix = stripped[:start].strip()
|
|
821
|
+
if not prefix or prefix == "@":
|
|
822
|
+
return True
|
|
823
|
+
return prefix.rstrip().endswith(("&", "|", "("))
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _percent_var_touches_operator(stripped: str, start: int, end: int) -> bool:
|
|
827
|
+
"""Return True when an unquoted variable sits next to a shell operator."""
|
|
828
|
+
left = start - 1
|
|
829
|
+
while left >= 0 and stripped[left].isspace():
|
|
830
|
+
left -= 1
|
|
831
|
+
if left >= 0 and stripped[left] in "&|<>":
|
|
832
|
+
return True
|
|
833
|
+
right = end
|
|
834
|
+
while right < len(stripped) and stripped[right].isspace():
|
|
835
|
+
right += 1
|
|
836
|
+
return right < len(stripped) and stripped[right] in "&|<>"
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _has_unquoted_injection_var(stripped: str) -> bool:
|
|
840
|
+
"""Return True when an unquoted variable can inject a shell operator."""
|
|
841
|
+
for start, end in _unquoted_percent_var_spans(stripped):
|
|
842
|
+
if _percent_var_is_command(stripped, start) or _percent_var_touches_operator(
|
|
843
|
+
stripped, start, end
|
|
844
|
+
):
|
|
845
|
+
return True
|
|
846
|
+
return False
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _is_safe_command_injection(stripped: str) -> bool:
|
|
850
|
+
"""Check if a command with variables is safe from injection attacks."""
|
|
851
|
+
if _uses_only_system_variables(stripped):
|
|
852
|
+
return True
|
|
853
|
+
if _is_safe_installer_style_redirection(stripped):
|
|
854
|
+
return True
|
|
855
|
+
if any(
|
|
856
|
+
re.search(pattern, stripped, re.IGNORECASE)
|
|
857
|
+
for pattern in SAFE_COMMAND_INJECTION_PATTERNS
|
|
858
|
+
):
|
|
859
|
+
return True
|
|
860
|
+
return _is_safe_file_redirection_only(stripped)
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _check_enhanced_security_rules(lines: List[str]) -> List[LintIssue]:
|
|
864
|
+
"""Check for enhanced security issues (SEC011-SEC013)."""
|
|
865
|
+
issues: List[LintIssue] = []
|
|
866
|
+
|
|
867
|
+
for i, line in enumerate(lines, start=1):
|
|
868
|
+
stripped = line.strip()
|
|
869
|
+
|
|
870
|
+
# Check for path traversal (SEC011)
|
|
871
|
+
if _has_path_traversal_risk(stripped):
|
|
872
|
+
issues.append(
|
|
873
|
+
LintIssue(
|
|
874
|
+
line_number=i,
|
|
875
|
+
rule=RULES["SEC011"],
|
|
876
|
+
context="Path contains .. which may allow directory traversal",
|
|
877
|
+
)
|
|
878
|
+
)
|
|
879
|
+
|
|
880
|
+
# Check for unsafe temp file creation (SEC012)
|
|
881
|
+
if _has_unsafe_temp_creation(stripped):
|
|
882
|
+
issues.append(
|
|
883
|
+
LintIssue(
|
|
884
|
+
line_number=i,
|
|
885
|
+
rule=RULES["SEC012"],
|
|
886
|
+
context="Temp file creation without random component",
|
|
887
|
+
)
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
# Check for command injection via variables (SEC013)
|
|
891
|
+
# Exclude echo statements as they are generally safe for output
|
|
892
|
+
if _has_unquoted_injection_var(stripped):
|
|
893
|
+
# Skip echo statements - they are safe for variable expansion
|
|
894
|
+
if not re.match(r"\s*echo\s+", stripped, re.IGNORECASE):
|
|
895
|
+
if not _is_safe_command_injection(stripped):
|
|
896
|
+
issues.append(
|
|
897
|
+
LintIssue(
|
|
898
|
+
line_number=i,
|
|
899
|
+
rule=RULES["SEC013"],
|
|
900
|
+
context="Variable used with shell operators may allow injection",
|
|
901
|
+
)
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
return issues
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
def _check_unnecessary_output_p014(
|
|
908
|
+
lines: List[str], i: int, stripped: str
|
|
909
|
+
) -> Optional[LintIssue]:
|
|
910
|
+
"""Check for unnecessary output in non-interactive context (P014)."""
|
|
911
|
+
# Only flag TYPE and DIR commands - ECHO is typically intentional user communication
|
|
912
|
+
noisy_commands = ["type", "dir"]
|
|
913
|
+
|
|
914
|
+
for cmd in noisy_commands:
|
|
915
|
+
if re.match(rf"\s*{cmd}\s+", stripped, re.IGNORECASE):
|
|
916
|
+
if ">nul" not in stripped.lower() and ">" not in stripped:
|
|
917
|
+
# Check if nearby lines suggest interactive context
|
|
918
|
+
nearby_interactive = _has_nearby_interactive_cmds(lines, i)
|
|
919
|
+
|
|
920
|
+
if not nearby_interactive:
|
|
921
|
+
return LintIssue(
|
|
922
|
+
line_number=i,
|
|
923
|
+
rule=RULES["P014"],
|
|
924
|
+
context=(
|
|
925
|
+
f"{cmd.upper()} output may be unnecessary in "
|
|
926
|
+
"non-interactive context"
|
|
927
|
+
),
|
|
928
|
+
)
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _has_nearby_interactive_cmds(lines: List[str], line_index: int) -> bool:
|
|
933
|
+
"""Check if there are interactive commands near the given line."""
|
|
934
|
+
interactive_keywords = ["pause", "timeout", "set /p", "choice"]
|
|
935
|
+
|
|
936
|
+
for j in range(max(0, line_index - 3), min(len(lines), line_index + 4)):
|
|
937
|
+
nearby_line = lines[j].lower() if j < len(lines) else ""
|
|
938
|
+
if any(keyword in nearby_line for keyword in interactive_keywords):
|
|
939
|
+
return True
|
|
940
|
+
return False
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _check_enhanced_performance(lines: List[str]) -> List[LintIssue]:
|
|
944
|
+
"""Check for enhanced performance issues (P012-P014)."""
|
|
945
|
+
issues: List[LintIssue] = []
|
|
946
|
+
|
|
947
|
+
for i, line in enumerate(lines, start=1):
|
|
948
|
+
stripped = line.strip()
|
|
949
|
+
|
|
950
|
+
# Check DIR without /B for performance (P013)
|
|
951
|
+
if re.match(r"\s*dir\s+(?!.*\/b)", stripped, re.IGNORECASE):
|
|
952
|
+
if "|" in stripped or ">" in stripped: # Output is being processed
|
|
953
|
+
issues.append(
|
|
954
|
+
LintIssue(
|
|
955
|
+
line_number=i,
|
|
956
|
+
rule=RULES["P013"],
|
|
957
|
+
context="DIR output processed - consider /B flag for performance",
|
|
958
|
+
)
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
# Check for unnecessary output (P014)
|
|
962
|
+
p014_issue = _check_unnecessary_output_p014(lines, i, stripped)
|
|
963
|
+
if p014_issue:
|
|
964
|
+
issues.append(p014_issue)
|
|
965
|
+
|
|
966
|
+
# P012: Inefficient string operations (multiple substring ops on one line)
|
|
967
|
+
subst_ops = cast(
|
|
968
|
+
List[str],
|
|
969
|
+
re.findall(r"%[^%]*:[^%]*%", stripped, re.IGNORECASE),
|
|
970
|
+
)
|
|
971
|
+
if len(subst_ops) >= 2:
|
|
972
|
+
issues.append(
|
|
973
|
+
LintIssue(
|
|
974
|
+
line_number=i,
|
|
975
|
+
rule=RULES["P012"],
|
|
976
|
+
context="Multiple string substitution operations on one line",
|
|
977
|
+
)
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
return issues
|