gitrupt 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.
- gitrupt/__init__.py +13 -0
- gitrupt/cli.py +546 -0
- gitrupt/config.py +269 -0
- gitrupt/git.py +590 -0
- gitrupt/hooks/__init__.py +7 -0
- gitrupt/hooks/install.py +255 -0
- gitrupt/hooks/pre_commit.py +103 -0
- gitrupt/hooks/pre_push.py +178 -0
- gitrupt/models.py +190 -0
- gitrupt/policy.py +36 -0
- gitrupt/reporting.py +316 -0
- gitrupt/risk.py +197 -0
- gitrupt/scanner.py +117 -0
- gitrupt/scanners/__init__.py +17 -0
- gitrupt/scanners/adapters.py +166 -0
- gitrupt/scanners/base.py +113 -0
- gitrupt/scanners/binaries.py +185 -0
- gitrupt/scanners/code_rules/__init__.py +36 -0
- gitrupt/scanners/code_rules/base.py +27 -0
- gitrupt/scanners/code_rules/go.py +65 -0
- gitrupt/scanners/code_rules/javascript.py +106 -0
- gitrupt/scanners/code_rules/php.py +71 -0
- gitrupt/scanners/code_rules/powershell.py +85 -0
- gitrupt/scanners/code_rules/python.py +153 -0
- gitrupt/scanners/code_rules/ruby.py +76 -0
- gitrupt/scanners/code_rules/rust.py +41 -0
- gitrupt/scanners/code_rules/shell.py +112 -0
- gitrupt/scanners/dependencies.py +244 -0
- gitrupt/scanners/ecosystems/__init__.py +30 -0
- gitrupt/scanners/ecosystems/base.py +60 -0
- gitrupt/scanners/ecosystems/node.py +128 -0
- gitrupt/scanners/ecosystems/python.py +157 -0
- gitrupt/scanners/entropy.py +123 -0
- gitrupt/scanners/forbidden_files.py +201 -0
- gitrupt/scanners/malware.py +219 -0
- gitrupt/scanners/osv_client.py +221 -0
- gitrupt/scanners/registry.py +66 -0
- gitrupt/scanners/secret_rules.py +368 -0
- gitrupt/scanners/secrets.py +558 -0
- gitrupt/scanners/suspicious_code.py +208 -0
- gitrupt/scanners/yara_loader.py +65 -0
- gitrupt/scanners/yara_rules_builtin.py +141 -0
- gitrupt-0.1.0.dist-info/METADATA +342 -0
- gitrupt-0.1.0.dist-info/RECORD +48 -0
- gitrupt-0.1.0.dist-info/WHEEL +5 -0
- gitrupt-0.1.0.dist-info/entry_points.txt +2 -0
- gitrupt-0.1.0.dist-info/licenses/LICENSE +23 -0
- gitrupt-0.1.0.dist-info/top_level.txt +1 -0
gitrupt/config.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for Gitrupt.
|
|
3
|
+
|
|
4
|
+
Reads .gitrupt.yml from the repository root and provides
|
|
5
|
+
a validated, typed configuration object.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
16
|
+
|
|
17
|
+
from gitrupt.models import PolicyAction, Severity
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# Default configuration values
|
|
22
|
+
DEFAULT_FORBIDDEN_PATHS: list[str] = [
|
|
23
|
+
".env",
|
|
24
|
+
".env.*",
|
|
25
|
+
"*.pem",
|
|
26
|
+
"*.key",
|
|
27
|
+
"*.p12",
|
|
28
|
+
"*.pfx",
|
|
29
|
+
"credentials.json",
|
|
30
|
+
"service-account.json",
|
|
31
|
+
"*.secret",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
DEFAULT_FORBIDDEN_EXTENSIONS: list[str] = [
|
|
35
|
+
".scr",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
DEFAULT_ALLOW_PATHS: list[str] = [
|
|
39
|
+
".env.example",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
CONFIG_FILENAME = ".gitrupt.yml"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ScanConfig(BaseModel):
|
|
46
|
+
"""Which scanners to run."""
|
|
47
|
+
|
|
48
|
+
secrets: bool = True
|
|
49
|
+
threats: bool = True
|
|
50
|
+
suspicious_code: bool = False
|
|
51
|
+
dependencies: bool = False
|
|
52
|
+
|
|
53
|
+
model_config = {"frozen": True}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EntropyConfig(BaseModel):
|
|
58
|
+
enabled: bool = True
|
|
59
|
+
threshold: float = Field(default=4.5, ge=0.0, le=8.0)
|
|
60
|
+
min_length: int = Field(default=20, ge=8)
|
|
61
|
+
|
|
62
|
+
model_config = {"frozen": True}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class RulesConfig(BaseModel):
|
|
68
|
+
"""File and path rules."""
|
|
69
|
+
|
|
70
|
+
forbidden_paths: list[str] = Field(default_factory=lambda: list(DEFAULT_FORBIDDEN_PATHS))
|
|
71
|
+
forbidden_extensions: list[str] = Field(
|
|
72
|
+
default_factory=lambda: list(DEFAULT_FORBIDDEN_EXTENSIONS)
|
|
73
|
+
)
|
|
74
|
+
max_file_size_mb: float = 50.0
|
|
75
|
+
|
|
76
|
+
model_config = {"frozen": True}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class AllowConfig(BaseModel):
|
|
80
|
+
"""Allowlisted paths that bypass rules."""
|
|
81
|
+
|
|
82
|
+
paths: list[str] = Field(default_factory=lambda: list(DEFAULT_ALLOW_PATHS))
|
|
83
|
+
|
|
84
|
+
model_config = {"frozen": True}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SuppressionConfig(BaseModel):
|
|
88
|
+
"""Suppression and allowlist settings for findings."""
|
|
89
|
+
|
|
90
|
+
rules: list[str] = Field(default_factory=list)
|
|
91
|
+
files: list[str] = Field(default_factory=list)
|
|
92
|
+
paths: list[str] = Field(default_factory=list)
|
|
93
|
+
|
|
94
|
+
model_config = {"frozen": True}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PolicyConfig(BaseModel):
|
|
98
|
+
"""Severity → action mapping."""
|
|
99
|
+
|
|
100
|
+
low: PolicyAction = PolicyAction.ALLOW
|
|
101
|
+
medium: PolicyAction = PolicyAction.WARN
|
|
102
|
+
high: PolicyAction = PolicyAction.BLOCK
|
|
103
|
+
critical: PolicyAction = PolicyAction.BLOCK
|
|
104
|
+
|
|
105
|
+
model_config = {"frozen": True}
|
|
106
|
+
|
|
107
|
+
def action_for(self, severity: Severity) -> PolicyAction:
|
|
108
|
+
return {
|
|
109
|
+
Severity.LOW: self.low,
|
|
110
|
+
Severity.MEDIUM: self.medium,
|
|
111
|
+
Severity.HIGH: self.high,
|
|
112
|
+
Severity.CRITICAL: self.critical,
|
|
113
|
+
}[severity]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SuspiciousCodeConfig(BaseModel):
|
|
117
|
+
"""Tuning for the native suspicious-code scanner."""
|
|
118
|
+
|
|
119
|
+
min_confidence: float = Field(default=0.6, ge=0.0, le=1.0)
|
|
120
|
+
languages: list[str] = Field(
|
|
121
|
+
default_factory=lambda: [
|
|
122
|
+
"python",
|
|
123
|
+
"javascript",
|
|
124
|
+
"shell",
|
|
125
|
+
"powershell",
|
|
126
|
+
"php",
|
|
127
|
+
"ruby",
|
|
128
|
+
"go",
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
model_config = {"frozen": True}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class MalwareConfig(BaseModel):
|
|
139
|
+
"""Tuning for the native malware scanner."""
|
|
140
|
+
|
|
141
|
+
use_yara: bool = True
|
|
142
|
+
max_file_size_mb: float = Field(default=50.0, gt=0.0)
|
|
143
|
+
custom_yara_dirs: list[str] = Field(default_factory=list)
|
|
144
|
+
|
|
145
|
+
model_config = {"frozen": True}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class DependenciesConfig(BaseModel):
|
|
151
|
+
"""Tuning for the native dependency scanner."""
|
|
152
|
+
|
|
153
|
+
ecosystems: list[str] = Field(default_factory=lambda: ["python", "node"])
|
|
154
|
+
offline: bool = False
|
|
155
|
+
timeout_seconds: float = Field(default=10.0, gt=0.0)
|
|
156
|
+
cache_ttl_hours: int = Field(default=24, ge=0)
|
|
157
|
+
|
|
158
|
+
model_config = {"frozen": True}
|
|
159
|
+
|
|
160
|
+
class HooksConfig(BaseModel):
|
|
161
|
+
"""Which Git hooks Gitrupt installs and honors."""
|
|
162
|
+
|
|
163
|
+
pre_commit: bool = True
|
|
164
|
+
pre_push: bool = True
|
|
165
|
+
|
|
166
|
+
model_config = {"frozen": True}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class GitruptConfig(BaseModel):
|
|
172
|
+
"""
|
|
173
|
+
Top-level Gitrupt configuration.
|
|
174
|
+
|
|
175
|
+
Read from .gitrupt.yml in the repository root.
|
|
176
|
+
Falls back to safe defaults if no file is present.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
version: int = 1
|
|
180
|
+
mode: str = "strict"
|
|
181
|
+
scan: ScanConfig = Field(default_factory=ScanConfig)
|
|
182
|
+
rules: RulesConfig = Field(default_factory=RulesConfig)
|
|
183
|
+
allow: AllowConfig = Field(default_factory=AllowConfig)
|
|
184
|
+
suppressions: SuppressionConfig = Field(default_factory=SuppressionConfig)
|
|
185
|
+
suspicious_code: SuspiciousCodeConfig = Field(default_factory=SuspiciousCodeConfig)
|
|
186
|
+
malware: MalwareConfig = Field(default_factory=MalwareConfig)
|
|
187
|
+
dependencies: DependenciesConfig = Field(default_factory=DependenciesConfig)
|
|
188
|
+
hooks: HooksConfig = Field(default_factory=HooksConfig)
|
|
189
|
+
entropy: EntropyConfig = Field(default_factory=EntropyConfig)
|
|
190
|
+
policy: PolicyConfig = Field(default_factory=PolicyConfig)
|
|
191
|
+
model_config = {"frozen": True}
|
|
192
|
+
|
|
193
|
+
@field_validator("version")
|
|
194
|
+
@classmethod
|
|
195
|
+
def validate_version(cls, v: int) -> int:
|
|
196
|
+
if v != 1:
|
|
197
|
+
raise ValueError(f"Unsupported configuration version: {v}. Only version 1 is supported.")
|
|
198
|
+
return v
|
|
199
|
+
|
|
200
|
+
@field_validator("mode")
|
|
201
|
+
@classmethod
|
|
202
|
+
def validate_mode(cls, v: str) -> str:
|
|
203
|
+
valid_modes = {"strict", "permissive", "warn-only"}
|
|
204
|
+
if v not in valid_modes:
|
|
205
|
+
raise ValueError(f"Invalid mode '{v}'. Must be one of: {', '.join(valid_modes)}")
|
|
206
|
+
return v
|
|
207
|
+
|
|
208
|
+
@model_validator(mode="after")
|
|
209
|
+
def apply_mode_overrides(self) -> "GitruptConfig":
|
|
210
|
+
"""In warn-only mode, override all blocking policies to warn."""
|
|
211
|
+
if self.mode == "warn-only":
|
|
212
|
+
object.__setattr__(
|
|
213
|
+
self,
|
|
214
|
+
"policy",
|
|
215
|
+
PolicyConfig(
|
|
216
|
+
low=PolicyAction.ALLOW,
|
|
217
|
+
medium=PolicyAction.WARN,
|
|
218
|
+
high=PolicyAction.WARN,
|
|
219
|
+
critical=PolicyAction.WARN,
|
|
220
|
+
),
|
|
221
|
+
)
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def load_config(repo_root: str | Path) -> GitruptConfig:
|
|
226
|
+
"""
|
|
227
|
+
Load Gitrupt configuration from the repository root.
|
|
228
|
+
|
|
229
|
+
If no .gitrupt.yml exists, returns a safe default configuration.
|
|
230
|
+
If the file is malformed, raises a descriptive error.
|
|
231
|
+
"""
|
|
232
|
+
config_path = Path(repo_root) / CONFIG_FILENAME
|
|
233
|
+
|
|
234
|
+
if not config_path.exists():
|
|
235
|
+
logger.debug("No .gitrupt.yml found, using defaults")
|
|
236
|
+
return GitruptConfig()
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
with config_path.open("r", encoding="utf-8") as f:
|
|
240
|
+
raw: dict[str, Any] = yaml.safe_load(f) or {}
|
|
241
|
+
except yaml.YAMLError as e:
|
|
242
|
+
raise ConfigurationError(f"Invalid YAML in {config_path}: {e}") from e
|
|
243
|
+
except OSError as e:
|
|
244
|
+
raise ConfigurationError(f"Cannot read {config_path}: {e}") from e
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
config = GitruptConfig.model_validate(raw)
|
|
248
|
+
except Exception as e:
|
|
249
|
+
raise ConfigurationError(f"Invalid configuration in {config_path}: {e}") from e
|
|
250
|
+
|
|
251
|
+
logger.debug("Loaded configuration from %s", config_path)
|
|
252
|
+
return config
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def find_config_path(start: str | Path) -> Path | None:
|
|
256
|
+
"""Walk up from start looking for .gitrupt.yml."""
|
|
257
|
+
current = Path(start).resolve()
|
|
258
|
+
for directory in [current, *current.parents]:
|
|
259
|
+
candidate = directory / CONFIG_FILENAME
|
|
260
|
+
if candidate.exists():
|
|
261
|
+
return candidate
|
|
262
|
+
# Stop at filesystem root
|
|
263
|
+
if directory == directory.parent:
|
|
264
|
+
break
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class ConfigurationError(Exception):
|
|
269
|
+
"""Raised when the Gitrupt configuration is invalid."""
|