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
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secret scanner.
|
|
3
|
+
|
|
4
|
+
Detects secrets in staged file content using:
|
|
5
|
+
- Curated legacy patterns (SECRET_PATTERNS) — provider-specific, high-specificity
|
|
6
|
+
- Private-key block detection (PRIVATE_KEY_RULE)
|
|
7
|
+
- Provider-specific rules (PROVIDER_RULES) — AI, payments, comms, cloud, tooling
|
|
8
|
+
- Shannon-entropy analysis with context-aware scoring
|
|
9
|
+
|
|
10
|
+
Works on raw text content — language-agnostic.
|
|
11
|
+
|
|
12
|
+
Secrets are NEVER logged in full. All evidence is redacted.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import logging
|
|
18
|
+
import math
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from gitrupt.models import Finding, ScanTarget, Severity
|
|
23
|
+
from gitrupt.scanners.base import Scanner
|
|
24
|
+
from gitrupt.scanners.entropy import find_entropy_secrets
|
|
25
|
+
from gitrupt.scanners.secret_rules import (
|
|
26
|
+
CONTEXT_WINDOW,
|
|
27
|
+
PRIVATE_KEY_RULE,
|
|
28
|
+
PROVIDER_RULES,
|
|
29
|
+
SecretRule,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
# Maximum file size to scan for secrets (bytes). Large files are skipped.
|
|
35
|
+
MAX_CONTENT_SIZE = 5 * 1024 * 1024 # 5 MB
|
|
36
|
+
|
|
37
|
+
# Legacy entropy thresholds (kept for backward compatibility with earlier tests)
|
|
38
|
+
ENTROPY_THRESHOLD_HIGH = 4.5
|
|
39
|
+
ENTROPY_THRESHOLD_CRITICAL = 5.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SecretPattern:
|
|
44
|
+
"""A pattern for detecting a specific type of secret."""
|
|
45
|
+
|
|
46
|
+
rule_id: str
|
|
47
|
+
name: str
|
|
48
|
+
pattern: re.Pattern[str]
|
|
49
|
+
severity: Severity
|
|
50
|
+
confidence: float
|
|
51
|
+
description: str
|
|
52
|
+
recommendation: str
|
|
53
|
+
# Group index that contains the actual secret value (for redaction).
|
|
54
|
+
# 0 means use the whole match.
|
|
55
|
+
secret_group: int = 0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _compile(pattern: str) -> re.Pattern[str]:
|
|
59
|
+
return re.compile(pattern, re.IGNORECASE | re.MULTILINE)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
# Curated legacy patterns
|
|
64
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
SECRET_PATTERNS: list[SecretPattern] = [
|
|
67
|
+
# ── Private Keys ─────────────────────────────────────────────────────────
|
|
68
|
+
SecretPattern(
|
|
69
|
+
rule_id="private-key-pem",
|
|
70
|
+
name="PEM Private Key",
|
|
71
|
+
pattern=_compile(r"-----BEGIN\s+(?:RSA|EC|DSA|OPENSSH|PGP|ENCRYPTED)\s+PRIVATE KEY-----"),
|
|
72
|
+
severity=Severity.CRITICAL,
|
|
73
|
+
confidence=0.99,
|
|
74
|
+
description="PEM-encoded private key detected in file content",
|
|
75
|
+
recommendation="Remove the private key and rotate/revoke it immediately. Store keys in a secure vault.",
|
|
76
|
+
secret_group=0,
|
|
77
|
+
),
|
|
78
|
+
SecretPattern(
|
|
79
|
+
rule_id="private-key-generic",
|
|
80
|
+
name="Private Key (Generic)",
|
|
81
|
+
pattern=_compile(r"-----BEGIN\s+(?:[A-Z0-9\s_-]+)?PRIVATE KEY-----"),
|
|
82
|
+
severity=Severity.CRITICAL,
|
|
83
|
+
confidence=0.99,
|
|
84
|
+
description="PEM-encoded private key block detected",
|
|
85
|
+
recommendation="Remove the private key from this file and use a secrets manager.",
|
|
86
|
+
secret_group=0,
|
|
87
|
+
),
|
|
88
|
+
|
|
89
|
+
# ── AWS Credentials ──────────────────────────────────────────────────────
|
|
90
|
+
SecretPattern(
|
|
91
|
+
rule_id="aws-access-key-id",
|
|
92
|
+
name="AWS Access Key ID",
|
|
93
|
+
pattern=_compile(r"\b(AKIA[0-9A-Z]{16})\b"),
|
|
94
|
+
severity=Severity.CRITICAL,
|
|
95
|
+
confidence=0.97,
|
|
96
|
+
description="AWS Access Key ID pattern detected",
|
|
97
|
+
recommendation="Revoke this AWS key immediately. Use IAM roles or environment variables.",
|
|
98
|
+
secret_group=1,
|
|
99
|
+
),
|
|
100
|
+
SecretPattern(
|
|
101
|
+
rule_id="aws-secret-access-key",
|
|
102
|
+
name="AWS Secret Access Key",
|
|
103
|
+
pattern=_compile(
|
|
104
|
+
r"(?:aws[_\-\s]?secret[_\-\s]?(?:access[_\-\s]?)?key|aws[_\-\s]?key)"
|
|
105
|
+
r"\s*[=:\"'`]\s*([A-Za-z0-9/+=]{40})\b"
|
|
106
|
+
),
|
|
107
|
+
severity=Severity.CRITICAL,
|
|
108
|
+
confidence=0.92,
|
|
109
|
+
description="Possible AWS Secret Access Key detected",
|
|
110
|
+
recommendation="Revoke this AWS key immediately. Use IAM roles or environment variables.",
|
|
111
|
+
secret_group=1,
|
|
112
|
+
),
|
|
113
|
+
|
|
114
|
+
# ── GitHub Tokens ────────────────────────────────────────────────────────
|
|
115
|
+
SecretPattern(
|
|
116
|
+
rule_id="github-pat-classic",
|
|
117
|
+
name="GitHub Personal Access Token (Classic)",
|
|
118
|
+
pattern=_compile(r"\b(ghp_[A-Za-z0-9]{36})\b"),
|
|
119
|
+
severity=Severity.CRITICAL,
|
|
120
|
+
confidence=0.99,
|
|
121
|
+
description="GitHub Personal Access Token (classic) detected",
|
|
122
|
+
recommendation="Revoke this token at github.com/settings/tokens",
|
|
123
|
+
secret_group=1,
|
|
124
|
+
),
|
|
125
|
+
SecretPattern(
|
|
126
|
+
rule_id="github-pat-fine-grained",
|
|
127
|
+
name="GitHub Fine-Grained PAT",
|
|
128
|
+
pattern=_compile(r"\b(github_pat_[A-Za-z0-9_]{82})\b"),
|
|
129
|
+
severity=Severity.CRITICAL,
|
|
130
|
+
confidence=0.99,
|
|
131
|
+
description="GitHub fine-grained personal access token detected",
|
|
132
|
+
recommendation="Revoke this token at github.com/settings/tokens",
|
|
133
|
+
secret_group=1,
|
|
134
|
+
),
|
|
135
|
+
SecretPattern(
|
|
136
|
+
rule_id="github-oauth-token",
|
|
137
|
+
name="GitHub OAuth Token",
|
|
138
|
+
pattern=_compile(r"\b(gho_[A-Za-z0-9]{36})\b"),
|
|
139
|
+
severity=Severity.CRITICAL,
|
|
140
|
+
confidence=0.99,
|
|
141
|
+
description="GitHub OAuth token detected",
|
|
142
|
+
recommendation="Revoke this token immediately",
|
|
143
|
+
secret_group=1,
|
|
144
|
+
),
|
|
145
|
+
SecretPattern(
|
|
146
|
+
rule_id="github-actions-token",
|
|
147
|
+
name="GitHub Actions Token",
|
|
148
|
+
pattern=_compile(r"\b(ghs_[A-Za-z0-9]{36})\b"),
|
|
149
|
+
severity=Severity.HIGH,
|
|
150
|
+
confidence=0.99,
|
|
151
|
+
description="GitHub Actions token detected",
|
|
152
|
+
recommendation="This token is short-lived but should not be committed",
|
|
153
|
+
secret_group=1,
|
|
154
|
+
),
|
|
155
|
+
|
|
156
|
+
# ── Generic API Keys & Tokens ────────────────────────────────────────────
|
|
157
|
+
SecretPattern(
|
|
158
|
+
rule_id="generic-api-key",
|
|
159
|
+
name="Generic API Key",
|
|
160
|
+
pattern=_compile(
|
|
161
|
+
r"(?:api[_\-\s]?key|apikey|api[_\-\s]?secret|access[_\-\s]?token)"
|
|
162
|
+
r"\s*[=:\"'`]\s*([A-Za-z0-9_\-]{20,64})"
|
|
163
|
+
),
|
|
164
|
+
severity=Severity.HIGH,
|
|
165
|
+
confidence=0.75,
|
|
166
|
+
description="Possible API key or token assignment detected",
|
|
167
|
+
recommendation="Move this value to environment variables or a secrets manager.",
|
|
168
|
+
secret_group=1,
|
|
169
|
+
),
|
|
170
|
+
|
|
171
|
+
# ── Database URLs ────────────────────────────────────────────────────────
|
|
172
|
+
SecretPattern(
|
|
173
|
+
rule_id="database-url-with-password",
|
|
174
|
+
name="Database URL with Password",
|
|
175
|
+
pattern=_compile(
|
|
176
|
+
r"(?:postgresql|postgres|mysql|mongodb|redis|amqp|mssql|oracle)"
|
|
177
|
+
r"://[^:@\s]+:([^@\s]{6,}@[^\s\"'`]+)"
|
|
178
|
+
),
|
|
179
|
+
severity=Severity.CRITICAL,
|
|
180
|
+
confidence=0.92,
|
|
181
|
+
description="Database connection URL with embedded password detected",
|
|
182
|
+
recommendation="Use environment variables for database credentials. Never hardcode passwords in URLs.",
|
|
183
|
+
secret_group=1,
|
|
184
|
+
),
|
|
185
|
+
|
|
186
|
+
# ── JWT Tokens ───────────────────────────────────────────────────────────
|
|
187
|
+
SecretPattern(
|
|
188
|
+
rule_id="jwt-token",
|
|
189
|
+
name="JWT Token",
|
|
190
|
+
pattern=_compile(r"\b(eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,})\b"),
|
|
191
|
+
severity=Severity.HIGH,
|
|
192
|
+
confidence=0.88,
|
|
193
|
+
description="JSON Web Token detected",
|
|
194
|
+
recommendation="JWT tokens may contain sensitive claims. Do not commit tokens to version control.",
|
|
195
|
+
secret_group=1,
|
|
196
|
+
),
|
|
197
|
+
|
|
198
|
+
# ── Google Credentials ───────────────────────────────────────────────────
|
|
199
|
+
SecretPattern(
|
|
200
|
+
rule_id="google-api-key",
|
|
201
|
+
name="Google API Key",
|
|
202
|
+
pattern=_compile(r"\b(AIza[0-9A-Za-z_\-]{35})\b"),
|
|
203
|
+
severity=Severity.CRITICAL,
|
|
204
|
+
confidence=0.96,
|
|
205
|
+
description="Google API key detected",
|
|
206
|
+
recommendation="Restrict this API key in the Google Cloud Console and revoke/rotate it.",
|
|
207
|
+
secret_group=1,
|
|
208
|
+
),
|
|
209
|
+
SecretPattern(
|
|
210
|
+
rule_id="google-oauth-client-secret",
|
|
211
|
+
name="Google OAuth Client Secret",
|
|
212
|
+
pattern=_compile(r"\"client_secret\"\s*:\s*\"([A-Za-z0-9_\-]{24,})\""),
|
|
213
|
+
severity=Severity.CRITICAL,
|
|
214
|
+
confidence=0.85,
|
|
215
|
+
description="Google OAuth client secret detected",
|
|
216
|
+
recommendation="Revoke and rotate the OAuth client secret.",
|
|
217
|
+
secret_group=1,
|
|
218
|
+
),
|
|
219
|
+
|
|
220
|
+
# ── Slack ────────────────────────────────────────────────────────────────
|
|
221
|
+
SecretPattern(
|
|
222
|
+
rule_id="slack-token",
|
|
223
|
+
name="Slack Token",
|
|
224
|
+
pattern=_compile(r"\b(xox[boaprs]-[0-9A-Za-z\-]{10,})\b"),
|
|
225
|
+
severity=Severity.CRITICAL,
|
|
226
|
+
confidence=0.97,
|
|
227
|
+
description="Slack API token detected",
|
|
228
|
+
recommendation="Revoke this token at api.slack.com/apps",
|
|
229
|
+
secret_group=1,
|
|
230
|
+
),
|
|
231
|
+
SecretPattern(
|
|
232
|
+
rule_id="slack-webhook",
|
|
233
|
+
name="Slack Webhook URL",
|
|
234
|
+
pattern=_compile(r"https://hooks\.slack\.com/services/[A-Z0-9]+/[A-Z0-9]+/[A-Za-z0-9]+"),
|
|
235
|
+
severity=Severity.HIGH,
|
|
236
|
+
confidence=0.97,
|
|
237
|
+
description="Slack incoming webhook URL detected",
|
|
238
|
+
recommendation="Regenerate this webhook URL",
|
|
239
|
+
secret_group=0,
|
|
240
|
+
),
|
|
241
|
+
|
|
242
|
+
# ── Stripe ───────────────────────────────────────────────────────────────
|
|
243
|
+
SecretPattern(
|
|
244
|
+
rule_id="stripe-secret-key",
|
|
245
|
+
name="Stripe Secret Key",
|
|
246
|
+
pattern=_compile(r"\b(sk_(?:live|test)_[0-9A-Za-z]{24,})\b"),
|
|
247
|
+
severity=Severity.CRITICAL,
|
|
248
|
+
confidence=0.99,
|
|
249
|
+
description="Stripe secret API key detected",
|
|
250
|
+
recommendation="Revoke this key at dashboard.stripe.com/apikeys",
|
|
251
|
+
secret_group=1,
|
|
252
|
+
),
|
|
253
|
+
|
|
254
|
+
# ── Generic password assignments ────────────────────────────────────────
|
|
255
|
+
SecretPattern(
|
|
256
|
+
rule_id="hardcoded-password",
|
|
257
|
+
name="Hardcoded Password",
|
|
258
|
+
pattern=_compile(
|
|
259
|
+
r"(?:password|passwd|pwd)\s*[=:\"'`]\s*[\"']([^\"'\s]{8,})[\"']"
|
|
260
|
+
),
|
|
261
|
+
severity=Severity.HIGH,
|
|
262
|
+
confidence=0.72,
|
|
263
|
+
description="Possible hardcoded password detected",
|
|
264
|
+
recommendation="Replace hardcoded passwords with environment variables or a secrets manager.",
|
|
265
|
+
secret_group=1,
|
|
266
|
+
),
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
271
|
+
# Scanner
|
|
272
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class SecretScanner(Scanner):
|
|
276
|
+
"""
|
|
277
|
+
Scans staged file content for secrets and credentials.
|
|
278
|
+
|
|
279
|
+
Four detection passes per added line:
|
|
280
|
+
1. SECRET_PATTERNS — curated legacy patterns (specific, low FP)
|
|
281
|
+
2. PRIVATE_KEY_RULE — PEM private-key blocks
|
|
282
|
+
3. PROVIDER_RULES — AI providers, payments, comms, cloud, tooling
|
|
283
|
+
4. Entropy — Shannon entropy with context-aware scoring
|
|
284
|
+
|
|
285
|
+
All evidence is redacted before it ever reaches a Finding.
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def name(self) -> str:
|
|
290
|
+
return "secrets"
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def description(self) -> str:
|
|
294
|
+
return "Detects secrets, credentials, and API keys in file content"
|
|
295
|
+
|
|
296
|
+
def scan(self, target: ScanTarget) -> list[Finding]:
|
|
297
|
+
findings: list[Finding] = []
|
|
298
|
+
|
|
299
|
+
for staged_file in target.staged_files:
|
|
300
|
+
if staged_file.status == "D":
|
|
301
|
+
continue
|
|
302
|
+
if staged_file.is_binary:
|
|
303
|
+
continue
|
|
304
|
+
if staged_file.size_bytes > MAX_CONTENT_SIZE:
|
|
305
|
+
logger.warning(
|
|
306
|
+
"Skipping secret scan of %s: file too large (%d bytes)",
|
|
307
|
+
staged_file.path,
|
|
308
|
+
staged_file.size_bytes,
|
|
309
|
+
)
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
file_findings = self._scan_diff_for_file(
|
|
313
|
+
target.staged_diff, staged_file.path
|
|
314
|
+
)
|
|
315
|
+
findings.extend(file_findings)
|
|
316
|
+
|
|
317
|
+
return findings
|
|
318
|
+
|
|
319
|
+
# ── Internals ────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
def _scan_diff_for_file(self, diff: str, file_path: str) -> list[Finding]:
|
|
322
|
+
"""Extract added lines from the diff for a specific file and scan them."""
|
|
323
|
+
added_lines = _extract_added_lines(diff, file_path)
|
|
324
|
+
if not added_lines:
|
|
325
|
+
return []
|
|
326
|
+
|
|
327
|
+
findings: list[Finding] = []
|
|
328
|
+
# Dedup key is (rule_id, file_path) — one finding per rule per file,
|
|
329
|
+
# to avoid flooding when a config file contains many similar values.
|
|
330
|
+
seen: set[tuple[str, str]] = set()
|
|
331
|
+
|
|
332
|
+
for line_number, line_content in added_lines:
|
|
333
|
+
|
|
334
|
+
# ── Pass 1: curated legacy patterns ─────────────────────────────
|
|
335
|
+
for sp in SECRET_PATTERNS:
|
|
336
|
+
match = sp.pattern.search(line_content)
|
|
337
|
+
if not match:
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
key = (sp.rule_id, file_path)
|
|
341
|
+
if key in seen:
|
|
342
|
+
continue
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
secret_value = (
|
|
346
|
+
match.group(sp.secret_group)
|
|
347
|
+
if sp.secret_group > 0
|
|
348
|
+
else match.group(0)
|
|
349
|
+
)
|
|
350
|
+
except IndexError:
|
|
351
|
+
secret_value = match.group(0)
|
|
352
|
+
|
|
353
|
+
seen.add(key)
|
|
354
|
+
findings.append(
|
|
355
|
+
Finding(
|
|
356
|
+
scanner=self.name,
|
|
357
|
+
rule_id=sp.rule_id,
|
|
358
|
+
severity=sp.severity,
|
|
359
|
+
confidence=sp.confidence,
|
|
360
|
+
file=file_path,
|
|
361
|
+
line=line_number,
|
|
362
|
+
message=f"{sp.name} detected in {file_path}",
|
|
363
|
+
description=sp.description,
|
|
364
|
+
evidence=_redact(secret_value),
|
|
365
|
+
recommendation=sp.recommendation,
|
|
366
|
+
can_override=False,
|
|
367
|
+
)
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# ── Pass 2: private-key block ───────────────────────────────────
|
|
371
|
+
pk_match = PRIVATE_KEY_RULE.pattern.search(line_content)
|
|
372
|
+
if pk_match:
|
|
373
|
+
key = (PRIVATE_KEY_RULE.rule_id, file_path)
|
|
374
|
+
if key not in seen:
|
|
375
|
+
seen.add(key)
|
|
376
|
+
findings.append(
|
|
377
|
+
_rule_to_finding(
|
|
378
|
+
PRIVATE_KEY_RULE,
|
|
379
|
+
file_path,
|
|
380
|
+
line_number,
|
|
381
|
+
line_content,
|
|
382
|
+
match=pk_match,
|
|
383
|
+
)
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
# ── Pass 3: provider-specific rules ─────────────────────────────
|
|
387
|
+
for rule in PROVIDER_RULES:
|
|
388
|
+
match = rule.pattern.search(line_content)
|
|
389
|
+
if not match:
|
|
390
|
+
continue
|
|
391
|
+
if not _context_ok(line_content, match, rule):
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
key = (rule.rule_id, file_path)
|
|
395
|
+
if key in seen:
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
seen.add(key)
|
|
399
|
+
findings.append(
|
|
400
|
+
_rule_to_finding(
|
|
401
|
+
rule,
|
|
402
|
+
file_path,
|
|
403
|
+
line_number,
|
|
404
|
+
line_content,
|
|
405
|
+
match=match,
|
|
406
|
+
)
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# ── Pass 4: entropy ─────────────────────────────────────────────
|
|
410
|
+
for hit in find_entropy_secrets(line_content):
|
|
411
|
+
key = ("generic-high-entropy", file_path)
|
|
412
|
+
if key in seen:
|
|
413
|
+
continue
|
|
414
|
+
|
|
415
|
+
seen.add(key)
|
|
416
|
+
findings.append(
|
|
417
|
+
Finding(
|
|
418
|
+
scanner=self.name,
|
|
419
|
+
rule_id="generic-high-entropy",
|
|
420
|
+
severity=Severity.MEDIUM,
|
|
421
|
+
confidence=(
|
|
422
|
+
0.7 if hit.reason == "context-assisted" else 0.55
|
|
423
|
+
),
|
|
424
|
+
file=file_path,
|
|
425
|
+
line=line_number,
|
|
426
|
+
message="High-entropy string — possible secret",
|
|
427
|
+
description=(
|
|
428
|
+
f"Entropy {hit.entropy:.2f} bits/char ({hit.reason}). "
|
|
429
|
+
"May be an unrecognised API key or token."
|
|
430
|
+
),
|
|
431
|
+
evidence=_redact(hit.value),
|
|
432
|
+
recommendation=(
|
|
433
|
+
"Verify whether this is a secret. If it is, move it "
|
|
434
|
+
"to an environment variable and add the shape to the "
|
|
435
|
+
".gitrupt.yml allowlist."
|
|
436
|
+
),
|
|
437
|
+
can_override=True,
|
|
438
|
+
)
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return findings
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
445
|
+
# Helpers
|
|
446
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _context_ok(line: str, match: re.Match[str], rule: SecretRule) -> bool:
|
|
450
|
+
"""
|
|
451
|
+
True if the rule has no context requirement, or if one of its context
|
|
452
|
+
words appears within CONTEXT_WINDOW characters of the match.
|
|
453
|
+
"""
|
|
454
|
+
if not rule.context_words:
|
|
455
|
+
return True
|
|
456
|
+
start = max(0, match.start() - CONTEXT_WINDOW)
|
|
457
|
+
end = min(len(line), match.end() + CONTEXT_WINDOW)
|
|
458
|
+
window = line[start:end].lower()
|
|
459
|
+
return any(w.lower() in window for w in rule.context_words)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _rule_to_finding(
|
|
463
|
+
rule: SecretRule,
|
|
464
|
+
file_path: str,
|
|
465
|
+
line_number: int,
|
|
466
|
+
line_content: str,
|
|
467
|
+
match: re.Match[str] | None = None,
|
|
468
|
+
) -> Finding:
|
|
469
|
+
"""Convert a SecretRule match into a Finding with redacted evidence."""
|
|
470
|
+
if match is None:
|
|
471
|
+
match = rule.pattern.search(line_content)
|
|
472
|
+
raw_value = match.group(0) if match else line_content
|
|
473
|
+
return Finding(
|
|
474
|
+
scanner="secrets",
|
|
475
|
+
rule_id=rule.rule_id,
|
|
476
|
+
severity=rule.severity,
|
|
477
|
+
confidence=rule.confidence,
|
|
478
|
+
file=file_path,
|
|
479
|
+
line=line_number,
|
|
480
|
+
message=rule.message,
|
|
481
|
+
description=rule.description,
|
|
482
|
+
evidence=_redact(raw_value),
|
|
483
|
+
recommendation=rule.recommendation,
|
|
484
|
+
can_override=False,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _extract_added_lines(diff: str, file_path: str) -> list[tuple[int, str]]:
|
|
489
|
+
"""
|
|
490
|
+
Extract lines that were added (starting with '+') for a specific file
|
|
491
|
+
from a unified diff.
|
|
492
|
+
|
|
493
|
+
Returns a list of (line_number, content) tuples.
|
|
494
|
+
"""
|
|
495
|
+
if not diff:
|
|
496
|
+
return []
|
|
497
|
+
|
|
498
|
+
lines: list[tuple[int, str]] = []
|
|
499
|
+
in_target_file = False
|
|
500
|
+
current_line = 0
|
|
501
|
+
|
|
502
|
+
for raw_line in diff.splitlines():
|
|
503
|
+
if raw_line.startswith("diff --git "):
|
|
504
|
+
in_target_file = (
|
|
505
|
+
f"b/{file_path}" in raw_line or raw_line.endswith(file_path)
|
|
506
|
+
)
|
|
507
|
+
current_line = 0
|
|
508
|
+
continue
|
|
509
|
+
|
|
510
|
+
if not in_target_file:
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
if raw_line.startswith("@@"):
|
|
514
|
+
match = re.search(r"\+(\d+)(?:,\d+)?", raw_line)
|
|
515
|
+
if match:
|
|
516
|
+
current_line = int(match.group(1)) - 1
|
|
517
|
+
continue
|
|
518
|
+
|
|
519
|
+
if raw_line.startswith("+") and not raw_line.startswith("+++"):
|
|
520
|
+
current_line += 1
|
|
521
|
+
lines.append((current_line, raw_line[1:]))
|
|
522
|
+
elif raw_line.startswith(" "):
|
|
523
|
+
current_line += 1
|
|
524
|
+
|
|
525
|
+
return lines
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _redact(secret: str) -> str:
|
|
529
|
+
"""
|
|
530
|
+
Redact a secret value for safe display.
|
|
531
|
+
|
|
532
|
+
Shows the first few characters followed by bullets.
|
|
533
|
+
Never shows the full value.
|
|
534
|
+
"""
|
|
535
|
+
if not secret:
|
|
536
|
+
return "••••••••"
|
|
537
|
+
|
|
538
|
+
visible = min(4, len(secret) // 4)
|
|
539
|
+
hidden = len(secret) - visible
|
|
540
|
+
return secret[:visible] + "•" * min(hidden, 20)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def shannon_entropy(data: str) -> float:
|
|
544
|
+
"""Calculate the Shannon entropy of a string."""
|
|
545
|
+
if not data:
|
|
546
|
+
return 0.0
|
|
547
|
+
|
|
548
|
+
freq: dict[str, int] = {}
|
|
549
|
+
for char in data:
|
|
550
|
+
freq[char] = freq.get(char, 0) + 1
|
|
551
|
+
|
|
552
|
+
entropy = 0.0
|
|
553
|
+
length = len(data)
|
|
554
|
+
for count in freq.values():
|
|
555
|
+
probability = count / length
|
|
556
|
+
entropy -= probability * math.log2(probability)
|
|
557
|
+
|
|
558
|
+
return entropy
|