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.
- comit/__init__.py +1 -0
- comit/ai/__init__.py +39 -0
- comit/ai/base.py +42 -0
- comit/ai/factory.py +59 -0
- comit/ai/gemini_provider.py +125 -0
- comit/ai/groq_provider.py +121 -0
- comit/ai/ollama_provider.py +107 -0
- comit/ai/openai_provider.py +107 -0
- comit/cli.py +446 -0
- comit/commit/__init__.py +21 -0
- comit/commit/context.py +60 -0
- comit/commit/validator.py +270 -0
- comit/config.py +279 -0
- comit/git.py +281 -0
- comit/prompts.py +148 -0
- comit/review/__init__.py +29 -0
- comit/review/base.py +13 -0
- comit/review/databases.py +40 -0
- comit/review/engine.py +92 -0
- comit/review/generated_files.py +75 -0
- comit/review/git_files.py +54 -0
- comit/review/large_files.py +38 -0
- comit/review/models.py +59 -0
- comit/review/private_keys.py +69 -0
- comit/review/secrets.py +230 -0
- comit/review/sensitive_files.py +79 -0
- comit/ui.py +412 -0
- comit_cli-1.0.0.dist-info/METADATA +524 -0
- comit_cli-1.0.0.dist-info/RECORD +32 -0
- comit_cli-1.0.0.dist-info/WHEEL +5 -0
- comit_cli-1.0.0.dist-info/entry_points.txt +3 -0
- comit_cli-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
comit/review/secrets.py
ADDED
|
@@ -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
|