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 ADDED
@@ -0,0 +1,4 @@
1
+ """PyShield: Developer-focused open-source security analysis platform for Python."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
pyshield/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Main entrypoint for python -m pyshield."""
2
+
3
+ import sys
4
+
5
+ from pyshield.cli.main import app
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(app())
@@ -0,0 +1,5 @@
1
+ """PyShield CLI package."""
2
+
3
+ from pyshield.cli.main import app
4
+
5
+ __all__ = ["app"]
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,5 @@
1
+ """Configuration components for PyShield."""
2
+
3
+ from pyshield.config.models import DEFAULT_EXCLUDES, ScanConfig
4
+
5
+ __all__ = ["DEFAULT_EXCLUDES", "ScanConfig"]
@@ -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)
@@ -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
+ )
@@ -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
@@ -0,0 +1,5 @@
1
+ """PyShield reporters module."""
2
+
3
+ from pyshield.reporters.terminal import TerminalReporter
4
+
5
+ __all__ = ["TerminalReporter"]