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.

@@ -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 = 10
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(
@@ -1,6 +1,6 @@
1
1
  """Hadolint Dockerfile linter integration."""
2
2
 
3
- import subprocess
3
+ import subprocess # nosec B404 - used safely with shell disabled
4
4
  from dataclasses import dataclass, field
5
5
 
6
6
  from loguru import logger
@@ -1,7 +1,7 @@
1
1
  """Yamllint YAML linter integration."""
2
2
 
3
3
  import os
4
- import subprocess
4
+ import subprocess # nosec B404 - used safely with shell disabled
5
5
  from dataclasses import dataclass, field
6
6
 
7
7
  from loguru import logger
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
@@ -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
- if output and any(
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=total_issues)
457
- logger.debug(f"{action} completed with {total_issues} total issues")
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
@@ -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 random
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
- return random.choice(sections)
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
@@ -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
- pass
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
- logger.error(f"Error saving outputs: {e}")
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.error(f"Unexpected error: {e}")
446
+ logger.debug(f"Unexpected error: {e}")
442
447
  logger.save_console_log()
443
448
  return DEFAULT_EXIT_CODE_FAILURE
@@ -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]