shellsafe 0.3.2__tar.gz → 0.3.3__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.
- {shellsafe-0.3.2 → shellsafe-0.3.3}/CHANGELOG.md +19 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/PKG-INFO +1 -1
- shellsafe-0.3.3/src/shellsafe/_version.py +1 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/audit/scanner.py +208 -25
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/cli.py +16 -3
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/execute.py +1 -2
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/platforms.py +1 -1
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/fixtures/audit/au001_sample.py +2 -2
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/fixtures/audit/au002_sample.py +2 -2
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/fixtures/audit/au003_sample.py +2 -2
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/fixtures/audit/au004_sample.py +2 -2
- shellsafe-0.3.3/tests/fixtures/audit/suppress_sample.py +26 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/integration/test_cli_audit.py +101 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/unit/test_audit.py +84 -0
- shellsafe-0.3.2/src/shellsafe/_version.py +0 -1
- {shellsafe-0.3.2 → shellsafe-0.3.3}/.github/workflows/ci.yml +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/.github/workflows/release.yml +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/.gitignore +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/CONTRIBUTING.md +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/LICENSE +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/README.md +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/examples/demo.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/pyproject.toml +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/__init__.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/__main__.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/audit/__init__.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/errors.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/exitcodes.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/py.typed +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/raw.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/src/shellsafe/render.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/golden/.gitkeep +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/integration/.gitkeep +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/integration/test_exec_posix.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/README.md +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_01_semicolon.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_02_substitution.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_03_backticks.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_04_background_chain.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_05_or_chain.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_06_redirect_out.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_07_redirect_in.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_08_pipe_out.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_09_quote_smuggle.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_10_quote_storm.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_11_globs_expansions.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_12_newline.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_13_fullwidth_semicolon.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_14_whitespace.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/payloads/cases/case_15_unicode_dashes.txt +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/property/.gitkeep +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/property/test_invariants.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/unit/test_cli.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/unit/test_errors.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/unit/test_payload_corpus.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/unit/test_public_surface.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/unit/test_raw.py +0 -0
- {shellsafe-0.3.2 → shellsafe-0.3.3}/tests/unit/test_render.py +0 -0
|
@@ -3,6 +3,25 @@
|
|
|
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.3] - 2026-09-01
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Inline suppression: `# shellsafe: ignore AU001 reason: <why>` comments
|
|
11
|
+
- File-level suppression: `# shellsafe: ignore-file AU001` at top of file
|
|
12
|
+
- Multi-rule suppression: `# shellsafe: ignore AU001, AU002 reason: <why>`
|
|
13
|
+
- AU010 meta-warning: flags suppression comments without a reason
|
|
14
|
+
- CLI `--show-ignored` flag to display suppressed findings
|
|
15
|
+
- CLI `--ignore RULE` flag to suppress rules from command line (repeatable)
|
|
16
|
+
- Suppression fixture corpus: inline, file-level, multi-rule, AU010 cases
|
|
17
|
+
- Unit tests for suppression parsing and application
|
|
18
|
+
- Integration tests for CLI suppression flags
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Terminal reporter shows suppressed count and muted display for ignored findings
|
|
23
|
+
- CLI version string shows "AU001-AU004, suppression"
|
|
24
|
+
|
|
6
25
|
## [0.3.2] - 2026-08-30
|
|
7
26
|
|
|
8
27
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: shellsafe
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
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
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.3"
|
|
@@ -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])
|
|
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)
|
|
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)
|
|
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)
|
|
157
|
+
def _track_dynamic_vars(tree: ast.Module):
|
|
159
158
|
"""Find variables assigned dynamic string expressions."""
|
|
160
|
-
dynamic
|
|
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)
|
|
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
|
|
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])
|
|
298
|
-
files
|
|
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)
|
|
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
|
|
329
|
-
"""
|
|
330
|
-
|
|
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
|
-
|
|
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(
|
|
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 = {
|
|
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)
|
|
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
|
|
366
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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])
|
|
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)
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|