pyshield-security 0.1.0__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.
- pyshield/__init__.py +4 -0
- pyshield/__main__.py +8 -0
- pyshield/cli/__init__.py +5 -0
- pyshield/cli/main.py +115 -0
- pyshield/config/__init__.py +5 -0
- pyshield/config/models.py +54 -0
- pyshield/core/__init__.py +19 -0
- pyshield/core/analyzer.py +77 -0
- pyshield/core/engine.py +165 -0
- pyshield/core/models.py +117 -0
- pyshield/core/registry.py +65 -0
- pyshield/reporters/__init__.py +5 -0
- pyshield/reporters/terminal.py +83 -0
- pyshield/rules/__init__.py +32 -0
- pyshield/rules/base.py +118 -0
- pyshield/rules/builtin/__init__.py +43 -0
- pyshield/rules/builtin/api_token.py +106 -0
- pyshield/rules/builtin/dangerous_eval.py +45 -0
- pyshield/rules/builtin/dangerous_exec.py +45 -0
- pyshield/rules/builtin/hardcoded_secret.py +151 -0
- pyshield/rules/builtin/insecure_crypto.py +101 -0
- pyshield/rules/builtin/insecure_random.py +133 -0
- pyshield/rules/builtin/os_system.py +61 -0
- pyshield/rules/builtin/private_key.py +52 -0
- pyshield/rules/builtin/unsafe_subprocess.py +101 -0
- pyshield/rules/builtin/weak_hash.py +94 -0
- pyshield/rules/secrets_utils.py +166 -0
- pyshield_security-0.1.0.dist-info/METADATA +212 -0
- pyshield_security-0.1.0.dist-info/RECORD +32 -0
- pyshield_security-0.1.0.dist-info/WHEEL +4 -0
- pyshield_security-0.1.0.dist-info/entry_points.txt +2 -0
- pyshield_security-0.1.0.dist-info/licenses/LICENSE +21 -0
pyshield/__init__.py
ADDED
pyshield/__main__.py
ADDED
pyshield/cli/__init__.py
ADDED
pyshield/cli/main.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Command-line interface for PyShield."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from pyshield import __version__
|
|
9
|
+
from pyshield.config.models import DEFAULT_EXCLUDES, ScanConfig
|
|
10
|
+
from pyshield.core.engine import ScanEngine
|
|
11
|
+
from pyshield.core.models import Severity
|
|
12
|
+
from pyshield.reporters.terminal import TerminalReporter
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="pyshield",
|
|
16
|
+
help="PyShield: Developer-focused open-source Python security analysis platform.",
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
add_completion=False,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def version_callback(value: bool) -> None:
|
|
23
|
+
"""Print the PyShield version and exit."""
|
|
24
|
+
if value:
|
|
25
|
+
typer.echo(f"PyShield {__version__}")
|
|
26
|
+
raise typer.Exit()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback()
|
|
30
|
+
def main(
|
|
31
|
+
version: bool | None = typer.Option(
|
|
32
|
+
None,
|
|
33
|
+
"--version",
|
|
34
|
+
"-v",
|
|
35
|
+
help="Show PyShield version and exit.",
|
|
36
|
+
callback=version_callback,
|
|
37
|
+
is_eager=True,
|
|
38
|
+
),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""PyShield root command."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command(name="scan", help="Scan Python files or directories for security vulnerabilities.")
|
|
44
|
+
def scan(
|
|
45
|
+
paths: list[Path] = typer.Argument(
|
|
46
|
+
None,
|
|
47
|
+
help="One or more file or directory paths to scan (default: current directory).",
|
|
48
|
+
),
|
|
49
|
+
fail_on: Severity = typer.Option(
|
|
50
|
+
Severity.LOW,
|
|
51
|
+
"--fail-on",
|
|
52
|
+
help="Minimum severity threshold to trigger exit code 1 (LOW, MEDIUM, HIGH, CRITICAL).",
|
|
53
|
+
case_sensitive=False,
|
|
54
|
+
),
|
|
55
|
+
exclude: list[str] = typer.Option(
|
|
56
|
+
None,
|
|
57
|
+
"--exclude",
|
|
58
|
+
"-e",
|
|
59
|
+
help="Additional glob patterns or directory names to exclude.",
|
|
60
|
+
),
|
|
61
|
+
disable_rule: list[str] = typer.Option(
|
|
62
|
+
None,
|
|
63
|
+
"--disable-rule",
|
|
64
|
+
"-d",
|
|
65
|
+
help="Rule IDs to disable (e.g. PS101).",
|
|
66
|
+
),
|
|
67
|
+
enable_rule: list[str] = typer.Option(
|
|
68
|
+
None,
|
|
69
|
+
"--enable-rule",
|
|
70
|
+
help="Explicit rule IDs to enable (only these will run).",
|
|
71
|
+
),
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Run deterministic static security analysis on the specified paths."""
|
|
74
|
+
target_paths = paths if paths else [Path(".")]
|
|
75
|
+
|
|
76
|
+
# Validate that all target paths exist; exit with code 2 if not
|
|
77
|
+
for path in target_paths:
|
|
78
|
+
if not path.exists():
|
|
79
|
+
sys.stderr.write(f"Error: Target path does not exist: {path}\n")
|
|
80
|
+
raise typer.Exit(code=2)
|
|
81
|
+
|
|
82
|
+
# Build scan configuration
|
|
83
|
+
exclude_patterns = list(DEFAULT_EXCLUDES)
|
|
84
|
+
if exclude:
|
|
85
|
+
exclude_patterns.extend(exclude)
|
|
86
|
+
|
|
87
|
+
config = ScanConfig(
|
|
88
|
+
target_paths=target_paths,
|
|
89
|
+
exclude_patterns=exclude_patterns,
|
|
90
|
+
fail_on=fail_on,
|
|
91
|
+
disabled_rules=set(disable_rule) if disable_rule else set(),
|
|
92
|
+
enabled_rules=set(enable_rule) if enable_rule else None,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
engine = ScanEngine(config=config)
|
|
97
|
+
result = engine.run()
|
|
98
|
+
except Exception as err:
|
|
99
|
+
sys.stderr.write(f"Error during scan execution: {err}\n")
|
|
100
|
+
raise typer.Exit(code=2) from err
|
|
101
|
+
|
|
102
|
+
reporter = TerminalReporter()
|
|
103
|
+
reporter.render(result)
|
|
104
|
+
|
|
105
|
+
# Exit code determination
|
|
106
|
+
# 0 = No findings at or above configured failure threshold
|
|
107
|
+
# 1 = One or more findings at or above configured threshold
|
|
108
|
+
# 2 = Scan error (e.g. zero files could be parsed and all failed)
|
|
109
|
+
if result.summary.files_scanned == 0 and result.summary.files_failed > 0:
|
|
110
|
+
raise typer.Exit(code=2)
|
|
111
|
+
|
|
112
|
+
if any(finding.severity >= fail_on for finding in result.findings):
|
|
113
|
+
raise typer.Exit(code=1)
|
|
114
|
+
|
|
115
|
+
raise typer.Exit(code=0)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Configuration data models for PyShield."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from pyshield.core.models import Severity
|
|
8
|
+
|
|
9
|
+
DEFAULT_EXCLUDES: list[str] = [
|
|
10
|
+
".git",
|
|
11
|
+
".venv",
|
|
12
|
+
"venv",
|
|
13
|
+
"env",
|
|
14
|
+
"__pycache__",
|
|
15
|
+
"node_modules",
|
|
16
|
+
"build",
|
|
17
|
+
"dist",
|
|
18
|
+
".pytest_cache",
|
|
19
|
+
".mypy_cache",
|
|
20
|
+
".ruff_cache",
|
|
21
|
+
"*.egg-info",
|
|
22
|
+
".eggs",
|
|
23
|
+
".tox",
|
|
24
|
+
".nox",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ScanConfig(BaseModel):
|
|
29
|
+
"""Configuration options for a PyShield scan."""
|
|
30
|
+
|
|
31
|
+
target_paths: list[Path] = Field(
|
|
32
|
+
default_factory=lambda: [Path(".")],
|
|
33
|
+
description="List of file or directory paths to analyze",
|
|
34
|
+
)
|
|
35
|
+
exclude_patterns: list[str] = Field(
|
|
36
|
+
default_factory=lambda: list(DEFAULT_EXCLUDES),
|
|
37
|
+
description="Glob patterns or directory names to exclude from analysis",
|
|
38
|
+
)
|
|
39
|
+
fail_on: Severity | None = Field(
|
|
40
|
+
default=Severity.LOW,
|
|
41
|
+
description="Minimum severity threshold that triggers a non-zero exit code",
|
|
42
|
+
)
|
|
43
|
+
max_file_size_bytes: int = Field(
|
|
44
|
+
default=10 * 1024 * 1024,
|
|
45
|
+
description="Maximum file size in bytes to inspect (default: 10MB)",
|
|
46
|
+
)
|
|
47
|
+
disabled_rules: set[str] = Field(
|
|
48
|
+
default_factory=set,
|
|
49
|
+
description="Set of rule IDs to disable during the scan",
|
|
50
|
+
)
|
|
51
|
+
enabled_rules: set[str] | None = Field(
|
|
52
|
+
default=None,
|
|
53
|
+
description="Explicit set of rule IDs to run (runs all registered if None)",
|
|
54
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""PyShield core engine components."""
|
|
2
|
+
|
|
3
|
+
from pyshield.core.models import (
|
|
4
|
+
Confidence,
|
|
5
|
+
FileDiagnostic,
|
|
6
|
+
Finding,
|
|
7
|
+
ScanResult,
|
|
8
|
+
ScanSummary,
|
|
9
|
+
Severity,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"Confidence",
|
|
14
|
+
"FileDiagnostic",
|
|
15
|
+
"Finding",
|
|
16
|
+
"ScanResult",
|
|
17
|
+
"ScanSummary",
|
|
18
|
+
"Severity",
|
|
19
|
+
]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""AST Analyzer: safely reads, parses, and executes rules on Python files."""
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from pyshield.core.models import FileDiagnostic, Finding
|
|
7
|
+
from pyshield.rules.base import ASTContext, BaseRule
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ASTAnalyzer:
|
|
11
|
+
"""Performs static analysis on Python source files using standard ast."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, rules: list[BaseRule]) -> None:
|
|
14
|
+
self.rules = rules
|
|
15
|
+
|
|
16
|
+
def analyze_source(
|
|
17
|
+
self,
|
|
18
|
+
source_code: str,
|
|
19
|
+
file_path: Path,
|
|
20
|
+
) -> tuple[list[Finding], FileDiagnostic | None]:
|
|
21
|
+
"""Analyze a string of Python source code against configured rules."""
|
|
22
|
+
try:
|
|
23
|
+
tree = ast.parse(source_code, filename=str(file_path))
|
|
24
|
+
except SyntaxError as err:
|
|
25
|
+
diagnostic = FileDiagnostic(
|
|
26
|
+
file_path=file_path,
|
|
27
|
+
error_type="SyntaxError",
|
|
28
|
+
message=err.msg or "Syntax error during parsing",
|
|
29
|
+
line=err.lineno,
|
|
30
|
+
column=err.offset,
|
|
31
|
+
)
|
|
32
|
+
return [], diagnostic
|
|
33
|
+
except Exception as err:
|
|
34
|
+
diagnostic = FileDiagnostic(
|
|
35
|
+
file_path=file_path,
|
|
36
|
+
error_type=type(err).__name__,
|
|
37
|
+
message=str(err) or "Failed to parse AST",
|
|
38
|
+
)
|
|
39
|
+
return [], diagnostic
|
|
40
|
+
|
|
41
|
+
context = ASTContext.create(file_path=file_path, source_code=source_code, tree=tree)
|
|
42
|
+
findings: list[Finding] = []
|
|
43
|
+
|
|
44
|
+
for rule in self.rules:
|
|
45
|
+
rule_findings = rule.check(context)
|
|
46
|
+
findings.extend(rule_findings)
|
|
47
|
+
|
|
48
|
+
return findings, None
|
|
49
|
+
|
|
50
|
+
def analyze_file(
|
|
51
|
+
self,
|
|
52
|
+
file_path: Path,
|
|
53
|
+
max_file_size_bytes: int = 10 * 1024 * 1024,
|
|
54
|
+
) -> tuple[list[Finding], FileDiagnostic | None]:
|
|
55
|
+
"""Read and analyze a single Python file from disk."""
|
|
56
|
+
try:
|
|
57
|
+
stat = file_path.stat()
|
|
58
|
+
if stat.st_size > max_file_size_bytes:
|
|
59
|
+
return [], FileDiagnostic(
|
|
60
|
+
file_path=file_path,
|
|
61
|
+
error_type="FileSizeLimitExceeded",
|
|
62
|
+
message=(
|
|
63
|
+
f"File size ({stat.st_size} bytes) exceeds "
|
|
64
|
+
f"limit ({max_file_size_bytes} bytes)"
|
|
65
|
+
),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Read with utf-8 and replacement characters for invalid encodings
|
|
69
|
+
source_code = file_path.read_text(encoding="utf-8", errors="replace")
|
|
70
|
+
except OSError as err:
|
|
71
|
+
return [], FileDiagnostic(
|
|
72
|
+
file_path=file_path,
|
|
73
|
+
error_type="ReadError",
|
|
74
|
+
message=f"Could not read file: {err}",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return self.analyze_source(source_code=source_code, file_path=file_path)
|
pyshield/core/engine.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Scan Engine: discovers Python source files and orchestrates security analysis."""
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pyshield.config.models import ScanConfig
|
|
9
|
+
from pyshield.core.analyzer import ASTAnalyzer
|
|
10
|
+
from pyshield.core.models import Finding, ScanResult, ScanSummary, Severity
|
|
11
|
+
from pyshield.core.registry import RuleRegistry
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def should_exclude(path: Path, exclude_patterns: list[str]) -> bool:
|
|
15
|
+
"""Determine whether a given path matches any exclude pattern."""
|
|
16
|
+
path_str = str(path).replace("\\", "/")
|
|
17
|
+
parts = path.parts
|
|
18
|
+
|
|
19
|
+
for pattern in exclude_patterns:
|
|
20
|
+
# Check against path components (e.g. '.venv', '__pycache__')
|
|
21
|
+
for part in parts:
|
|
22
|
+
if fnmatch.fnmatch(part, pattern):
|
|
23
|
+
return True
|
|
24
|
+
# Check against full path string or glob
|
|
25
|
+
if fnmatch.fnmatch(path_str, pattern) or fnmatch.fnmatch(path.name, pattern):
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
return False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def discover_python_files(
|
|
32
|
+
target_path: Path,
|
|
33
|
+
exclude_patterns: list[str],
|
|
34
|
+
) -> list[Path]:
|
|
35
|
+
"""Discover all Python source files recursively, avoiding exclusions and symlink cycles."""
|
|
36
|
+
if not target_path.exists():
|
|
37
|
+
raise FileNotFoundError(f"Target path does not exist: {target_path}")
|
|
38
|
+
|
|
39
|
+
# If single file target
|
|
40
|
+
if target_path.is_file():
|
|
41
|
+
if target_path.suffix == ".py" and not should_exclude(target_path, exclude_patterns):
|
|
42
|
+
return [target_path]
|
|
43
|
+
return []
|
|
44
|
+
|
|
45
|
+
discovered: list[Path] = []
|
|
46
|
+
visited_dirs: set[str] = set()
|
|
47
|
+
|
|
48
|
+
for root_dir, dirs, files in _safe_walk(target_path, visited_dirs):
|
|
49
|
+
# Filter directories in-place to prevent traversing excluded dirs
|
|
50
|
+
dirs[:] = [d for d in dirs if not should_exclude(root_dir / d, exclude_patterns)]
|
|
51
|
+
|
|
52
|
+
for file_name in files:
|
|
53
|
+
if file_name.endswith(".py"):
|
|
54
|
+
file_path = root_dir / file_name
|
|
55
|
+
if not should_exclude(file_path, exclude_patterns):
|
|
56
|
+
discovered.append(file_path)
|
|
57
|
+
|
|
58
|
+
return sorted(discovered)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _safe_walk(
|
|
62
|
+
target_path: Path, visited_dirs: set[str]
|
|
63
|
+
) -> Iterator[tuple[Path, list[str], list[str]]]:
|
|
64
|
+
"""Walk directory tree while preventing symlink loops."""
|
|
65
|
+
import os
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
resolved_root = str(target_path.resolve())
|
|
69
|
+
visited_dirs.add(resolved_root)
|
|
70
|
+
except OSError:
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
for root, dirs, files in os.walk(target_path, followlinks=False):
|
|
74
|
+
current_root_path = Path(root)
|
|
75
|
+
try:
|
|
76
|
+
visited_dirs.add(str(current_root_path.resolve()))
|
|
77
|
+
except OSError:
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
# Prevent circular symlink recursion
|
|
81
|
+
filtered_dirs: list[str] = []
|
|
82
|
+
for d in dirs:
|
|
83
|
+
dir_path = current_root_path / d
|
|
84
|
+
try:
|
|
85
|
+
if dir_path.is_symlink():
|
|
86
|
+
resolved_target = str(dir_path.resolve())
|
|
87
|
+
if resolved_target in visited_dirs:
|
|
88
|
+
continue # Skip symlink cycle
|
|
89
|
+
visited_dirs.add(resolved_target)
|
|
90
|
+
filtered_dirs.append(d)
|
|
91
|
+
except OSError:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
dirs[:] = filtered_dirs
|
|
95
|
+
yield current_root_path, dirs, files
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class ScanEngine:
|
|
99
|
+
"""Coordinates file discovery, AST analysis, and result aggregation."""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
config: ScanConfig | None = None,
|
|
104
|
+
registry: RuleRegistry | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
self.config = config or ScanConfig()
|
|
107
|
+
self.registry = registry or RuleRegistry.create_default()
|
|
108
|
+
|
|
109
|
+
def run(self) -> ScanResult:
|
|
110
|
+
"""Execute the configured scan across all target paths."""
|
|
111
|
+
start_time = time.perf_counter()
|
|
112
|
+
|
|
113
|
+
active_rules = self.registry.get_active(
|
|
114
|
+
disabled_rules=self.config.disabled_rules,
|
|
115
|
+
enabled_rules=self.config.enabled_rules,
|
|
116
|
+
)
|
|
117
|
+
analyzer = ASTAnalyzer(rules=active_rules)
|
|
118
|
+
|
|
119
|
+
all_findings: list[Finding] = []
|
|
120
|
+
all_diagnostics = []
|
|
121
|
+
files_scanned = 0
|
|
122
|
+
files_failed = 0
|
|
123
|
+
|
|
124
|
+
# Collect files from all target paths
|
|
125
|
+
target_files: list[Path] = []
|
|
126
|
+
for target in self.config.target_paths:
|
|
127
|
+
found = discover_python_files(target, self.config.exclude_patterns)
|
|
128
|
+
target_files.extend(found)
|
|
129
|
+
|
|
130
|
+
# Remove potential duplicates while preserving order
|
|
131
|
+
unique_files = list(dict.fromkeys(target_files))
|
|
132
|
+
|
|
133
|
+
for file_path in unique_files:
|
|
134
|
+
findings, diagnostic = analyzer.analyze_file(
|
|
135
|
+
file_path=file_path,
|
|
136
|
+
max_file_size_bytes=self.config.max_file_size_bytes,
|
|
137
|
+
)
|
|
138
|
+
if diagnostic is not None:
|
|
139
|
+
all_diagnostics.append(diagnostic)
|
|
140
|
+
files_failed += 1
|
|
141
|
+
else:
|
|
142
|
+
files_scanned += 1
|
|
143
|
+
all_findings.extend(findings)
|
|
144
|
+
|
|
145
|
+
# Sort findings deterministically: file, line, col, rule_id
|
|
146
|
+
all_findings.sort(key=lambda f: (str(f.file_path), f.line, f.column, f.rule_id))
|
|
147
|
+
|
|
148
|
+
findings_count = {s: 0 for s in Severity}
|
|
149
|
+
for finding in all_findings:
|
|
150
|
+
findings_count[finding.severity] += 1
|
|
151
|
+
|
|
152
|
+
duration = time.perf_counter() - start_time
|
|
153
|
+
|
|
154
|
+
summary = ScanSummary(
|
|
155
|
+
files_scanned=files_scanned,
|
|
156
|
+
files_failed=files_failed,
|
|
157
|
+
findings_count=findings_count,
|
|
158
|
+
duration_seconds=round(duration, 3),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
return ScanResult(
|
|
162
|
+
findings=all_findings,
|
|
163
|
+
diagnostics=all_diagnostics,
|
|
164
|
+
summary=summary,
|
|
165
|
+
)
|
pyshield/core/models.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Core data models for PyShield security analysis."""
|
|
2
|
+
|
|
3
|
+
from enum import StrEnum
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(StrEnum):
|
|
11
|
+
"""Vulnerability severity levels ordered by criticality."""
|
|
12
|
+
|
|
13
|
+
LOW = "LOW"
|
|
14
|
+
MEDIUM = "MEDIUM"
|
|
15
|
+
HIGH = "HIGH"
|
|
16
|
+
CRITICAL = "CRITICAL"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def level(self) -> int:
|
|
20
|
+
"""Integer priority level for comparison (higher = more severe)."""
|
|
21
|
+
mapping = {
|
|
22
|
+
Severity.LOW: 1,
|
|
23
|
+
Severity.MEDIUM: 2,
|
|
24
|
+
Severity.HIGH: 3,
|
|
25
|
+
Severity.CRITICAL: 4,
|
|
26
|
+
}
|
|
27
|
+
return mapping[self]
|
|
28
|
+
|
|
29
|
+
def __ge__(self, other: Any) -> bool:
|
|
30
|
+
if isinstance(other, Severity):
|
|
31
|
+
return self.level >= other.level
|
|
32
|
+
return NotImplemented
|
|
33
|
+
|
|
34
|
+
def __gt__(self, other: Any) -> bool:
|
|
35
|
+
if isinstance(other, Severity):
|
|
36
|
+
return self.level > other.level
|
|
37
|
+
return NotImplemented
|
|
38
|
+
|
|
39
|
+
def __le__(self, other: Any) -> bool:
|
|
40
|
+
if isinstance(other, Severity):
|
|
41
|
+
return self.level <= other.level
|
|
42
|
+
return NotImplemented
|
|
43
|
+
|
|
44
|
+
def __lt__(self, other: Any) -> bool:
|
|
45
|
+
if isinstance(other, Severity):
|
|
46
|
+
return self.level < other.level
|
|
47
|
+
return NotImplemented
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Confidence(StrEnum):
|
|
51
|
+
"""Confidence levels in the deterministic finding."""
|
|
52
|
+
|
|
53
|
+
LOW = "LOW"
|
|
54
|
+
MEDIUM = "MEDIUM"
|
|
55
|
+
HIGH = "HIGH"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Finding(BaseModel):
|
|
59
|
+
"""Represents a deterministic security finding detected by a rule."""
|
|
60
|
+
|
|
61
|
+
model_config = ConfigDict(frozen=True)
|
|
62
|
+
|
|
63
|
+
rule_id: str = Field(description="Unique stable rule identifier, e.g. PS101")
|
|
64
|
+
title: str = Field(description="Brief title of the finding")
|
|
65
|
+
severity: Severity = Field(description="Severity classification")
|
|
66
|
+
confidence: Confidence = Field(description="Confidence level in the match")
|
|
67
|
+
file_path: Path = Field(description="Path to the file where the finding was detected")
|
|
68
|
+
line: int = Field(description="Line number (1-indexed)")
|
|
69
|
+
column: int = Field(description="Column number (1-indexed)")
|
|
70
|
+
end_line: int | None = Field(default=None, description="Ending line number if known")
|
|
71
|
+
end_column: int | None = Field(default=None, description="Ending column number if known")
|
|
72
|
+
message: str = Field(description="Context-specific finding message")
|
|
73
|
+
description: str = Field(description="Detailed explanation of the vulnerability risk")
|
|
74
|
+
remediation: str = Field(description="Actionable guidance to remediate the issue")
|
|
75
|
+
cwe: str | None = Field(default=None, description="Common Weakness Enumeration ID, e.g. CWE-95")
|
|
76
|
+
snippet: str | None = Field(default=None, description="Safe code excerpt at finding location")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FileDiagnostic(BaseModel):
|
|
80
|
+
"""Represents a non-fatal diagnostic or error encountered when processing a file."""
|
|
81
|
+
|
|
82
|
+
model_config = ConfigDict(frozen=True)
|
|
83
|
+
|
|
84
|
+
file_path: Path = Field(description="Path to the file that caused the diagnostic")
|
|
85
|
+
error_type: str = Field(description="Type of error (e.g. SyntaxError, UnicodeDecodeError)")
|
|
86
|
+
message: str = Field(description="Detailed diagnostic message")
|
|
87
|
+
line: int | None = Field(default=None, description="Line number if available")
|
|
88
|
+
column: int | None = Field(default=None, description="Column number if available")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class ScanSummary(BaseModel):
|
|
92
|
+
"""Summary metrics of a scan execution."""
|
|
93
|
+
|
|
94
|
+
files_scanned: int = Field(default=0, description="Total number of files parsed successfully")
|
|
95
|
+
files_failed: int = Field(default=0, description="Number of files that failed parsing/reading")
|
|
96
|
+
findings_count: dict[Severity, int] = Field(
|
|
97
|
+
default_factory=lambda: {s: 0 for s in Severity},
|
|
98
|
+
description="Count of findings per severity level",
|
|
99
|
+
)
|
|
100
|
+
duration_seconds: float = Field(default=0.0, description="Scan execution duration in seconds")
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def total_findings(self) -> int:
|
|
104
|
+
"""Total number of findings detected across all severities."""
|
|
105
|
+
return sum(self.findings_count.values())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ScanResult(BaseModel):
|
|
109
|
+
"""Aggregated result of a scan execution."""
|
|
110
|
+
|
|
111
|
+
findings: list[Finding] = Field(default_factory=list, description="All detected findings")
|
|
112
|
+
diagnostics: list[FileDiagnostic] = Field(
|
|
113
|
+
default_factory=list, description="Diagnostics for failed files"
|
|
114
|
+
)
|
|
115
|
+
summary: ScanSummary = Field(
|
|
116
|
+
default_factory=ScanSummary, description="Summary metrics of the scan"
|
|
117
|
+
)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Rule registry for managing and querying PyShield security rules."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
from pyshield.rules.base import BaseRule
|
|
6
|
+
from pyshield.rules.builtin import BUILTIN_RULES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RuleRegistry:
|
|
10
|
+
"""Registry managing available PyShield security rules."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
self._rules: dict[str, BaseRule] = {}
|
|
14
|
+
|
|
15
|
+
def register(self, rule: BaseRule | type[BaseRule], overwrite: bool = False) -> None:
|
|
16
|
+
"""Register a new rule instance or class in the registry."""
|
|
17
|
+
rule_instance = rule() if isinstance(rule, type) else rule
|
|
18
|
+
rule_id = rule_instance.rule_id
|
|
19
|
+
|
|
20
|
+
if rule_id in self._rules and not overwrite:
|
|
21
|
+
raise ValueError(f"Rule with ID '{rule_id}' is already registered.")
|
|
22
|
+
|
|
23
|
+
self._rules[rule_id] = rule_instance
|
|
24
|
+
|
|
25
|
+
def register_all(
|
|
26
|
+
self, rules: Iterable[BaseRule | type[BaseRule]], overwrite: bool = False
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Register multiple rules."""
|
|
29
|
+
for r in rules:
|
|
30
|
+
self.register(r, overwrite=overwrite)
|
|
31
|
+
|
|
32
|
+
def get(self, rule_id: str) -> BaseRule | None:
|
|
33
|
+
"""Retrieve a rule by its ID, or None if not found."""
|
|
34
|
+
return self._rules.get(rule_id)
|
|
35
|
+
|
|
36
|
+
def get_all(self) -> list[BaseRule]:
|
|
37
|
+
"""Return all registered rule instances sorted by rule_id."""
|
|
38
|
+
return sorted(self._rules.values(), key=lambda r: r.rule_id)
|
|
39
|
+
|
|
40
|
+
def get_active(
|
|
41
|
+
self,
|
|
42
|
+
disabled_rules: set[str] | None = None,
|
|
43
|
+
enabled_rules: set[str] | None = None,
|
|
44
|
+
) -> list[BaseRule]:
|
|
45
|
+
"""Return active rules considering enabled and disabled rule sets."""
|
|
46
|
+
disabled = disabled_rules or set()
|
|
47
|
+
rules = self.get_all()
|
|
48
|
+
|
|
49
|
+
if enabled_rules is not None:
|
|
50
|
+
rules = [r for r in rules if r.rule_id in enabled_rules]
|
|
51
|
+
|
|
52
|
+
return [r for r in rules if r.rule_id not in disabled]
|
|
53
|
+
|
|
54
|
+
def __len__(self) -> int:
|
|
55
|
+
return len(self._rules)
|
|
56
|
+
|
|
57
|
+
def __contains__(self, rule_id: str) -> bool:
|
|
58
|
+
return rule_id in self._rules
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def create_default(cls) -> "RuleRegistry":
|
|
62
|
+
"""Instantiate a registry prepopulated with all built-in rules."""
|
|
63
|
+
registry = cls()
|
|
64
|
+
registry.register_all(BUILTIN_RULES)
|
|
65
|
+
return registry
|