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.
Files changed (48) hide show
  1. gitrupt/__init__.py +13 -0
  2. gitrupt/cli.py +546 -0
  3. gitrupt/config.py +269 -0
  4. gitrupt/git.py +590 -0
  5. gitrupt/hooks/__init__.py +7 -0
  6. gitrupt/hooks/install.py +255 -0
  7. gitrupt/hooks/pre_commit.py +103 -0
  8. gitrupt/hooks/pre_push.py +178 -0
  9. gitrupt/models.py +190 -0
  10. gitrupt/policy.py +36 -0
  11. gitrupt/reporting.py +316 -0
  12. gitrupt/risk.py +197 -0
  13. gitrupt/scanner.py +117 -0
  14. gitrupt/scanners/__init__.py +17 -0
  15. gitrupt/scanners/adapters.py +166 -0
  16. gitrupt/scanners/base.py +113 -0
  17. gitrupt/scanners/binaries.py +185 -0
  18. gitrupt/scanners/code_rules/__init__.py +36 -0
  19. gitrupt/scanners/code_rules/base.py +27 -0
  20. gitrupt/scanners/code_rules/go.py +65 -0
  21. gitrupt/scanners/code_rules/javascript.py +106 -0
  22. gitrupt/scanners/code_rules/php.py +71 -0
  23. gitrupt/scanners/code_rules/powershell.py +85 -0
  24. gitrupt/scanners/code_rules/python.py +153 -0
  25. gitrupt/scanners/code_rules/ruby.py +76 -0
  26. gitrupt/scanners/code_rules/rust.py +41 -0
  27. gitrupt/scanners/code_rules/shell.py +112 -0
  28. gitrupt/scanners/dependencies.py +244 -0
  29. gitrupt/scanners/ecosystems/__init__.py +30 -0
  30. gitrupt/scanners/ecosystems/base.py +60 -0
  31. gitrupt/scanners/ecosystems/node.py +128 -0
  32. gitrupt/scanners/ecosystems/python.py +157 -0
  33. gitrupt/scanners/entropy.py +123 -0
  34. gitrupt/scanners/forbidden_files.py +201 -0
  35. gitrupt/scanners/malware.py +219 -0
  36. gitrupt/scanners/osv_client.py +221 -0
  37. gitrupt/scanners/registry.py +66 -0
  38. gitrupt/scanners/secret_rules.py +368 -0
  39. gitrupt/scanners/secrets.py +558 -0
  40. gitrupt/scanners/suspicious_code.py +208 -0
  41. gitrupt/scanners/yara_loader.py +65 -0
  42. gitrupt/scanners/yara_rules_builtin.py +141 -0
  43. gitrupt-0.1.0.dist-info/METADATA +342 -0
  44. gitrupt-0.1.0.dist-info/RECORD +48 -0
  45. gitrupt-0.1.0.dist-info/WHEEL +5 -0
  46. gitrupt-0.1.0.dist-info/entry_points.txt +2 -0
  47. gitrupt-0.1.0.dist-info/licenses/LICENSE +23 -0
  48. 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