lintro 0.3.2__py3-none-any.whl → 0.4.2__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.
Potentially problematic release.
This version of lintro might be problematic. Click here for more details.
- lintro/__init__.py +1 -1
- lintro/formatters/tools/__init__.py +12 -0
- lintro/formatters/tools/actionlint_formatter.py +82 -0
- lintro/formatters/tools/bandit_formatter.py +100 -0
- lintro/parsers/__init__.py +21 -0
- lintro/parsers/actionlint/__init__.py +1 -0
- lintro/parsers/actionlint/actionlint_issue.py +24 -0
- lintro/parsers/actionlint/actionlint_parser.py +67 -0
- lintro/tools/__init__.py +4 -0
- lintro/tools/core/tool_base.py +6 -4
- lintro/tools/implementations/tool_actionlint.py +151 -0
- lintro/tools/implementations/tool_bandit.py +445 -0
- lintro/tools/implementations/tool_darglint.py +2 -2
- lintro/tools/implementations/tool_hadolint.py +1 -1
- lintro/tools/implementations/tool_yamllint.py +1 -1
- lintro/tools/tool_enum.py +4 -0
- lintro/utils/console_logger.py +21 -5
- lintro/utils/formatting.py +4 -2
- lintro/utils/tool_executor.py +11 -6
- lintro/utils/tool_utils.py +19 -0
- {lintro-0.3.2.dist-info → lintro-0.4.2.dist-info}/METADATA +35 -28
- {lintro-0.3.2.dist-info → lintro-0.4.2.dist-info}/RECORD +26 -19
- {lintro-0.3.2.dist-info → lintro-0.4.2.dist-info}/licenses/LICENSE +1 -1
- {lintro-0.3.2.dist-info → lintro-0.4.2.dist-info}/WHEEL +0 -0
- {lintro-0.3.2.dist-info → lintro-0.4.2.dist-info}/entry_points.txt +0 -0
- {lintro-0.3.2.dist-info → lintro-0.4.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""Bandit security linter integration."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess # nosec B404 - deliberate, shell disabled
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
from lintro.enums.tool_type import ToolType
|
|
15
|
+
from lintro.models.core.tool import ToolConfig, ToolResult
|
|
16
|
+
from lintro.parsers.bandit.bandit_parser import parse_bandit_output
|
|
17
|
+
from lintro.tools.core.tool_base import BaseTool
|
|
18
|
+
from lintro.utils.tool_utils import walk_files_with_excludes
|
|
19
|
+
|
|
20
|
+
# Constants for Bandit configuration
|
|
21
|
+
BANDIT_DEFAULT_TIMEOUT: int = 30
|
|
22
|
+
BANDIT_DEFAULT_PRIORITY: int = 90 # High priority for security tool
|
|
23
|
+
BANDIT_FILE_PATTERNS: list[str] = ["*.py", "*.pyi"]
|
|
24
|
+
BANDIT_OUTPUT_FORMAT: str = "json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _extract_bandit_json(raw_text: str) -> dict[str, Any]:
|
|
28
|
+
"""Extract Bandit's JSON object from mixed stdout/stderr text.
|
|
29
|
+
|
|
30
|
+
Bandit may print informational lines and a progress bar alongside the
|
|
31
|
+
JSON report. This helper locates the first opening brace and the last
|
|
32
|
+
closing brace and attempts to parse the enclosed JSON object.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
raw_text: str: Combined stdout+stderr text from Bandit.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
dict[str, Any]: Parsed JSON object.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
JSONDecodeError: If JSON cannot be parsed.
|
|
42
|
+
ValueError: If no JSON object boundaries are found.
|
|
43
|
+
"""
|
|
44
|
+
if not raw_text or not raw_text.strip():
|
|
45
|
+
raise json.JSONDecodeError("Empty output", raw_text or "", 0)
|
|
46
|
+
|
|
47
|
+
text: str = raw_text.strip()
|
|
48
|
+
|
|
49
|
+
# Quick path: if the entire text is JSON
|
|
50
|
+
if text.startswith("{") and text.endswith("}"):
|
|
51
|
+
return json.loads(text)
|
|
52
|
+
|
|
53
|
+
start: int = text.find("{")
|
|
54
|
+
end: int = text.rfind("}")
|
|
55
|
+
if start == -1 or end == -1 or end < start:
|
|
56
|
+
raise ValueError("Could not locate JSON object in Bandit output")
|
|
57
|
+
|
|
58
|
+
json_str: str = text[start : end + 1]
|
|
59
|
+
return json.loads(json_str)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _load_bandit_config() -> dict[str, Any]:
|
|
63
|
+
"""Load bandit configuration from pyproject.toml.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
dict[str, Any]: Bandit configuration dictionary.
|
|
67
|
+
"""
|
|
68
|
+
config: dict[str, Any] = {}
|
|
69
|
+
pyproject_path = Path("pyproject.toml")
|
|
70
|
+
|
|
71
|
+
if pyproject_path.exists():
|
|
72
|
+
try:
|
|
73
|
+
with open(pyproject_path, "rb") as f:
|
|
74
|
+
pyproject_data = tomllib.load(f)
|
|
75
|
+
if "tool" in pyproject_data and "bandit" in pyproject_data["tool"]:
|
|
76
|
+
config = pyproject_data["tool"]["bandit"]
|
|
77
|
+
except Exception as e:
|
|
78
|
+
logger.warning(f"Failed to load bandit configuration: {e}")
|
|
79
|
+
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class BanditTool(BaseTool):
|
|
85
|
+
"""Bandit security linter integration.
|
|
86
|
+
|
|
87
|
+
Bandit is a security linter designed to find common security issues in Python code.
|
|
88
|
+
It processes Python files, builds an AST, and runs security plugins against the
|
|
89
|
+
AST nodes. Bandit does not support auto-fixing of issues.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
name: str: Tool name.
|
|
93
|
+
description: str: Tool description.
|
|
94
|
+
can_fix: bool: Whether the tool can fix issues.
|
|
95
|
+
config: ToolConfig: Tool configuration.
|
|
96
|
+
exclude_patterns: list[str]: List of patterns to exclude.
|
|
97
|
+
include_venv: bool: Whether to include virtual environment files.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
name: str = "bandit"
|
|
101
|
+
description: str = (
|
|
102
|
+
"Security linter that finds common security issues in Python code"
|
|
103
|
+
)
|
|
104
|
+
can_fix: bool = False # Bandit does not support auto-fixing
|
|
105
|
+
config: ToolConfig = field(
|
|
106
|
+
default_factory=lambda: ToolConfig(
|
|
107
|
+
priority=BANDIT_DEFAULT_PRIORITY, # High priority for security
|
|
108
|
+
conflicts_with=[], # Can work alongside other tools
|
|
109
|
+
file_patterns=BANDIT_FILE_PATTERNS, # Python files only
|
|
110
|
+
tool_type=ToolType.SECURITY, # Security-focused tool
|
|
111
|
+
options={
|
|
112
|
+
"timeout": BANDIT_DEFAULT_TIMEOUT, # Default timeout in seconds
|
|
113
|
+
"severity": None, # Minimum severity level (LOW, MEDIUM, HIGH)
|
|
114
|
+
"confidence": None, # Minimum confidence level (LOW, MEDIUM, HIGH)
|
|
115
|
+
"tests": None, # Comma-separated list of test IDs to run
|
|
116
|
+
"skips": None, # Comma-separated list of test IDs to skip
|
|
117
|
+
"profile": None, # Profile to use
|
|
118
|
+
"configfile": None, # Path to config file
|
|
119
|
+
"baseline": None, # Path to baseline report for comparison
|
|
120
|
+
"ignore_nosec": False, # Ignore # nosec comments
|
|
121
|
+
"aggregate": "vuln", # Aggregate by vulnerability or file
|
|
122
|
+
"verbose": False, # Verbose output
|
|
123
|
+
"quiet": False, # Quiet mode
|
|
124
|
+
},
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def __post_init__(self) -> None:
|
|
129
|
+
"""Initialize the tool with default configuration."""
|
|
130
|
+
super().__post_init__()
|
|
131
|
+
|
|
132
|
+
# Load bandit configuration from pyproject.toml
|
|
133
|
+
bandit_config = _load_bandit_config()
|
|
134
|
+
|
|
135
|
+
# Apply configuration overrides
|
|
136
|
+
if "exclude_dirs" in bandit_config:
|
|
137
|
+
# Convert exclude_dirs to exclude patterns
|
|
138
|
+
exclude_dirs = bandit_config["exclude_dirs"]
|
|
139
|
+
if isinstance(exclude_dirs, list):
|
|
140
|
+
for exclude_dir in exclude_dirs:
|
|
141
|
+
pattern = f"{exclude_dir}/**"
|
|
142
|
+
if pattern not in self.exclude_patterns:
|
|
143
|
+
self.exclude_patterns.append(pattern)
|
|
144
|
+
|
|
145
|
+
# Set other options from configuration
|
|
146
|
+
config_mapping = {
|
|
147
|
+
"tests": "tests",
|
|
148
|
+
"skips": "skips",
|
|
149
|
+
"profile": "profile",
|
|
150
|
+
"configfile": "configfile",
|
|
151
|
+
"baseline": "baseline",
|
|
152
|
+
"ignore_nosec": "ignore_nosec",
|
|
153
|
+
"aggregate": "aggregate",
|
|
154
|
+
# Newly mapped options from pyproject
|
|
155
|
+
"severity": "severity",
|
|
156
|
+
"confidence": "confidence",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for config_key, option_key in config_mapping.items():
|
|
160
|
+
if config_key in bandit_config:
|
|
161
|
+
self.options[option_key] = bandit_config[config_key]
|
|
162
|
+
|
|
163
|
+
def set_options(
|
|
164
|
+
self,
|
|
165
|
+
severity: str | None = None,
|
|
166
|
+
confidence: str | None = None,
|
|
167
|
+
tests: str | None = None,
|
|
168
|
+
skips: str | None = None,
|
|
169
|
+
profile: str | None = None,
|
|
170
|
+
configfile: str | None = None,
|
|
171
|
+
baseline: str | None = None,
|
|
172
|
+
ignore_nosec: bool | None = None,
|
|
173
|
+
aggregate: str | None = None,
|
|
174
|
+
verbose: bool | None = None,
|
|
175
|
+
quiet: bool | None = None,
|
|
176
|
+
**kwargs,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Set Bandit-specific options.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
severity: str | None: Minimum severity level (LOW, MEDIUM, HIGH).
|
|
182
|
+
confidence: str | None: Minimum confidence level (LOW, MEDIUM, HIGH).
|
|
183
|
+
tests: str | None: Comma-separated list of test IDs to run.
|
|
184
|
+
skips: str | None: Comma-separated list of test IDs to skip.
|
|
185
|
+
profile: str | None: Profile to use.
|
|
186
|
+
configfile: str | None: Path to config file.
|
|
187
|
+
baseline: str | None: Path to baseline report for comparison.
|
|
188
|
+
ignore_nosec: bool | None: Ignore # nosec comments.
|
|
189
|
+
aggregate: str | None: Aggregate by vulnerability or file.
|
|
190
|
+
verbose: bool | None: Verbose output.
|
|
191
|
+
quiet: bool | None: Quiet mode.
|
|
192
|
+
**kwargs: Other tool options.
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
ValueError: If an option value is invalid.
|
|
196
|
+
"""
|
|
197
|
+
# Validate severity level
|
|
198
|
+
if severity is not None:
|
|
199
|
+
valid_severities = ["LOW", "MEDIUM", "HIGH"]
|
|
200
|
+
if severity.upper() not in valid_severities:
|
|
201
|
+
raise ValueError(f"severity must be one of {valid_severities}")
|
|
202
|
+
severity = severity.upper()
|
|
203
|
+
|
|
204
|
+
# Validate confidence level
|
|
205
|
+
if confidence is not None:
|
|
206
|
+
valid_confidences = ["LOW", "MEDIUM", "HIGH"]
|
|
207
|
+
if confidence.upper() not in valid_confidences:
|
|
208
|
+
raise ValueError(f"confidence must be one of {valid_confidences}")
|
|
209
|
+
confidence = confidence.upper()
|
|
210
|
+
|
|
211
|
+
# Validate aggregate option
|
|
212
|
+
if aggregate is not None:
|
|
213
|
+
valid_aggregates = ["vuln", "file"]
|
|
214
|
+
if aggregate not in valid_aggregates:
|
|
215
|
+
raise ValueError(f"aggregate must be one of {valid_aggregates}")
|
|
216
|
+
|
|
217
|
+
options: dict[str, Any] = {
|
|
218
|
+
"severity": severity,
|
|
219
|
+
"confidence": confidence,
|
|
220
|
+
"tests": tests,
|
|
221
|
+
"skips": skips,
|
|
222
|
+
"profile": profile,
|
|
223
|
+
"configfile": configfile,
|
|
224
|
+
"baseline": baseline,
|
|
225
|
+
"ignore_nosec": ignore_nosec,
|
|
226
|
+
"aggregate": aggregate,
|
|
227
|
+
"verbose": verbose,
|
|
228
|
+
"quiet": quiet,
|
|
229
|
+
}
|
|
230
|
+
# Remove None values
|
|
231
|
+
options = {k: v for k, v in options.items() if v is not None}
|
|
232
|
+
super().set_options(**options, **kwargs)
|
|
233
|
+
|
|
234
|
+
def _build_check_command(
|
|
235
|
+
self,
|
|
236
|
+
files: list[str],
|
|
237
|
+
) -> list[str]:
|
|
238
|
+
"""Build the bandit check command.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
files: list[str]: List of files to check.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
list[str]: List of command arguments.
|
|
245
|
+
"""
|
|
246
|
+
# Prefer system bandit, then `uvx bandit`, then `uv run bandit`.
|
|
247
|
+
if shutil.which("bandit"):
|
|
248
|
+
exec_cmd: list[str] = ["bandit"]
|
|
249
|
+
elif shutil.which("uvx"):
|
|
250
|
+
exec_cmd = ["uvx", "bandit"]
|
|
251
|
+
else:
|
|
252
|
+
exec_cmd = self._get_executable_command(tool_name="bandit")
|
|
253
|
+
|
|
254
|
+
cmd: list[str] = exec_cmd + ["-r"]
|
|
255
|
+
|
|
256
|
+
# Add configuration options
|
|
257
|
+
if self.options.get("severity"):
|
|
258
|
+
severity = self.options["severity"]
|
|
259
|
+
if severity == "LOW":
|
|
260
|
+
cmd.append("-l")
|
|
261
|
+
elif severity == "MEDIUM":
|
|
262
|
+
cmd.extend(["-ll"])
|
|
263
|
+
elif severity == "HIGH":
|
|
264
|
+
cmd.extend(["-lll"])
|
|
265
|
+
|
|
266
|
+
if self.options.get("confidence"):
|
|
267
|
+
confidence = self.options["confidence"]
|
|
268
|
+
if confidence == "LOW":
|
|
269
|
+
cmd.append("-i")
|
|
270
|
+
elif confidence == "MEDIUM":
|
|
271
|
+
cmd.extend(["-ii"])
|
|
272
|
+
elif confidence == "HIGH":
|
|
273
|
+
cmd.extend(["-iii"])
|
|
274
|
+
|
|
275
|
+
if self.options.get("tests"):
|
|
276
|
+
cmd.extend(["-t", self.options["tests"]])
|
|
277
|
+
|
|
278
|
+
if self.options.get("skips"):
|
|
279
|
+
cmd.extend(["-s", self.options["skips"]])
|
|
280
|
+
|
|
281
|
+
if self.options.get("profile"):
|
|
282
|
+
cmd.extend(["-p", self.options["profile"]])
|
|
283
|
+
|
|
284
|
+
if self.options.get("configfile"):
|
|
285
|
+
cmd.extend(["-c", self.options["configfile"]])
|
|
286
|
+
|
|
287
|
+
if self.options.get("baseline"):
|
|
288
|
+
cmd.extend(["-b", self.options["baseline"]])
|
|
289
|
+
|
|
290
|
+
if self.options.get("ignore_nosec"):
|
|
291
|
+
cmd.append("--ignore-nosec")
|
|
292
|
+
|
|
293
|
+
if self.options.get("aggregate"):
|
|
294
|
+
cmd.extend(["-a", self.options["aggregate"]])
|
|
295
|
+
|
|
296
|
+
if self.options.get("verbose"):
|
|
297
|
+
cmd.append("-v")
|
|
298
|
+
|
|
299
|
+
if self.options.get("quiet"):
|
|
300
|
+
cmd.append("-q")
|
|
301
|
+
|
|
302
|
+
# Output format
|
|
303
|
+
cmd.extend(["-f", BANDIT_OUTPUT_FORMAT])
|
|
304
|
+
|
|
305
|
+
# Add quiet flag (once) to suppress log messages that interfere with JSON
|
|
306
|
+
# parsing. Guard against duplicates when quiet=True already added it.
|
|
307
|
+
if "-q" not in cmd:
|
|
308
|
+
cmd.append("-q")
|
|
309
|
+
|
|
310
|
+
# Add files
|
|
311
|
+
cmd.extend(files)
|
|
312
|
+
|
|
313
|
+
return cmd
|
|
314
|
+
|
|
315
|
+
def check(
|
|
316
|
+
self,
|
|
317
|
+
paths: list[str],
|
|
318
|
+
) -> ToolResult:
|
|
319
|
+
"""Check files with Bandit for security issues.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
paths: list[str]: List of file or directory paths to check.
|
|
323
|
+
|
|
324
|
+
Returns:
|
|
325
|
+
ToolResult: ToolResult instance.
|
|
326
|
+
|
|
327
|
+
Raises:
|
|
328
|
+
subprocess.TimeoutExpired: If the subprocess exceeds the timeout.
|
|
329
|
+
"""
|
|
330
|
+
self._validate_paths(paths=paths)
|
|
331
|
+
if not paths:
|
|
332
|
+
return ToolResult(
|
|
333
|
+
name=self.name,
|
|
334
|
+
success=True,
|
|
335
|
+
output="No files to check.",
|
|
336
|
+
issues_count=0,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
# Use shared utility for file discovery
|
|
340
|
+
python_files: list[str] = walk_files_with_excludes(
|
|
341
|
+
paths=paths,
|
|
342
|
+
file_patterns=self.config.file_patterns,
|
|
343
|
+
exclude_patterns=self.exclude_patterns,
|
|
344
|
+
include_venv=self.include_venv,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
if not python_files:
|
|
348
|
+
return ToolResult(
|
|
349
|
+
name=self.name,
|
|
350
|
+
success=True,
|
|
351
|
+
output="No Python files found to check.",
|
|
352
|
+
issues_count=0,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
logger.debug(f"Files to check: {python_files}")
|
|
356
|
+
|
|
357
|
+
# Ensure Bandit discovers the correct configuration by setting the
|
|
358
|
+
# working directory to the common parent of the target files.
|
|
359
|
+
cwd: str | None = self.get_cwd(paths=python_files)
|
|
360
|
+
rel_files: list[str] = [
|
|
361
|
+
os.path.relpath(f, cwd) if cwd else f for f in python_files
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
timeout: int = self.options.get("timeout", BANDIT_DEFAULT_TIMEOUT)
|
|
365
|
+
cmd: list[str] = self._build_check_command(files=rel_files)
|
|
366
|
+
|
|
367
|
+
output: str
|
|
368
|
+
# Run Bandit and capture both stdout and stderr; Bandit may emit logs
|
|
369
|
+
# and JSON to different streams depending on version/settings.
|
|
370
|
+
try:
|
|
371
|
+
result = subprocess.run(
|
|
372
|
+
cmd,
|
|
373
|
+
capture_output=True,
|
|
374
|
+
text=True,
|
|
375
|
+
timeout=timeout,
|
|
376
|
+
cwd=cwd,
|
|
377
|
+
) # nosec B603 - cmd args list; no shell
|
|
378
|
+
# Combine streams for robust JSON extraction
|
|
379
|
+
stdout_text: str = result.stdout or ""
|
|
380
|
+
stderr_text: str = result.stderr or ""
|
|
381
|
+
output = (stdout_text + "\n" + stderr_text).strip()
|
|
382
|
+
rc: int = result.returncode
|
|
383
|
+
except subprocess.TimeoutExpired:
|
|
384
|
+
raise
|
|
385
|
+
except Exception as e:
|
|
386
|
+
logger.error(f"Failed to run Bandit: {e}")
|
|
387
|
+
output = ""
|
|
388
|
+
|
|
389
|
+
# Parse the JSON output
|
|
390
|
+
try:
|
|
391
|
+
# If command failed and no obvious JSON present, surface error cleanly
|
|
392
|
+
if (
|
|
393
|
+
("{" not in output or "}" not in output)
|
|
394
|
+
and "rc" in locals()
|
|
395
|
+
and rc != 0
|
|
396
|
+
):
|
|
397
|
+
return ToolResult(
|
|
398
|
+
name=self.name,
|
|
399
|
+
success=False,
|
|
400
|
+
output=output,
|
|
401
|
+
issues_count=0,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Attempt robust JSON extraction from mixed output
|
|
405
|
+
bandit_data = _extract_bandit_json(raw_text=output)
|
|
406
|
+
issues = parse_bandit_output(bandit_data)
|
|
407
|
+
issues_count = len(issues)
|
|
408
|
+
|
|
409
|
+
# Bandit returns 0 if no issues; 1 if issues found (still successful run)
|
|
410
|
+
execution_success = len(bandit_data.get("errors", [])) == 0
|
|
411
|
+
|
|
412
|
+
return ToolResult(
|
|
413
|
+
name=self.name,
|
|
414
|
+
success=execution_success,
|
|
415
|
+
output=None,
|
|
416
|
+
issues_count=issues_count,
|
|
417
|
+
issues=issues,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
except (json.JSONDecodeError, ValueError) as e:
|
|
421
|
+
logger.error(f"Failed to parse bandit output: {e}")
|
|
422
|
+
return ToolResult(
|
|
423
|
+
name=self.name,
|
|
424
|
+
success=False,
|
|
425
|
+
output=(output or f"Failed to parse bandit output: {str(e)}"),
|
|
426
|
+
issues_count=0,
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
def fix(
|
|
430
|
+
self,
|
|
431
|
+
paths: list[str],
|
|
432
|
+
) -> ToolResult:
|
|
433
|
+
"""Fix issues in files with Bandit.
|
|
434
|
+
|
|
435
|
+
Note: Bandit does not support auto-fixing of security issues.
|
|
436
|
+
This method raises NotImplementedError.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
paths: list[str]: List of file or directory paths to fix.
|
|
440
|
+
|
|
441
|
+
Raises:
|
|
442
|
+
NotImplementedError: Always raised since Bandit doesn't support fixing.
|
|
443
|
+
"""
|
|
444
|
+
# Bandit cannot auto-fix issues; explicitly signal this.
|
|
445
|
+
raise NotImplementedError("Bandit does not support auto-fixing.")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Darglint docstring linter integration."""
|
|
2
2
|
|
|
3
|
-
import subprocess
|
|
3
|
+
import subprocess # nosec B404 - vetted use via BaseTool._run_subprocess
|
|
4
4
|
from dataclasses import dataclass, field
|
|
5
5
|
|
|
6
6
|
from loguru import logger
|
|
@@ -16,7 +16,7 @@ from lintro.tools.core.tool_base import BaseTool
|
|
|
16
16
|
from lintro.utils.tool_utils import walk_files_with_excludes
|
|
17
17
|
|
|
18
18
|
# Constants for Darglint configuration
|
|
19
|
-
DARGLINT_DEFAULT_TIMEOUT: int =
|
|
19
|
+
DARGLINT_DEFAULT_TIMEOUT: int = 30
|
|
20
20
|
DARGLINT_DEFAULT_PRIORITY: int = 45
|
|
21
21
|
DARGLINT_FILE_PATTERNS: list[str] = ["*.py"]
|
|
22
22
|
DARGLINT_STRICTNESS_LEVELS: tuple[str, ...] = tuple(
|
lintro/tools/tool_enum.py
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from enum import Enum
|
|
4
4
|
|
|
5
|
+
from lintro.tools.implementations.tool_actionlint import ActionlintTool
|
|
6
|
+
from lintro.tools.implementations.tool_bandit import BanditTool
|
|
5
7
|
from lintro.tools.implementations.tool_darglint import DarglintTool
|
|
6
8
|
from lintro.tools.implementations.tool_hadolint import HadolintTool
|
|
7
9
|
from lintro.tools.implementations.tool_prettier import PrettierTool
|
|
@@ -15,3 +17,5 @@ class ToolEnum(Enum):
|
|
|
15
17
|
PRETTIER = PrettierTool
|
|
16
18
|
RUFF = RuffTool
|
|
17
19
|
YAMLLINT = YamllintTool
|
|
20
|
+
ACTIONLINT = ActionlintTool
|
|
21
|
+
BANDIT = BanditTool
|
lintro/utils/console_logger.py
CHANGED
|
@@ -225,6 +225,7 @@ class SimpleLintroLogger:
|
|
|
225
225
|
issues_count: int,
|
|
226
226
|
raw_output_for_meta: str | None = None,
|
|
227
227
|
action: str = "check",
|
|
228
|
+
success: bool | None = None,
|
|
228
229
|
) -> None:
|
|
229
230
|
"""Print the result for a tool.
|
|
230
231
|
|
|
@@ -235,6 +236,9 @@ class SimpleLintroLogger:
|
|
|
235
236
|
raw_output_for_meta: str | None: Raw tool output used to extract
|
|
236
237
|
fixable/remaining hints when available.
|
|
237
238
|
action: str: The action being performed ("check" or "fmt").
|
|
239
|
+
success: bool | None: Whether the tool run succeeded. When False,
|
|
240
|
+
the result is treated as a failure even if no issues were
|
|
241
|
+
counted (e.g., parse or runtime errors).
|
|
238
242
|
"""
|
|
239
243
|
if output and output.strip():
|
|
240
244
|
# Display the output (either raw or formatted, depending on what was passed)
|
|
@@ -265,8 +269,11 @@ class SimpleLintroLogger:
|
|
|
265
269
|
)
|
|
266
270
|
return
|
|
267
271
|
|
|
272
|
+
# If the tool reported a failure (e.g., parse error), do not claim pass
|
|
273
|
+
if success is False:
|
|
274
|
+
self.console_output(text="✗ Tool execution failed", color="red")
|
|
268
275
|
# Check if the output indicates no files were processed
|
|
269
|
-
|
|
276
|
+
elif output and any(
|
|
270
277
|
(msg in output for msg in ["No files to", "No Python files found to"]),
|
|
271
278
|
):
|
|
272
279
|
self.console_output(
|
|
@@ -448,13 +455,22 @@ class SimpleLintroLogger:
|
|
|
448
455
|
f"{total_remaining} remaining",
|
|
449
456
|
)
|
|
450
457
|
else:
|
|
451
|
-
# For check commands, use total issues
|
|
458
|
+
# For check commands, use total issues; treat any tool failure as failure
|
|
452
459
|
total_issues: int = sum(
|
|
453
460
|
(getattr(result, "issues_count", 0) for result in tool_results),
|
|
454
461
|
)
|
|
462
|
+
any_failed: bool = any(
|
|
463
|
+
not getattr(result, "success", True) for result in tool_results
|
|
464
|
+
)
|
|
465
|
+
total_for_art: int = (
|
|
466
|
+
total_issues if not any_failed else max(1, total_issues)
|
|
467
|
+
)
|
|
455
468
|
# Show ASCII art as the last item; no status text after art
|
|
456
|
-
self._print_ascii_art(total_issues=
|
|
457
|
-
logger.debug(
|
|
469
|
+
self._print_ascii_art(total_issues=total_for_art)
|
|
470
|
+
logger.debug(
|
|
471
|
+
f"{action} completed with {total_issues} total issues"
|
|
472
|
+
+ (" and failures" if any_failed else "")
|
|
473
|
+
)
|
|
458
474
|
|
|
459
475
|
def _print_summary_table(
|
|
460
476
|
self,
|
|
@@ -557,7 +573,7 @@ class SimpleLintroLogger:
|
|
|
557
573
|
else: # check
|
|
558
574
|
status_display = (
|
|
559
575
|
click.style("✅ PASS", fg="green", bold=True)
|
|
560
|
-
if issues_count == 0
|
|
576
|
+
if (success and issues_count == 0)
|
|
561
577
|
else click.style("❌ FAIL", fg="red", bold=True)
|
|
562
578
|
)
|
|
563
579
|
# Check if files were excluded
|
lintro/utils/formatting.py
CHANGED
|
@@ -4,7 +4,7 @@ Includes helpers to read multi-section ASCII art files and normalize
|
|
|
4
4
|
ASCII blocks to a fixed size (width/height) while preserving shape.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import
|
|
7
|
+
import secrets
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
|
|
10
10
|
|
|
@@ -43,7 +43,9 @@ def read_ascii_art(filename: str) -> list[str]:
|
|
|
43
43
|
|
|
44
44
|
# Return a random section if there are multiple, otherwise return all lines
|
|
45
45
|
if sections:
|
|
46
|
-
|
|
46
|
+
# Use ``secrets.choice`` to avoid Bandit B311; cryptographic
|
|
47
|
+
# strength is not required here, but this silences the warning.
|
|
48
|
+
return secrets.choice(sections)
|
|
47
49
|
return lines
|
|
48
50
|
except (FileNotFoundError, OSError):
|
|
49
51
|
# Return empty list if file not found or can't be read
|
lintro/utils/tool_executor.py
CHANGED
|
@@ -266,8 +266,8 @@ def run_lint_tools_simple(
|
|
|
266
266
|
if cfg:
|
|
267
267
|
try:
|
|
268
268
|
tool.set_options(**cfg)
|
|
269
|
-
except Exception:
|
|
270
|
-
|
|
269
|
+
except Exception as e:
|
|
270
|
+
logger.debug(f"Ignoring invalid config for {tool_name}: {e}")
|
|
271
271
|
# 2) CLI --tool-options overrides config file
|
|
272
272
|
if tool_name in tool_option_dict:
|
|
273
273
|
tool.set_options(**tool_option_dict[tool_name])
|
|
@@ -343,6 +343,7 @@ def run_lint_tools_simple(
|
|
|
343
343
|
issues_count=issues_count,
|
|
344
344
|
raw_output_for_meta=output,
|
|
345
345
|
action=action,
|
|
346
|
+
success=getattr(result, "success", None),
|
|
346
347
|
)
|
|
347
348
|
|
|
348
349
|
# Store result
|
|
@@ -422,7 +423,8 @@ def run_lint_tools_simple(
|
|
|
422
423
|
logger.save_console_log()
|
|
423
424
|
logger.debug("Saved all output files")
|
|
424
425
|
except Exception as e:
|
|
425
|
-
|
|
426
|
+
# Log at debug to avoid failing the run for non-critical persistence.
|
|
427
|
+
logger.debug(f"Error saving outputs: {e}")
|
|
426
428
|
|
|
427
429
|
# Return appropriate exit code
|
|
428
430
|
if action == "fmt":
|
|
@@ -430,14 +432,17 @@ def run_lint_tools_simple(
|
|
|
430
432
|
# (even if there are remaining unfixable issues)
|
|
431
433
|
return DEFAULT_EXIT_CODE_SUCCESS
|
|
432
434
|
else: # check
|
|
433
|
-
# Check operations fail if issues are found
|
|
435
|
+
# Check operations fail if issues are found OR any tool reported failure
|
|
436
|
+
any_failed: bool = any(
|
|
437
|
+
not getattr(result, "success", True) for result in all_results
|
|
438
|
+
)
|
|
434
439
|
return (
|
|
435
440
|
DEFAULT_EXIT_CODE_SUCCESS
|
|
436
|
-
if total_issues == 0
|
|
441
|
+
if (total_issues == 0 and not any_failed)
|
|
437
442
|
else DEFAULT_EXIT_CODE_FAILURE
|
|
438
443
|
)
|
|
439
444
|
|
|
440
445
|
except Exception as e:
|
|
441
|
-
logger.
|
|
446
|
+
logger.debug(f"Unexpected error: {e}")
|
|
442
447
|
logger.save_console_log()
|
|
443
448
|
return DEFAULT_EXIT_CODE_FAILURE
|
lintro/utils/tool_utils.py
CHANGED
|
@@ -11,6 +11,14 @@ try:
|
|
|
11
11
|
except ImportError:
|
|
12
12
|
TABULATE_AVAILABLE = False
|
|
13
13
|
|
|
14
|
+
from lintro.formatters.tools.actionlint_formatter import (
|
|
15
|
+
ActionlintTableDescriptor,
|
|
16
|
+
format_actionlint_issues,
|
|
17
|
+
)
|
|
18
|
+
from lintro.formatters.tools.bandit_formatter import (
|
|
19
|
+
BanditTableDescriptor,
|
|
20
|
+
format_bandit_issues,
|
|
21
|
+
)
|
|
14
22
|
from lintro.formatters.tools.darglint_formatter import (
|
|
15
23
|
DarglintTableDescriptor,
|
|
16
24
|
format_darglint_issues,
|
|
@@ -31,6 +39,7 @@ from lintro.formatters.tools.yamllint_formatter import (
|
|
|
31
39
|
YamllintTableDescriptor,
|
|
32
40
|
format_yamllint_issues,
|
|
33
41
|
)
|
|
42
|
+
from lintro.parsers.bandit.bandit_parser import parse_bandit_output
|
|
34
43
|
from lintro.parsers.darglint.darglint_parser import parse_darglint_output
|
|
35
44
|
from lintro.parsers.hadolint.hadolint_parser import parse_hadolint_output
|
|
36
45
|
from lintro.parsers.prettier.prettier_issue import PrettierIssue
|
|
@@ -46,6 +55,8 @@ TOOL_TABLE_FORMATTERS: dict[str, tuple] = {
|
|
|
46
55
|
"prettier": (PrettierTableDescriptor(), format_prettier_issues),
|
|
47
56
|
"ruff": (RuffTableDescriptor(), format_ruff_issues),
|
|
48
57
|
"yamllint": (YamllintTableDescriptor(), format_yamllint_issues),
|
|
58
|
+
"actionlint": (ActionlintTableDescriptor(), format_actionlint_issues),
|
|
59
|
+
"bandit": (BanditTableDescriptor(), format_bandit_issues),
|
|
49
60
|
}
|
|
50
61
|
VENV_PATTERNS: list[str] = [
|
|
51
62
|
"venv",
|
|
@@ -349,6 +360,14 @@ def format_tool_output(
|
|
|
349
360
|
parsed_issues = parse_hadolint_output(output=output)
|
|
350
361
|
elif tool_name == "yamllint":
|
|
351
362
|
parsed_issues = parse_yamllint_output(output=output)
|
|
363
|
+
elif tool_name == "bandit":
|
|
364
|
+
# Bandit emits JSON; try parsing when raw output is provided
|
|
365
|
+
try:
|
|
366
|
+
parsed_issues = parse_bandit_output(
|
|
367
|
+
bandit_data=__import__("json").loads(output)
|
|
368
|
+
)
|
|
369
|
+
except Exception:
|
|
370
|
+
parsed_issues = []
|
|
352
371
|
|
|
353
372
|
if parsed_issues and tool_name in TOOL_TABLE_FORMATTERS:
|
|
354
373
|
_, formatter_func = TOOL_TABLE_FORMATTERS[tool_name]
|