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.
Files changed (59) hide show
  1. blinter/__init__.py +36 -0
  2. blinter/__main__.py +6 -0
  3. blinter/_version.py +101 -0
  4. blinter/checkers/__init__.py +5 -0
  5. blinter/checkers/advanced/__init__.py +81 -0
  6. blinter/checkers/advanced/enhanced.py +200 -0
  7. blinter/checkers/advanced/escaping.py +145 -0
  8. blinter/checkers/advanced/style_security_perf.py +980 -0
  9. blinter/checkers/advanced/vars_syntax.py +882 -0
  10. blinter/checkers/cmd_extended.py +922 -0
  11. blinter/checkers/globals/__init__.py +77 -0
  12. blinter/checkers/globals/analysis.py +821 -0
  13. blinter/checkers/globals/exit_flow.py +476 -0
  14. blinter/checkers/globals/for_scope.py +155 -0
  15. blinter/checkers/globals/style_globals.py +443 -0
  16. blinter/checkers/line_endings.py +156 -0
  17. blinter/checkers/orchestration.py +325 -0
  18. blinter/checkers/performance.py +254 -0
  19. blinter/checkers/security.py +450 -0
  20. blinter/checkers/style.py +134 -0
  21. blinter/checkers/syntax.py +1183 -0
  22. blinter/checkers/vars.py +137 -0
  23. blinter/checkers/warnings.py +918 -0
  24. blinter/checkers/warnings_compat.py +173 -0
  25. blinter/cli/__init__.py +1 -0
  26. blinter/cli/args.py +400 -0
  27. blinter/cli/main.py +768 -0
  28. blinter/config/__init__.py +1 -0
  29. blinter/config/loader.py +310 -0
  30. blinter/constants.py +207 -0
  31. blinter/engine/__init__.py +5 -0
  32. blinter/engine/dependencies.py +604 -0
  33. blinter/engine/lines_cache.py +33 -0
  34. blinter/engine/linter.py +204 -0
  35. blinter/io/__init__.py +1 -0
  36. blinter/io/discovery.py +125 -0
  37. blinter/io/encoding.py +429 -0
  38. blinter/logging_config.py +7 -0
  39. blinter/models.py +224 -0
  40. blinter/output/__init__.py +1 -0
  41. blinter/output/formatters.py +398 -0
  42. blinter/output/json_formatter.py +146 -0
  43. blinter/output/sarif_formatter.py +379 -0
  44. blinter/parsing/__init__.py +1 -0
  45. blinter/parsing/context.py +475 -0
  46. blinter/parsing/embedded.py +368 -0
  47. blinter/parsing/structure.py +635 -0
  48. blinter/paths.py +44 -0
  49. blinter/patterns.py +490 -0
  50. blinter/py.typed +0 -0
  51. blinter/rules/__init__.py +1 -0
  52. blinter/rules/expansion_data.py +52 -0
  53. blinter/rules/helpers.py +98 -0
  54. blinter/rules/registry.py +1371 -0
  55. blinter-1.1.43.dist-info/METADATA +485 -0
  56. blinter-1.1.43.dist-info/RECORD +59 -0
  57. blinter-1.1.43.dist-info/WHEEL +4 -0
  58. blinter-1.1.43.dist-info/entry_points.txt +3 -0
  59. 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