shellsafe 0.3.2__tar.gz → 0.3.4__tar.gz

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 (58) hide show
  1. {shellsafe-0.3.2 → shellsafe-0.3.4}/CHANGELOG.md +27 -0
  2. {shellsafe-0.3.2 → shellsafe-0.3.4}/PKG-INFO +14 -1
  3. {shellsafe-0.3.2 → shellsafe-0.3.4}/README.md +13 -0
  4. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/__init__.py +3 -2
  5. shellsafe-0.3.4/src/shellsafe/_version.py +1 -0
  6. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/audit/scanner.py +208 -25
  7. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/cli.py +16 -3
  8. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/execute.py +1 -2
  9. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/platforms.py +1 -1
  10. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/fixtures/audit/au001_sample.py +2 -2
  11. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/fixtures/audit/au002_sample.py +2 -2
  12. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/fixtures/audit/au003_sample.py +2 -2
  13. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/fixtures/audit/au004_sample.py +2 -2
  14. shellsafe-0.3.4/tests/fixtures/audit/suppress_sample.py +26 -0
  15. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/integration/test_cli_audit.py +101 -0
  16. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/unit/test_audit.py +84 -0
  17. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/unit/test_public_surface.py +2 -1
  18. shellsafe-0.3.2/src/shellsafe/_version.py +0 -1
  19. {shellsafe-0.3.2 → shellsafe-0.3.4}/.github/workflows/ci.yml +0 -0
  20. {shellsafe-0.3.2 → shellsafe-0.3.4}/.github/workflows/release.yml +0 -0
  21. {shellsafe-0.3.2 → shellsafe-0.3.4}/.gitignore +0 -0
  22. {shellsafe-0.3.2 → shellsafe-0.3.4}/CONTRIBUTING.md +0 -0
  23. {shellsafe-0.3.2 → shellsafe-0.3.4}/LICENSE +0 -0
  24. {shellsafe-0.3.2 → shellsafe-0.3.4}/examples/demo.py +0 -0
  25. {shellsafe-0.3.2 → shellsafe-0.3.4}/pyproject.toml +0 -0
  26. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/__main__.py +0 -0
  27. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/audit/__init__.py +0 -0
  28. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/errors.py +0 -0
  29. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/exitcodes.py +0 -0
  30. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/py.typed +0 -0
  31. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/raw.py +0 -0
  32. {shellsafe-0.3.2 → shellsafe-0.3.4}/src/shellsafe/render.py +0 -0
  33. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/golden/.gitkeep +0 -0
  34. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/integration/.gitkeep +0 -0
  35. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/integration/test_exec_posix.py +0 -0
  36. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/README.md +0 -0
  37. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_01_semicolon.txt +0 -0
  38. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_02_substitution.txt +0 -0
  39. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_03_backticks.txt +0 -0
  40. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_04_background_chain.txt +0 -0
  41. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_05_or_chain.txt +0 -0
  42. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
  43. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
  44. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
  45. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
  46. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
  47. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
  48. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_12_newline.txt +0 -0
  49. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
  50. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_14_whitespace.txt +0 -0
  51. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
  52. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/property/.gitkeep +0 -0
  53. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/property/test_invariants.py +0 -0
  54. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/unit/test_cli.py +0 -0
  55. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/unit/test_errors.py +0 -0
  56. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/unit/test_payload_corpus.py +0 -0
  57. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/unit/test_raw.py +0 -0
  58. {shellsafe-0.3.2 → shellsafe-0.3.4}/tests/unit/test_render.py +0 -0
@@ -3,6 +3,33 @@
3
3
  All notable changes to this project are documented here. Format follows
4
4
  Keep a Changelog; versioning follows SemVer.
5
5
 
6
+ ## [0.3.4] - 2026-09-03
7
+
8
+ ### Fixed
9
+
10
+ - Export `plan()` from public surface (`__init__.py`); was defined in
11
+ `execute.py` but not listed in `__all__`, causing `ImportError` on
12
+ `from shellsafe import plan`
13
+
14
+ ## [0.3.3] - 2026-09-01
15
+
16
+ ### Added
17
+
18
+ - Inline suppression: `# shellsafe: ignore AU001 reason: <why>` comments
19
+ - File-level suppression: `# shellsafe: ignore-file AU001` at top of file
20
+ - Multi-rule suppression: `# shellsafe: ignore AU001, AU002 reason: <why>`
21
+ - AU010 meta-warning: flags suppression comments without a reason
22
+ - CLI `--show-ignored` flag to display suppressed findings
23
+ - CLI `--ignore RULE` flag to suppress rules from command line (repeatable)
24
+ - Suppression fixture corpus: inline, file-level, multi-rule, AU010 cases
25
+ - Unit tests for suppression parsing and application
26
+ - Integration tests for CLI suppression flags
27
+
28
+ ### Changed
29
+
30
+ - Terminal reporter shows suppressed count and muted display for ignored findings
31
+ - CLI version string shows "AU001-AU004, suppression"
32
+
6
33
  ## [0.3.2] - 2026-08-30
7
34
 
8
35
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: shellsafe
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Run shell commands safely using Python 3.14 template strings. Values can never turn into commands.
5
5
  Project-URL: Homepage, https://github.com/rahulXs/shellsafe
6
6
  Project-URL: Repository, https://github.com/rahulXs/shellsafe
@@ -131,6 +131,19 @@ Filter by severity:
131
131
  shellsafe audit src/ --severity warning
132
132
  ```
133
133
 
134
+ Suppress known-safe findings inline:
135
+
136
+ ```python
137
+ # shellsafe: ignore AU001 reason: tested, value is constant
138
+ os.system(f"echo {safe_value}")
139
+ ```
140
+
141
+ Suppress from the command line:
142
+
143
+ ```bash
144
+ shellsafe audit src/ --ignore AU004
145
+ ```
146
+
134
147
  ## Limits
135
148
 
136
149
  - Shell features (pipes, redirections) work on Linux and macOS only. Windows
@@ -108,6 +108,19 @@ Filter by severity:
108
108
  shellsafe audit src/ --severity warning
109
109
  ```
110
110
 
111
+ Suppress known-safe findings inline:
112
+
113
+ ```python
114
+ # shellsafe: ignore AU001 reason: tested, value is constant
115
+ os.system(f"echo {safe_value}")
116
+ ```
117
+
118
+ Suppress from the command line:
119
+
120
+ ```bash
121
+ shellsafe audit src/ --ignore AU004
122
+ ```
123
+
111
124
  ## Limits
112
125
 
113
126
  - Shell features (pipes, redirections) work on Linux and macOS only. Windows
@@ -1,11 +1,11 @@
1
1
  """shellsafe: safe shell commands via Python 3.14 template strings.
2
2
 
3
3
  Public surface (stable names, additive only through 1.0):
4
- run, capture, shx, RAW, CaptureResult.
4
+ run, capture, shx, plan, RAW, CaptureResult.
5
5
  """
6
6
 
7
7
  from ._version import __version__
8
- from .execute import CaptureResult, capture, run, shx
8
+ from .execute import CaptureResult, capture, plan, run, shx
9
9
  from .raw import RAW
10
10
 
11
11
  __all__ = [
@@ -13,6 +13,7 @@ __all__ = [
13
13
  "CaptureResult",
14
14
  "__version__",
15
15
  "capture",
16
+ "plan",
16
17
  "run",
17
18
  "shx",
18
19
  ]
@@ -0,0 +1 @@
1
+ __version__ = "0.3.4"
@@ -7,7 +7,6 @@ import ast
7
7
  import json
8
8
  import sys
9
9
  import time
10
- from collections.abc import Callable
11
10
  from dataclasses import dataclass, field
12
11
  from pathlib import Path
13
12
  from typing import Any
@@ -25,7 +24,7 @@ class Finding:
25
24
  lineno: int
26
25
  col: int
27
26
  message: str
28
- evidence: dict[str, Any] = field(default_factory=dict)
27
+ evidence: dict[str, Any] = field(default_factory=dict) # why: heterogeneous value types
29
28
  fix_hint: str = ""
30
29
  ignored: bool = False
31
30
  ignore_reason: str | None = None
@@ -48,7 +47,7 @@ _IMPORT_ALIAS = {
48
47
  }
49
48
 
50
49
 
51
- def _resolve_callee(node: ast.expr, aliases: dict[str, str]) -> str | None:
50
+ def _resolve_callee(node: ast.expr, aliases: dict[str, str]):
52
51
  """Resolve a Call.func to an executor label like 'os.system'."""
53
52
  if (
54
53
  isinstance(node, ast.Attribute)
@@ -98,14 +97,14 @@ def au001(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
98
97
  return None
99
98
 
100
99
 
101
- def _has_shell_true(node: ast.Call) -> bool:
100
+ def _has_shell_true(node: ast.Call):
102
101
  for kw in node.keywords:
103
102
  if kw.arg == "shell" and isinstance(kw.value, ast.Constant) and kw.value.value is True:
104
103
  return True
105
104
  return False
106
105
 
107
106
 
108
- def _is_dynamic_string(node: ast.expr) -> bool:
107
+ def _is_dynamic_string(node: ast.expr):
109
108
  if isinstance(node, ast.JoinedStr):
110
109
  return any(isinstance(v, ast.FormattedValue) for v in node.values)
111
110
  if (
@@ -155,9 +154,9 @@ def au002(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
155
154
  return None
156
155
 
157
156
 
158
- def _track_dynamic_vars(tree: ast.Module) -> set[str]:
157
+ def _track_dynamic_vars(tree: ast.Module):
159
158
  """Find variables assigned dynamic string expressions."""
160
- dynamic: set[str] = {}
159
+ dynamic = set()
161
160
  for node in ast.walk(tree):
162
161
  if not isinstance(node, ast.Assign):
163
162
  continue
@@ -249,7 +248,7 @@ def au003(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
249
248
  return None
250
249
 
251
250
 
252
- def _has_timeout(node: ast.Call) -> bool:
251
+ def _has_timeout(node: ast.Call):
253
252
  return any(kw.arg == "timeout" for kw in node.keywords)
254
253
 
255
254
 
@@ -286,7 +285,7 @@ def au004(node: ast.Call, aliases: dict[str, str], path: str) -> Finding | None:
286
285
  )
287
286
 
288
287
 
289
- RULES: list[tuple[str, Callable[..., Finding | None]]] = [
288
+ RULES = [
290
289
  ("AU001", au001),
291
290
  ("AU002", au002),
292
291
  ("AU003", au003),
@@ -294,8 +293,8 @@ RULES: list[tuple[str, Callable[..., Finding | None]]] = [
294
293
  ]
295
294
 
296
295
 
297
- def _discover(paths: list[str]) -> list[Path]:
298
- files: list[Path] = []
296
+ def _discover(paths: list[str]):
297
+ files = []
299
298
  for raw in paths:
300
299
  p = Path(raw)
301
300
  if p.is_file() and p.suffix == ".py":
@@ -309,7 +308,7 @@ def _discover(paths: list[str]) -> list[Path]:
309
308
  return files
310
309
 
311
310
 
312
- def _import_aliases(tree: ast.Module) -> dict[str, str]:
311
+ def _import_aliases(tree: ast.Module):
313
312
  """Map local names to real module.function for executor imports."""
314
313
  aliases = {}
315
314
  for node in ast.walk(tree):
@@ -325,47 +324,231 @@ def _import_aliases(tree: ast.Module) -> dict[str, str]:
325
324
  return aliases
326
325
 
327
326
 
328
- def scan(paths: list[str]) -> list[Finding]:
329
- """Scan Python files for audit findings."""
330
- findings: list[Finding] = []
327
+ def _parse_suppression_comment(comment: str) -> tuple[list[str], str | None]:
328
+ """Parse a shellsafe suppression comment.
329
+
330
+ Returns (rule_ids, reason_or_None).
331
+ Accepts: # shellsafe: ignore RULE[,RULE...] [reason: text]
332
+ """
333
+ stripped = comment.strip()
334
+ if not stripped.startswith("#"):
335
+ return [], None
336
+ stripped = stripped[1:].strip()
337
+ if not stripped.lower().startswith("shellsafe:"):
338
+ return [], None
339
+ directive = stripped[len("shellsafe:"):].strip()
340
+ if not directive.lower().startswith("ignore "):
341
+ return [], None
342
+ rest = directive[len("ignore "):]
343
+ reason = None
344
+ reason_match = rest.split(" reason:", 1)
345
+ if len(reason_match) == 2:
346
+ rest = reason_match[0]
347
+ reason = reason_match[1].strip() or None
348
+ rule_ids = [r.strip().upper() for r in rest.split(",") if r.strip()]
349
+ return rule_ids, reason
350
+
351
+
352
+ def _parse_suppressions(
353
+ source_lines: list[str],
354
+ ) -> tuple[set[int], dict[int, tuple[set[str], str | None]]]:
355
+ """Parse source lines for suppression directives.
356
+
357
+ Returns:
358
+ file_level_rules: rule ids suppressed file-wide (from ignore-file)
359
+ line_suppressions: line_no -> (set of rule ids, reason)
360
+ """
361
+ file_level_rules = set()
362
+ line_suppressions = {}
363
+
364
+ for i, line in enumerate(source_lines, 1):
365
+ stripped = line.strip()
366
+ if not stripped.startswith("#"):
367
+ continue
368
+ rule_ids, reason = _parse_suppression_comment(stripped)
369
+ if not rule_ids:
370
+ if stripped.lower() == "# shellsafe: ignore-file":
371
+ file_level_rules.add("*")
372
+ elif stripped.lower().startswith("# shellsafe: ignore-file "):
373
+ for r in stripped.split(None, 4)[-1].split(","):
374
+ r = r.strip().upper()
375
+ if r:
376
+ file_level_rules.add(r)
377
+ continue
378
+ line_suppressions[i] = (set(rule_ids), reason)
379
+
380
+ return file_level_rules, line_suppressions
381
+
382
+
383
+ def _find_suppressed_line(
384
+ target_line: int,
385
+ line_suppressions: dict[int, tuple[set[str], str | None]],
386
+ ) -> tuple[set[str], str | None] | None:
387
+ """Find which rules suppress findings at target_line.
388
+
389
+ Checks previous non-blank, non-comment line, then target line itself.
390
+ """
391
+ for candidate in (target_line - 1, target_line):
392
+ if candidate in line_suppressions:
393
+ return line_suppressions[candidate]
394
+ return None
395
+
396
+
397
+ def _apply_suppression(
398
+ result: Finding,
399
+ ignore: set[str] | None,
400
+ file_rules: set[str],
401
+ line_suppressions: dict[int, tuple[set[str], str | None]],
402
+ ) -> Finding:
403
+ """Apply suppression rules to a finding. Returns new Finding with ignored flag."""
404
+ rule_id = result.rule_id
405
+ suppressed = False
406
+ ignore_reason = None
407
+
408
+ if ignore and rule_id in ignore:
409
+ return Finding(
410
+ rule_id=result.rule_id,
411
+ title=result.title,
412
+ severity=result.severity,
413
+ confidence=result.confidence,
414
+ path=result.path,
415
+ lineno=result.lineno,
416
+ col=result.col,
417
+ message=result.message,
418
+ evidence=result.evidence,
419
+ fix_hint=result.fix_hint,
420
+ ignored=True,
421
+ ignore_reason="CLI --ignore",
422
+ )
423
+ if rule_id in file_rules or "*" in file_rules:
424
+ suppressed = True
425
+ ignore_reason = "file-level suppression"
426
+ else:
427
+ match = _find_suppressed_line(result.lineno, line_suppressions)
428
+ if match:
429
+ rules, reason = match
430
+ if rule_id in rules:
431
+ suppressed = True
432
+ ignore_reason = reason
433
+
434
+ if not suppressed:
435
+ return result
436
+ return Finding(
437
+ rule_id=result.rule_id,
438
+ title=result.title,
439
+ severity=result.severity,
440
+ confidence=result.confidence,
441
+ path=result.path,
442
+ lineno=result.lineno,
443
+ col=result.col,
444
+ message=result.message,
445
+ evidence=result.evidence,
446
+ fix_hint=result.fix_hint,
447
+ ignored=True,
448
+ ignore_reason=ignore_reason,
449
+ )
450
+
451
+
452
+ def _emit_au010(
453
+ line_suppressions: dict[int, tuple[set[str], str | None]],
454
+ rel: str,
455
+ ) -> list[Finding]:
456
+ """Emit AU010 warnings for suppression comments without reasons."""
457
+ results = []
458
+ for suppressed_rules, reason in line_suppressions.values():
459
+ if not reason and suppressed_rules:
460
+ first_line = next(
461
+ (ln for ln, (r, _) in line_suppressions.items() if r == suppressed_rules),
462
+ 1,
463
+ )
464
+ results.append(
465
+ Finding(
466
+ rule_id="AU010",
467
+ title="suppression comment without reason",
468
+ severity="info",
469
+ confidence=1.0,
470
+ path=rel,
471
+ lineno=first_line,
472
+ col=0,
473
+ message=(
474
+ f"Suppression for {', '.join(sorted(suppressed_rules))} "
475
+ f"has no reason. Add: reason: <why>"
476
+ ),
477
+ evidence={"rules": sorted(suppressed_rules)},
478
+ )
479
+ )
480
+ return results
481
+
482
+
483
+ def scan(paths: list[str], ignore: set[str] | None = None) -> list[Finding]:
484
+ """Scan Python files for audit findings.
485
+
486
+ Args:
487
+ paths: files or directories to scan.
488
+ ignore: rule ids to suppress from CLI --ignore flag.
489
+ """
490
+ findings = []
331
491
  for path in _discover(paths):
332
492
  try:
333
- tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
493
+ source = path.read_text(encoding="utf-8")
494
+ tree = ast.parse(source, filename=str(path))
334
495
  except (SyntaxError, UnicodeDecodeError):
335
496
  continue
336
497
  aliases = _import_aliases(tree)
498
+ source_lines = source.splitlines()
499
+ file_rules, line_suppressions = _parse_suppressions(source_lines)
337
500
  rel = str(path)
338
501
  for node in ast.walk(tree):
339
502
  if isinstance(node, ast.Call):
340
503
  for _, rule in RULES:
341
504
  result = rule(node, aliases, rel)
342
505
  if result is not None:
343
- findings.append(result)
506
+ findings.append(
507
+ _apply_suppression(result, ignore, file_rules, line_suppressions)
508
+ )
509
+ findings.extend(_emit_au010(line_suppressions, rel))
510
+
344
511
  findings.sort(key=lambda f: (f.severity != "error", f.path, f.lineno))
345
512
  return findings
346
513
 
347
514
 
348
- _COLORS = {"error": "\033[31m", "warning": "\033[33m", "info": "\033[36m", "reset": "\033[0m"}
515
+ _COLORS = {
516
+ "error": "\033[31m",
517
+ "warning": "\033[33m",
518
+ "info": "\033[36m",
519
+ "ignored": "\033[90m",
520
+ "reset": "\033[0m",
521
+ }
349
522
 
350
523
 
351
- def _color(text: str, severity: str) -> str:
524
+ def _color(text: str, severity: str):
352
525
  c = _COLORS.get(severity, "")
353
526
  return f"{c}{text}{_COLORS['reset']}" if c else text
354
527
 
355
528
 
356
- def report_terminal(findings: list[Finding]) -> str:
529
+ def report_terminal(findings: list[Finding], show_ignored: bool = False) -> str:
357
530
  """Render findings as a terminal table."""
358
531
  if not findings:
359
532
  return "no findings"
360
533
  use_color = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
534
+ visible = findings if show_ignored else [f for f in findings if not f.ignored]
535
+ if not visible:
536
+ return "no findings"
537
+ suppressed = sum(1 for f in findings if f.ignored)
361
538
  lines = [
362
539
  f"{'SEV':<9} {'RULE':<7} {'FILE':<40} {'LINE':>5} MESSAGE",
363
540
  "-" * 90,
364
541
  ]
365
- for f in findings:
366
- sev = _color(f.severity.upper(), f.severity) if use_color else f.severity.upper()
542
+ for f in visible:
543
+ if f.ignored:
544
+ sev = _color("IGNORED", "ignored") if use_color else "IGNORED"
545
+ else:
546
+ sev = _color(f.severity.upper(), f.severity) if use_color else f.severity.upper()
367
547
  path = f.path if len(f.path) <= 40 else "..." + f.path[-37:]
368
- lines.append(f"{sev:<18} {f.rule_id:<7} {path:<40} {f.lineno:>5} {f.message}")
548
+ reason = f" [{f.ignore_reason}]" if f.ignore_reason else ""
549
+ lines.append(f"{sev:<18} {f.rule_id:<7} {path:<40} {f.lineno:>5} {f.message}{reason}")
550
+ if suppressed and not show_ignored:
551
+ lines.append(f"\n {suppressed} finding(s) suppressed (use --show-ignored to display)")
369
552
  return "\n".join(lines)
370
553
 
371
554
 
@@ -374,10 +557,10 @@ def report_json(report: dict[str, object]) -> str:
374
557
  return json.dumps(report, indent=2)
375
558
 
376
559
 
377
- def scan_report(paths: list[str]) -> dict[str, object]:
560
+ def scan_report(paths: list[str], ignore: set[str] | None = None) -> dict[str, object]:
378
561
  """Scan and return a report dict matching schema v1."""
379
562
  start = time.monotonic()
380
- findings = scan(paths)
563
+ findings = scan(paths, ignore=ignore)
381
564
  duration_ms = round((time.monotonic() - start) * 1000, 1)
382
565
 
383
566
  errors = sum(1 for f in findings if f.severity == "error" and not f.ignored)
@@ -24,6 +24,18 @@ def _build_parser() -> argparse.ArgumentParser:
24
24
  default="warning",
25
25
  help="minimum severity to report (default: warning)",
26
26
  )
27
+ audit.add_argument(
28
+ "--show-ignored",
29
+ action="store_true",
30
+ help="show suppressed findings in output",
31
+ )
32
+ audit.add_argument(
33
+ "--ignore",
34
+ action="append",
35
+ default=[],
36
+ metavar="RULE",
37
+ help="suppress a rule (repeatable, e.g. --ignore AU001 --ignore AU004)",
38
+ )
27
39
 
28
40
  sub.add_parser("version", help="detailed version and capability matrix")
29
41
  return parser
@@ -32,7 +44,8 @@ def _build_parser() -> argparse.ArgumentParser:
32
44
  def _run_audit(args: argparse.Namespace) -> int:
33
45
  from .audit.scanner import Finding, report_json, report_terminal, scan_report
34
46
 
35
- report = scan_report(args.paths)
47
+ ignore = set(args.ignore) if args.ignore else None
48
+ report = scan_report(args.paths, ignore=ignore)
36
49
  findings = report.get("findings", [])
37
50
 
38
51
  min_sev = _SEVERITY_ORDER.get(args.severity, 1)
@@ -44,7 +57,7 @@ def _run_audit(args: argparse.Namespace) -> int:
44
57
  if args.json_output:
45
58
  print(report_json(report))
46
59
  else:
47
- print(report_terminal([Finding(**f) for f in filtered]))
60
+ print(report_terminal([Finding(**f) for f in filtered], show_ignored=args.show_ignored))
48
61
 
49
62
  summary = report.get("summary", {})
50
63
  return exitcodes.FINDINGS if summary.get("errors", 0) > 0 else exitcodes.OK
@@ -62,7 +75,7 @@ def main(argv: list[str] | None = None) -> int:
62
75
  print(f"shellsafe {__version__} . python {py} . {sys.platform}")
63
76
  print("argv-mode: available")
64
77
  print("shell-mode: available (posix)")
65
- print("audit: available (AU001-AU004)")
78
+ print("audit: available (AU001-AU004, suppression)")
66
79
  return exitcodes.OK
67
80
  if args.command == "audit":
68
81
  return _run_audit(args)
@@ -3,7 +3,6 @@
3
3
  import subprocess
4
4
  import sys
5
5
  from dataclasses import dataclass
6
- from typing import Any
7
6
 
8
7
  from .errors import ArgvOnlyError, ShellSafeError
9
8
  from .raw import Raw # noqa: F401 (re-exported through the package root)
@@ -48,7 +47,7 @@ def plan(template: object) -> ExecutionPlan:
48
47
  return render_plan(template)
49
48
 
50
49
 
51
- def _pass_through(kwargs: dict[str, object]) -> dict[str, Any]:
50
+ def _pass_through(kwargs: dict[str, object]):
52
51
  return {k: v for k, v in kwargs.items() if k in _ALLOWED_KWARGS}
53
52
 
54
53
 
@@ -7,7 +7,7 @@ METACHARACTERS = frozenset("|&;()<>$`\"'*?!#\\\n\r\t")
7
7
  IS_WINDOWS = sys.platform.startswith("win")
8
8
 
9
9
 
10
- def route_for(static_text: str) -> str:
10
+ def route_for(static_text: str):
11
11
  """Return "argv" or "shell" for the given static template text.
12
12
 
13
13
  Raises UnsupportedPlatformError on Windows when shell features are requested.
@@ -5,7 +5,7 @@ import subprocess
5
5
  from os import system
6
6
  from subprocess import run as subprocess_run
7
7
 
8
- # --- positive cases (should produce AU001 findings) ---
8
+ # positive cases (should produce AU001 findings)
9
9
 
10
10
  os.system(f"echo {user_input}")
11
11
 
@@ -20,7 +20,7 @@ subprocess_run(f"grep {pattern} {file}")
20
20
  subprocess.getoutput(f"echo {user_input}")
21
21
 
22
22
 
23
- # --- safe cases (should NOT produce findings) ---
23
+ # safe cases (should NOT produce findings)
24
24
 
25
25
  subprocess.run(["echo", user_input])
26
26
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  import subprocess
4
4
 
5
- # --- positive cases (should produce AU002 findings) ---
5
+ # positive cases (should produce AU002 findings)
6
6
 
7
7
  # f-string with shell=True (also caught by AU001)
8
8
  subprocess.run(f"echo {user_input}", shell=True)
@@ -23,7 +23,7 @@ subprocess.run(cmd, shell=True)
23
23
  subprocess.run(config.command, shell=True)
24
24
 
25
25
 
26
- # --- safe cases (should NOT produce AU002 findings) ---
26
+ # safe cases (should NOT produce AU002 findings)
27
27
 
28
28
  # shell=True with list (safe)
29
29
  subprocess.run(["echo", user_input], shell=True)
@@ -3,7 +3,7 @@
3
3
  import os
4
4
  import subprocess
5
5
 
6
- # --- positive cases (should produce AU003 findings) ---
6
+ # positive cases (should produce AU003 findings)
7
7
 
8
8
  # variable passed to executor
9
9
  cmd = "echo " + user_input
@@ -25,7 +25,7 @@ subprocess.run(cmd)
25
25
  subprocess.run("echo " + user_input)
26
26
 
27
27
 
28
- # --- safe cases (should NOT produce AU003 findings) ---
28
+ # safe cases (should NOT produce AU003 findings)
29
29
 
30
30
  # list form (safe)
31
31
  subprocess.run(["echo", user_input])
@@ -3,7 +3,7 @@
3
3
  import subprocess
4
4
  from subprocess import run as subprocess_run
5
5
 
6
- # --- positive cases (should produce AU004 findings) ---
6
+ # positive cases (should produce AU004 findings)
7
7
 
8
8
  subprocess.run(["echo", "hello"])
9
9
 
@@ -16,7 +16,7 @@ subprocess.check_output(["echo", "hello"])
16
16
  subprocess_run(["echo", "hello"])
17
17
 
18
18
 
19
- # --- safe cases (should NOT produce AU004 findings) ---
19
+ # safe cases (should NOT produce AU004 findings)
20
20
 
21
21
  subprocess.run(["echo", "hello"], timeout=10)
22
22
 
@@ -0,0 +1,26 @@
1
+ """Suppression test fixture: inline, file-level, multi-rule, AU010."""
2
+
3
+ import os
4
+ import subprocess
5
+
6
+ # Line-level suppression with reason
7
+
8
+ # shellsafe: ignore AU001 reason: tested, value is constant
9
+ os.system(f"echo {os.listdir('.')}")
10
+
11
+ # shellsafe: ignore AU001, AU002 reason: legacy code, tracked for migration
12
+ subprocess.run(f"echo {os.listdir('.')}", shell=True)
13
+
14
+ # Line-level suppression without reason (AU010 trigger)
15
+
16
+ # shellsafe: ignore AU004
17
+ subprocess.run(["ls"])
18
+
19
+ # No suppression (should still be flagged)
20
+
21
+ os.system(f"echo {os.listdir('.')}")
22
+
23
+ # Safe cases (should never be flagged regardless of suppression)
24
+
25
+ subprocess.run(["ls", "-la"])
26
+ subprocess.run(["echo", "hello"], timeout=5)
@@ -203,3 +203,104 @@ def test_audit_cli_all_rules():
203
203
  assert "AU002" in rule_ids
204
204
  assert "AU003" in rule_ids
205
205
  assert "AU004" in rule_ids
206
+
207
+
208
+ def test_audit_cli_suppress_inline():
209
+ out = subprocess.run(
210
+ [sys.executable, "-m", "shellsafe", "audit", str(FIXTURES / "suppress_sample.py")],
211
+ capture_output=True,
212
+ text=True,
213
+ )
214
+ assert "suppressed" in out.stdout
215
+
216
+
217
+ def test_audit_cli_suppress_show_ignored():
218
+ out = subprocess.run(
219
+ [
220
+ sys.executable,
221
+ "-m",
222
+ "shellsafe",
223
+ "audit",
224
+ str(FIXTURES / "suppress_sample.py"),
225
+ "--show-ignored",
226
+ ],
227
+ capture_output=True,
228
+ text=True,
229
+ )
230
+ assert "IGNORED" in out.stdout
231
+
232
+
233
+ def test_audit_cli_suppress_json():
234
+ out = subprocess.run(
235
+ [
236
+ sys.executable,
237
+ "-m",
238
+ "shellsafe",
239
+ "audit",
240
+ str(FIXTURES / "suppress_sample.py"),
241
+ "--json",
242
+ ],
243
+ capture_output=True,
244
+ text=True,
245
+ )
246
+ report = json.loads(out.stdout)
247
+ ignored = [f for f in report["findings"] if f["ignored"]]
248
+ assert len(ignored) >= 3
249
+ assert report["summary"]["ignored"] >= 3
250
+
251
+
252
+ def test_audit_cli_ignore_flag():
253
+ out = subprocess.run(
254
+ [
255
+ sys.executable,
256
+ "-m",
257
+ "shellsafe",
258
+ "audit",
259
+ str(FIXTURES / "suppress_sample.py"),
260
+ "--ignore",
261
+ "AU001",
262
+ "--show-ignored",
263
+ ],
264
+ capture_output=True,
265
+ text=True,
266
+ )
267
+ assert "CLI --ignore" in out.stdout
268
+
269
+
270
+ def test_audit_cli_ignore_flag_json():
271
+ out = subprocess.run(
272
+ [
273
+ sys.executable,
274
+ "-m",
275
+ "shellsafe",
276
+ "audit",
277
+ str(FIXTURES / "suppress_sample.py"),
278
+ "--ignore",
279
+ "AU001",
280
+ "--json",
281
+ ],
282
+ capture_output=True,
283
+ text=True,
284
+ )
285
+ report = json.loads(out.stdout)
286
+ au001 = [f for f in report["findings"] if f["rule_id"] == "AU001"]
287
+ assert all(f["ignored"] for f in au001)
288
+ assert all(f["ignore_reason"] == "CLI --ignore" for f in au001)
289
+
290
+
291
+ def test_audit_cli_au010_in_output():
292
+ out = subprocess.run(
293
+ [
294
+ sys.executable,
295
+ "-m",
296
+ "shellsafe",
297
+ "audit",
298
+ str(FIXTURES / "suppress_sample.py"),
299
+ "--severity",
300
+ "info",
301
+ "--show-ignored",
302
+ ],
303
+ capture_output=True,
304
+ text=True,
305
+ )
306
+ assert "AU010" in out.stdout
@@ -165,3 +165,87 @@ def test_all_rules_found():
165
165
  assert "AU002" in rule_ids
166
166
  assert "AU003" in rule_ids
167
167
  assert "AU004" in rule_ids
168
+
169
+
170
+ # Suppression tests
171
+
172
+
173
+ def test_suppress_with_reason():
174
+ findings = scan([str(FIXTURES / "suppress_sample.py")])
175
+ # Line 13: AU001 suppressed with reason
176
+ au001_ignored = [
177
+ f for f in findings
178
+ if f.rule_id == "AU001" and f.ignored and f.ignore_reason == "tested, value is constant"
179
+ ]
180
+ assert len(au001_ignored) == 1
181
+
182
+
183
+ def test_suppress_multi_rule_with_reason():
184
+ findings = scan([str(FIXTURES / "suppress_sample.py")])
185
+ # Line 16: AU001 and AU002 suppressed together with reason
186
+ multi_ignored = [
187
+ f for f in findings
188
+ if f.ignored and f.ignore_reason == "legacy code, tracked for migration"
189
+ ]
190
+ rule_ids = {f.rule_id for f in multi_ignored}
191
+ assert "AU001" in rule_ids
192
+ assert "AU002" in rule_ids
193
+
194
+
195
+ def test_suppress_without_reason_emits_au010():
196
+ findings = scan([str(FIXTURES / "suppress_sample.py")])
197
+ # Line 20: AU004 suppressed without reason
198
+ au004_ignored = [
199
+ f for f in findings
200
+ if f.rule_id == "AU004" and f.ignored
201
+ ]
202
+ assert len(au004_ignored) == 1
203
+ assert au004_ignored[0].ignore_reason is None
204
+ # AU010 should be emitted for the reason-less suppression
205
+ au010 = [f for f in findings if f.rule_id == "AU010"]
206
+ assert len(au010) >= 1
207
+
208
+
209
+ def test_unsuppressed_findings_still_flagged():
210
+ findings = scan([str(FIXTURES / "suppress_sample.py")])
211
+ # Line 24: AU001 with no suppression
212
+ au001_unsuppressed = [
213
+ f for f in findings
214
+ if f.rule_id == "AU001" and not f.ignored
215
+ ]
216
+ assert len(au001_unsuppressed) == 1
217
+
218
+
219
+ def test_suppress_cli_ignore():
220
+ findings = scan([str(FIXTURES / "suppress_sample.py")], ignore={"AU001"})
221
+ # All AU001 findings should be ignored
222
+ au001 = [f for f in findings if f.rule_id == "AU001"]
223
+ assert all(f.ignored for f in au001)
224
+ assert all(f.ignore_reason == "CLI --ignore" for f in au001)
225
+
226
+
227
+ def test_suppress_cli_ignore_does_not_affect_other_rules():
228
+ findings = scan([str(FIXTURES / "suppress_sample.py")], ignore={"AU001"})
229
+ # AU001 findings are all ignored via CLI
230
+ au001_active = [
231
+ f for f in findings
232
+ if f.rule_id == "AU001" and not f.ignored
233
+ ]
234
+ assert len(au001_active) == 0
235
+ # AU004 without suppression is still active
236
+ au004_active = [
237
+ f for f in findings
238
+ if f.rule_id == "AU004" and not f.ignored
239
+ ]
240
+ assert len(au004_active) >= 1
241
+
242
+
243
+ def test_suppress_cli_ignore_multiple_rules():
244
+ findings = scan(
245
+ [str(FIXTURES / "suppress_sample.py")],
246
+ ignore={"AU001", "AU002"},
247
+ )
248
+ au001 = [f for f in findings if f.rule_id == "AU001"]
249
+ au002 = [f for f in findings if f.rule_id == "AU002"]
250
+ assert all(f.ignored for f in au001)
251
+ assert all(f.ignored for f in au002)
@@ -4,7 +4,7 @@ import shellsafe
4
4
 
5
5
 
6
6
  def test_public_names_exist():
7
- for name in ("run", "capture", "shx", "RAW", "CaptureResult", "__version__"):
7
+ for name in ("run", "capture", "shx", "plan", "RAW", "CaptureResult", "__version__"):
8
8
  assert hasattr(shellsafe, name), name
9
9
 
10
10
 
@@ -13,6 +13,7 @@ def test_all_is_exact():
13
13
  "run",
14
14
  "capture",
15
15
  "shx",
16
+ "plan",
16
17
  "RAW",
17
18
  "CaptureResult",
18
19
  "__version__",
@@ -1 +0,0 @@
1
- __version__ = "0.3.2"
File without changes
File without changes
File without changes
File without changes
File without changes