comit-cli 1.0.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.
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from comit.commit.context import CommitContext
7
+ from comit.review.base import BaseAnalyzer
8
+ from comit.review.models import ReviewFinding, ReviewSeverity
9
+
10
+
11
+ class GitFileAnalyzer(BaseAnalyzer):
12
+ def analyze(self, context: CommitContext) -> List[ReviewFinding]:
13
+ findings: List[ReviewFinding] = []
14
+
15
+ for f in context.changed_files:
16
+ if f.status == "D":
17
+ continue
18
+
19
+ path_obj = Path(f.path)
20
+ parts = [p.lower() for p in path_obj.parts]
21
+ name = path_obj.name.lower()
22
+
23
+ if name == ".git-credentials":
24
+ findings.append(
25
+ ReviewFinding(
26
+ severity=ReviewSeverity.HIGH,
27
+ category="Git Internal File",
28
+ message="Sensitive Git credentials file staged",
29
+ file_path=f.path,
30
+ details="Git credentials files contain plaintext passwords or tokens.",
31
+ )
32
+ )
33
+ elif name == ".gitconfig":
34
+ findings.append(
35
+ ReviewFinding(
36
+ severity=ReviewSeverity.WARNING,
37
+ category="Git Internal File",
38
+ message="Git configuration file staged",
39
+ file_path=f.path,
40
+ details="Local or global Git configuration should usually not be committed to repositories.",
41
+ )
42
+ )
43
+ elif ".git" in parts:
44
+ findings.append(
45
+ ReviewFinding(
46
+ severity=ReviewSeverity.WARNING,
47
+ category="Git Internal File",
48
+ message="Git internal repository directory staged",
49
+ file_path=f.path,
50
+ details="Files inside .git/ must not be tracked in repository trees.",
51
+ )
52
+ )
53
+
54
+ return findings
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List
4
+
5
+ from comit.commit.context import CommitContext
6
+ from comit.git import get_staged_file_size
7
+ from comit.review.base import BaseAnalyzer
8
+ from comit.review.models import ReviewFinding, ReviewSeverity
9
+
10
+ DEFAULT_MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024
11
+
12
+
13
+ class LargeFileAnalyzer(BaseAnalyzer):
14
+ def __init__(self, max_size_bytes: int = DEFAULT_MAX_FILE_SIZE_BYTES):
15
+ self.max_size_bytes = max_size_bytes
16
+
17
+ def analyze(self, context: CommitContext) -> List[ReviewFinding]:
18
+ findings: List[ReviewFinding] = []
19
+
20
+ for f in context.changed_files:
21
+ if f.status == "D":
22
+ continue
23
+
24
+ size = get_staged_file_size(f.path)
25
+ if size > self.max_size_bytes:
26
+ size_mb = size / (1024 * 1024)
27
+ threshold_mb = self.max_size_bytes / (1024 * 1024)
28
+ findings.append(
29
+ ReviewFinding(
30
+ severity=ReviewSeverity.WARNING,
31
+ category="Large File",
32
+ message="Large file staged",
33
+ file_path=f.path,
34
+ details=f"File size is {size_mb:.1f} MB (threshold: {threshold_mb:.1f} MB). Consider Git LFS or excluding it.",
35
+ )
36
+ )
37
+
38
+ return findings
comit/review/models.py ADDED
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+ from typing import List, Optional
6
+
7
+ from comit.commit.context import DiffStat
8
+
9
+
10
+ class ReviewSeverity(str, Enum):
11
+ INFO = "INFO"
12
+ WARNING = "WARNING"
13
+ HIGH = "HIGH"
14
+
15
+ @property
16
+ def rank(self) -> int:
17
+ rank_map = {
18
+ ReviewSeverity.INFO: 1,
19
+ ReviewSeverity.WARNING: 2,
20
+ ReviewSeverity.HIGH: 3,
21
+ }
22
+ return rank_map.get(self, 0)
23
+
24
+
25
+ @dataclass
26
+ class ReviewFinding:
27
+ severity: ReviewSeverity
28
+ category: str
29
+ message: str
30
+ file_path: str
31
+ details: Optional[str] = None
32
+ line_number: Optional[int] = None
33
+
34
+
35
+ @dataclass
36
+ class ReviewResult:
37
+ findings: List[ReviewFinding] = field(default_factory=list)
38
+ files_checked: int = 0
39
+ diff_stat: DiffStat = field(default_factory=DiffStat)
40
+ added_files_count: int = 0
41
+ modified_files_count: int = 0
42
+ deleted_files_count: int = 0
43
+ renamed_files_count: int = 0
44
+
45
+ @property
46
+ def has_findings(self) -> bool:
47
+ return bool(self.findings)
48
+
49
+ @property
50
+ def high_count(self) -> int:
51
+ return sum(1 for f in self.findings if f.severity == ReviewSeverity.HIGH)
52
+
53
+ @property
54
+ def warning_count(self) -> int:
55
+ return sum(1 for f in self.findings if f.severity == ReviewSeverity.WARNING)
56
+
57
+ @property
58
+ def info_count(self) -> int:
59
+ return sum(1 for f in self.findings if f.severity == ReviewSeverity.INFO)
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import List, Tuple
4
+
5
+ from comit.commit.context import CommitContext
6
+ from comit.review.base import BaseAnalyzer
7
+ from comit.review.models import ReviewFinding, ReviewSeverity
8
+
9
+ KEY_MARKERS = [
10
+ "-----BEGIN PRIVATE KEY-----",
11
+ "-----BEGIN RSA PRIVATE KEY-----",
12
+ "-----BEGIN EC PRIVATE KEY-----",
13
+ "-----BEGIN OPENSSH PRIVATE KEY-----",
14
+ "-----BEGIN DSA PRIVATE KEY-----",
15
+ "-----BEGIN PGP PRIVATE KEY BLOCK-----",
16
+ ]
17
+
18
+
19
+ class PrivateKeyAnalyzer(BaseAnalyzer):
20
+ def analyze(self, context: CommitContext) -> List[ReviewFinding]:
21
+ findings: List[ReviewFinding] = []
22
+ if not context.staged_diff:
23
+ return findings
24
+
25
+ added_lines = self._extract_added_lines(context.staged_diff)
26
+ flagged_files = set()
27
+
28
+ for file_path, line in added_lines:
29
+ if file_path in flagged_files:
30
+ continue
31
+
32
+ for marker in KEY_MARKERS:
33
+ if marker in line:
34
+ findings.append(
35
+ ReviewFinding(
36
+ severity=ReviewSeverity.HIGH,
37
+ category="Private Key",
38
+ message="Private key detected in staged changes",
39
+ file_path=file_path,
40
+ details="PEM private key header found in added lines.",
41
+ )
42
+ )
43
+ flagged_files.add(file_path)
44
+ break
45
+
46
+ return findings
47
+
48
+ def _extract_added_lines(self, diff: str) -> List[Tuple[str, str]]:
49
+ results: List[Tuple[str, str]] = []
50
+ current_file = "unknown"
51
+
52
+ for raw_line in diff.splitlines():
53
+ if raw_line.startswith("diff --git "):
54
+ parts = raw_line.split()
55
+ if len(parts) >= 4 and parts[3].startswith("b/"):
56
+ current_file = parts[3][2:]
57
+ elif len(parts) >= 3 and parts[2].startswith("a/"):
58
+ current_file = parts[2][2:]
59
+ continue
60
+ elif raw_line.startswith("+++ b/"):
61
+ current_file = raw_line[6:].strip()
62
+ continue
63
+ elif raw_line.startswith("+++ "):
64
+ continue
65
+
66
+ if raw_line.startswith("+") and not raw_line.startswith("+++"):
67
+ results.append((current_file, raw_line[1:]))
68
+
69
+ return results
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import List, Tuple, Optional, Set
6
+
7
+ from comit.commit.context import CommitContext
8
+ from comit.review.base import BaseAnalyzer
9
+ from comit.review.models import ReviewFinding, ReviewSeverity
10
+
11
+ SPECIFIC_SECRET_PATTERNS: List[Tuple[str, re.Pattern[str]]] = [
12
+ ("AWS Access Key ID", re.compile(r"\b(AKIA[0-9A-Z]{16})\b")),
13
+ ("OpenAI API Key", re.compile(r"\b(sk-(?:proj-)?[a-zA-Z0-9_-]{20,})\b")),
14
+ ("Groq API Key", re.compile(r"\b(gsk_[a-zA-Z0-9_-]{20,})\b")),
15
+ ("Google API Key", re.compile(r"\b(AIza[0-9A-Za-z_-]{30,})\b")),
16
+ ("GitHub Personal Access Token", re.compile(r"\b(gh[pousr]_[a-zA-Z0-9]{36})\b")),
17
+ ("GitHub Fine-Grained Token", re.compile(r"\b(github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})\b")),
18
+ ]
19
+
20
+ GENERIC_ASSIGNMENT_PATTERN = re.compile(
21
+ r"""(?i)\b(?:api_key|apikey|secret_key|secret|auth_token|access_token|client_secret|db_password|password|passwd)\s*[:=]\s*["']([^"'\s]{8,})["']"""
22
+ )
23
+
24
+ KNOWN_TEST_DIRS = {
25
+ "tests",
26
+ "test",
27
+ "testing",
28
+ "__tests__",
29
+ "fixtures",
30
+ "fixture",
31
+ "testdata",
32
+ "mocks",
33
+ }
34
+
35
+ TEST_EXTENSIONS = {
36
+ ".spec.js",
37
+ ".spec.ts",
38
+ ".spec.jsx",
39
+ ".spec.tsx",
40
+ ".test.js",
41
+ ".test.ts",
42
+ ".test.jsx",
43
+ ".test.tsx",
44
+ ".test.py",
45
+ }
46
+
47
+ EXACT_IGNORE_VALUES: Set[str] = {
48
+ "password",
49
+ "password123",
50
+ "admin",
51
+ "secret",
52
+ "12345678",
53
+ "1234567890",
54
+ "true",
55
+ "false",
56
+ "none",
57
+ "null",
58
+ "undefined",
59
+ "your_api_key",
60
+ "your-api-key",
61
+ "example-token",
62
+ "fake-secret",
63
+ "test-secret-placeholder",
64
+ }
65
+
66
+ PLACEHOLDER_SUBSTRINGS = (
67
+ "dummy",
68
+ "mock",
69
+ "sample",
70
+ "example",
71
+ "placeholder",
72
+ "changeme",
73
+ "your_",
74
+ "your-",
75
+ "<your",
76
+ "[your",
77
+ "fake-",
78
+ "fake_",
79
+ "test-",
80
+ "test_",
81
+ "********",
82
+ "xxxxxx",
83
+ )
84
+
85
+
86
+ def is_test_or_fixture_path(file_path: str) -> bool:
87
+ norm = file_path.replace("\\", "/").lower()
88
+ p = Path(norm)
89
+ parts = set(p.parts)
90
+ if any(part in KNOWN_TEST_DIRS for part in parts):
91
+ return True
92
+ stem = p.stem.lower()
93
+ name = p.name.lower()
94
+ if stem.startswith("test_") or stem.endswith("_test") or stem.endswith("_fixture") or stem.endswith("_mock"):
95
+ return True
96
+ if any(name.endswith(ext) for ext in TEST_EXTENSIONS):
97
+ return True
98
+ return False
99
+
100
+
101
+ def is_clear_placeholder(value: str) -> bool:
102
+ val_lower = value.strip().lower()
103
+ if val_lower in EXACT_IGNORE_VALUES:
104
+ return True
105
+ if val_lower.startswith(("your_", "your-", "fake-", "fake_", "test-", "test_", "example-", "example_", "mock-", "mock_", "dummy-", "dummy_")):
106
+ return True
107
+ if any(sub in val_lower for sub in PLACEHOLDER_SUBSTRINGS):
108
+ return True
109
+ if val_lower == "akiaiosfodnn7example":
110
+ return True
111
+ return False
112
+
113
+
114
+ def mask_secret(value: str) -> str:
115
+ if len(value) <= 8:
116
+ return "********"
117
+ return f"{value[:3]}...{value[-3:]}"
118
+
119
+
120
+ class SecretAnalyzer(BaseAnalyzer):
121
+ def analyze(self, context: CommitContext) -> List[ReviewFinding]:
122
+ findings: List[ReviewFinding] = []
123
+ if not context.staged_diff:
124
+ return findings
125
+
126
+ added_lines = self._extract_added_lines(context.staged_diff)
127
+
128
+ for file_path, line, line_num in added_lines:
129
+ finding = self._check_line_for_secrets(file_path, line, line_num)
130
+ if finding:
131
+ findings.append(finding)
132
+
133
+ return findings
134
+
135
+ def _extract_added_lines(self, diff: str) -> List[Tuple[str, str, Optional[int]]]:
136
+ results: List[Tuple[str, str, Optional[int]]] = []
137
+ current_file = "unknown"
138
+ current_line_num: Optional[int] = None
139
+
140
+ for raw_line in diff.splitlines():
141
+ if raw_line.startswith("diff --git "):
142
+ parts = raw_line.split()
143
+ if len(parts) >= 4 and parts[3].startswith("b/"):
144
+ current_file = parts[3][2:]
145
+ elif len(parts) >= 3 and parts[2].startswith("a/"):
146
+ current_file = parts[2][2:]
147
+ current_line_num = None
148
+ continue
149
+ elif raw_line.startswith("+++ b/"):
150
+ current_file = raw_line[6:].strip()
151
+ current_line_num = None
152
+ continue
153
+ elif raw_line.startswith("+++ "):
154
+ continue
155
+ elif raw_line.startswith("@@ "):
156
+ match = re.search(r"\+(\d+)", raw_line)
157
+ if match:
158
+ current_line_num = int(match.group(1))
159
+ continue
160
+
161
+ if raw_line.startswith("+") and not raw_line.startswith("+++"):
162
+ added_content = raw_line[1:]
163
+ results.append((current_file, added_content, current_line_num))
164
+ if current_line_num is not None:
165
+ current_line_num += 1
166
+ elif not raw_line.startswith("-") and current_line_num is not None:
167
+ current_line_num += 1
168
+
169
+ return results
170
+
171
+ def _check_line_for_secrets(
172
+ self, file_path: str, line: str, line_number: Optional[int] = None
173
+ ) -> Optional[ReviewFinding]:
174
+ is_test = is_test_or_fixture_path(file_path)
175
+
176
+ for name, pattern in SPECIFIC_SECRET_PATTERNS:
177
+ match = pattern.search(line)
178
+ if match:
179
+ matched_val = match.group(1)
180
+ masked = mask_secret(matched_val)
181
+
182
+ if is_clear_placeholder(matched_val):
183
+ if is_test:
184
+ return ReviewFinding(
185
+ severity=ReviewSeverity.INFO,
186
+ category="Secret",
187
+ message="Test fixture or mock credential detected",
188
+ file_path=file_path,
189
+ details=f"Pattern: {name} (mock/placeholder)",
190
+ line_number=line_number,
191
+ )
192
+ return None
193
+
194
+ return ReviewFinding(
195
+ severity=ReviewSeverity.HIGH,
196
+ category="Secret",
197
+ message="Possible hardcoded secret or API key detected",
198
+ file_path=file_path,
199
+ details=f"Pattern: {name} ({masked})",
200
+ line_number=line_number,
201
+ )
202
+
203
+ generic_match = GENERIC_ASSIGNMENT_PATTERN.search(line)
204
+ if generic_match:
205
+ value = generic_match.group(1)
206
+
207
+ if is_clear_placeholder(value):
208
+ if is_test:
209
+ return ReviewFinding(
210
+ severity=ReviewSeverity.INFO,
211
+ category="Secret",
212
+ message="Test fixture or mock credential assignment detected",
213
+ file_path=file_path,
214
+ details="Literal test placeholder value detected",
215
+ line_number=line_number,
216
+ )
217
+ return None
218
+
219
+ masked = mask_secret(value)
220
+ severity = ReviewSeverity.WARNING if is_test else ReviewSeverity.HIGH
221
+ return ReviewFinding(
222
+ severity=severity,
223
+ category="Secret",
224
+ message="Possible hardcoded secret or credential assignment detected",
225
+ file_path=file_path,
226
+ details=f"Literal credential value detected ({masked})",
227
+ line_number=line_number,
228
+ )
229
+
230
+ return None
@@ -0,0 +1,79 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import List
5
+
6
+ from comit.commit.context import CommitContext
7
+ from comit.review.base import BaseAnalyzer
8
+ from comit.review.models import ReviewFinding, ReviewSeverity
9
+
10
+ EXEMPT_ENV_SUFFIXES = {
11
+ ".example",
12
+ ".sample",
13
+ ".template",
14
+ ".dist",
15
+ ".schema",
16
+ ".defaults",
17
+ }
18
+
19
+ EXEMPT_ENV_FILENAMES = {
20
+ "example.env",
21
+ "sample.env",
22
+ "template.env",
23
+ }
24
+
25
+ SENSITIVE_EXACT_NAMES = {
26
+ "credentials.json",
27
+ "credentials.yaml",
28
+ "credentials.yml",
29
+ "secrets.json",
30
+ "secrets.yaml",
31
+ "secrets.yml",
32
+ "service-account.json",
33
+ "service_account.json",
34
+ "id_rsa",
35
+ "id_dsa",
36
+ "id_ecdsa",
37
+ "id_ed25519",
38
+ }
39
+
40
+
41
+ class SensitiveFileAnalyzer(BaseAnalyzer):
42
+ def analyze(self, context: CommitContext) -> List[ReviewFinding]:
43
+ findings: List[ReviewFinding] = []
44
+
45
+ for f in context.changed_files:
46
+ if f.status == "D":
47
+ continue
48
+
49
+ name = Path(f.path).name.lower()
50
+
51
+ if self._is_sensitive_file(name):
52
+ findings.append(
53
+ ReviewFinding(
54
+ severity=ReviewSeverity.WARNING,
55
+ category="Sensitive File",
56
+ message="Sensitive environment or credential file staged",
57
+ file_path=f.path,
58
+ details="Environment files and credentials should typically not be tracked in version control.",
59
+ )
60
+ )
61
+
62
+ return findings
63
+
64
+ def _is_sensitive_file(self, filename: str) -> bool:
65
+ if filename in SENSITIVE_EXACT_NAMES:
66
+ return True
67
+
68
+ if filename == ".env":
69
+ return True
70
+
71
+ if filename.startswith(".env.") or filename.endswith(".env"):
72
+ if filename in EXEMPT_ENV_FILENAMES:
73
+ return False
74
+ for exempt_suffix in EXEMPT_ENV_SUFFIXES:
75
+ if filename.endswith(exempt_suffix):
76
+ return False
77
+ return True
78
+
79
+ return False