commit-shield 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {commit_shield-0.2.0 → commit_shield-0.3.0}/PKG-INFO +1 -1
- commit_shield-0.3.0/commit_guard/__init__.py +14 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/commit_guard/cli.py +81 -6
- {commit_shield-0.2.0 → commit_shield-0.3.0}/commit_guard/gitutil.py +9 -0
- commit_shield-0.3.0/commit_guard/secret_scanner.py +160 -0
- commit_shield-0.3.0/commit_guard/wizard.py +222 -0
- commit_shield-0.3.0/tests/test_secret_scanner.py +163 -0
- commit_shield-0.2.0/commit_guard/__init__.py +0 -5
- {commit_shield-0.2.0 → commit_shield-0.3.0}/.commit-guard.toml.example +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/.github/workflows/ci.yml +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/.github/workflows/release.yml +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/.gitignore +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/.pre-commit-hooks.yaml +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/CHANGELOG.md +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/CODE_OF_CONDUCT.md +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/CONTRIBUTING.md +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/LICENSE +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/README.md +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/commit_guard/_toml.py +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/commit_guard/checkers.py +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/commit_guard/config.py +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/pyproject.toml +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/tests/test_checkers.py +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/tests/test_cli.py +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/tests/test_config.py +0 -0
- {commit_shield-0.2.0 → commit_shield-0.3.0}/tests/test_toml.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: commit-shield
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Fast, zero-dependency Git commit message and staged file linter.
|
|
5
5
|
Project-URL: Homepage, https://github.com/lui01212/commit-guard
|
|
6
6
|
Project-URL: Repository, https://github.com/lui01212/commit-guard.git
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
commit-shield: Lightweight Git commit message, secret leak and staged file linter.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from commit_guard.checkers import check_commit_message, check_file_path
|
|
6
|
+
from commit_guard.secret_scanner import scan_git_diff, scan_text
|
|
7
|
+
|
|
8
|
+
__version__ = "0.3.0"
|
|
9
|
+
__all__ = [
|
|
10
|
+
"check_commit_message",
|
|
11
|
+
"check_file_path",
|
|
12
|
+
"scan_git_diff",
|
|
13
|
+
"scan_text",
|
|
14
|
+
]
|
|
@@ -4,6 +4,7 @@ Supports standalone usage, git hook usage, and pre-commit framework integration.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
|
+
import json
|
|
7
8
|
import os
|
|
8
9
|
import stat
|
|
9
10
|
import sys
|
|
@@ -17,8 +18,11 @@ from commit_guard.gitutil import (
|
|
|
17
18
|
git_dir,
|
|
18
19
|
is_git_repo,
|
|
19
20
|
staged_blob_size,
|
|
21
|
+
staged_diff,
|
|
20
22
|
staged_files,
|
|
21
23
|
)
|
|
24
|
+
from commit_guard.secret_scanner import scan_git_diff
|
|
25
|
+
from commit_guard.wizard import run_wizard
|
|
22
26
|
|
|
23
27
|
EXIT_OK = 0
|
|
24
28
|
EXIT_FAIL = 1
|
|
@@ -66,6 +70,15 @@ def cmd_check_msg(args: argparse.Namespace) -> int:
|
|
|
66
70
|
msg, max_header_len=args.max_header_len, config=config
|
|
67
71
|
)
|
|
68
72
|
|
|
73
|
+
if getattr(args, "json", False):
|
|
74
|
+
res = {
|
|
75
|
+
"valid": is_valid,
|
|
76
|
+
"errors": errors,
|
|
77
|
+
"message": msg.strip(),
|
|
78
|
+
}
|
|
79
|
+
print(json.dumps(res, indent=2))
|
|
80
|
+
return EXIT_OK if is_valid else EXIT_FAIL
|
|
81
|
+
|
|
69
82
|
if is_valid:
|
|
70
83
|
if not args.quiet:
|
|
71
84
|
print("[commit-guard] Commit message valid. [OK]")
|
|
@@ -107,6 +120,7 @@ def cmd_check_files(args: argparse.Namespace) -> int:
|
|
|
107
120
|
return EXIT_OK
|
|
108
121
|
|
|
109
122
|
has_issues = False
|
|
123
|
+
all_issues: List[str] = []
|
|
110
124
|
for path in files_to_check:
|
|
111
125
|
# Prefer the staged blob size: the working tree may have moved on.
|
|
112
126
|
size_bytes = staged_blob_size(path) if from_index else None
|
|
@@ -118,12 +132,40 @@ def cmd_check_files(args: argparse.Namespace) -> int:
|
|
|
118
132
|
)
|
|
119
133
|
if not is_valid:
|
|
120
134
|
has_issues = True
|
|
121
|
-
|
|
122
|
-
|
|
135
|
+
all_issues.extend(issues)
|
|
136
|
+
if not getattr(args, "json", False):
|
|
137
|
+
for issue in issues:
|
|
138
|
+
print("[commit-guard] Warning: {0}".format(issue), file=sys.stderr)
|
|
139
|
+
|
|
140
|
+
# Content-level secret scan on staged git diff (inspired by git-secrets / gitleaks)
|
|
141
|
+
secret_issues: List[str] = []
|
|
142
|
+
if from_index and not getattr(args, "no_secrets", False):
|
|
143
|
+
diff_text = staged_diff()
|
|
144
|
+
if diff_text:
|
|
145
|
+
findings = scan_git_diff(diff_text)
|
|
146
|
+
for f in findings:
|
|
147
|
+
msg = f.format_message()
|
|
148
|
+
secret_issues.append(msg)
|
|
149
|
+
all_issues.append(msg)
|
|
150
|
+
has_issues = True
|
|
151
|
+
if not getattr(args, "json", False):
|
|
152
|
+
print(f"[commit-guard] Warning: {msg}", file=sys.stderr)
|
|
153
|
+
|
|
154
|
+
if getattr(args, "json", False):
|
|
155
|
+
payload = {
|
|
156
|
+
"valid": not has_issues,
|
|
157
|
+
"issues": all_issues,
|
|
158
|
+
"secret_issues": secret_issues,
|
|
159
|
+
"files_checked": len(files_to_check),
|
|
160
|
+
}
|
|
161
|
+
print(json.dumps(payload, indent=2))
|
|
162
|
+
if has_issues and args.strict:
|
|
163
|
+
return EXIT_FAIL
|
|
164
|
+
return EXIT_OK
|
|
123
165
|
|
|
124
166
|
if has_issues and args.strict:
|
|
125
167
|
print(
|
|
126
|
-
"\n[commit-guard] Aborting commit due to sensitive or oversized "
|
|
168
|
+
"\n[commit-guard] Aborting commit due to sensitive, secret or oversized "
|
|
127
169
|
"files (strict mode).",
|
|
128
170
|
file=sys.stderr,
|
|
129
171
|
)
|
|
@@ -297,6 +339,15 @@ def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
|
297
339
|
return EXIT_OK
|
|
298
340
|
|
|
299
341
|
|
|
342
|
+
def cmd_wizard(args: argparse.Namespace) -> int:
|
|
343
|
+
"""Run interactive Conventional Commit wizard."""
|
|
344
|
+
try:
|
|
345
|
+
config = _resolve_config(args)
|
|
346
|
+
except ConfigError as exc:
|
|
347
|
+
return _fail(str(exc))
|
|
348
|
+
return run_wizard(config=config, dry_run=getattr(args, "dry_run", False))
|
|
349
|
+
|
|
350
|
+
|
|
300
351
|
def _add_config_flags(parser: argparse.ArgumentParser) -> None:
|
|
301
352
|
"""Flags shared by every validating subcommand."""
|
|
302
353
|
parser.add_argument(
|
|
@@ -310,12 +361,15 @@ def _add_config_flags(parser: argparse.ArgumentParser) -> None:
|
|
|
310
361
|
parser.add_argument(
|
|
311
362
|
"-q", "--quiet", action="store_true", help="Suppress success messages"
|
|
312
363
|
)
|
|
364
|
+
parser.add_argument(
|
|
365
|
+
"--json", action="store_true", help="Output machine-readable JSON results"
|
|
366
|
+
)
|
|
313
367
|
|
|
314
368
|
|
|
315
369
|
def build_parser() -> argparse.ArgumentParser:
|
|
316
370
|
parser = argparse.ArgumentParser(
|
|
317
371
|
prog="commit-guard",
|
|
318
|
-
description="Fast
|
|
372
|
+
description="Fast Git commit message, secret leak and staged file linter.",
|
|
319
373
|
)
|
|
320
374
|
parser.add_argument(
|
|
321
375
|
"--version", action="version", version="%(prog)s {0}".format(__version__)
|
|
@@ -323,6 +377,19 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
323
377
|
|
|
324
378
|
subparsers = parser.add_subparsers(dest="subcommand", help="Available subcommands")
|
|
325
379
|
|
|
380
|
+
# commit / cz (interactive wizard)
|
|
381
|
+
p_cz = subparsers.add_parser(
|
|
382
|
+
"commit", aliases=["cz"], help="Interactive Conventional Commit wizard"
|
|
383
|
+
)
|
|
384
|
+
p_cz.add_argument(
|
|
385
|
+
"--dry-run",
|
|
386
|
+
action="store_true",
|
|
387
|
+
help="Generate commit message without committing",
|
|
388
|
+
)
|
|
389
|
+
_add_config_flags(p_cz)
|
|
390
|
+
p_cz.set_defaults(func=cmd_wizard)
|
|
391
|
+
|
|
392
|
+
# check-msg
|
|
326
393
|
p_msg = subparsers.add_parser("check-msg", help="Validate commit message format")
|
|
327
394
|
p_msg.add_argument(
|
|
328
395
|
"file", nargs="?", help="Path to commit message file (e.g. .git/COMMIT_EDITMSG)"
|
|
@@ -337,8 +404,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
337
404
|
_add_config_flags(p_msg)
|
|
338
405
|
p_msg.set_defaults(func=cmd_check_msg)
|
|
339
406
|
|
|
407
|
+
# check-files / check-staged
|
|
340
408
|
p_files = subparsers.add_parser(
|
|
341
|
-
"check-files",
|
|
409
|
+
"check-files",
|
|
410
|
+
aliases=["check-staged"],
|
|
411
|
+
help="Validate staged files for size, sensitive patterns and secrets",
|
|
342
412
|
)
|
|
343
413
|
p_files.add_argument(
|
|
344
414
|
"files", nargs="*", help="File paths to inspect (defaults to git staged files)"
|
|
@@ -352,7 +422,12 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
352
422
|
p_files.add_argument(
|
|
353
423
|
"--strict",
|
|
354
424
|
action="store_true",
|
|
355
|
-
help="Fail with exit 1 if sensitive files detected",
|
|
425
|
+
help="Fail with exit 1 if sensitive files or secrets detected",
|
|
426
|
+
)
|
|
427
|
+
p_files.add_argument(
|
|
428
|
+
"--no-secrets",
|
|
429
|
+
action="store_true",
|
|
430
|
+
help="Skip scanning staged diffs for hardcoded credentials",
|
|
356
431
|
)
|
|
357
432
|
_add_config_flags(p_files)
|
|
358
433
|
p_files.set_defaults(func=cmd_check_files)
|
|
@@ -19,6 +19,7 @@ __all__ = [
|
|
|
19
19
|
"repo_root",
|
|
20
20
|
"staged_files",
|
|
21
21
|
"staged_blob_size",
|
|
22
|
+
"staged_diff",
|
|
22
23
|
]
|
|
23
24
|
|
|
24
25
|
|
|
@@ -104,3 +105,11 @@ def staged_blob_size(path: str, cwd: Optional[str] = None) -> Optional[int]:
|
|
|
104
105
|
return int(out.strip())
|
|
105
106
|
except ValueError:
|
|
106
107
|
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def staged_diff(cwd: Optional[str] = None) -> str:
|
|
111
|
+
"""Return unified diff of staged changes for secret scanning."""
|
|
112
|
+
try:
|
|
113
|
+
return run_git(["diff", "--cached", "-U0"], cwd)
|
|
114
|
+
except GitError:
|
|
115
|
+
return ""
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Content-level secret and credential scanner for git staged diffs and source files.
|
|
3
|
+
Inspired by AWS git-secrets and gitleaks.
|
|
4
|
+
Zero external dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class SecretFinding:
|
|
14
|
+
rule_name: str
|
|
15
|
+
file_path: str
|
|
16
|
+
line_number: int
|
|
17
|
+
secret_preview: str
|
|
18
|
+
line_content: str
|
|
19
|
+
|
|
20
|
+
def format_message(self) -> str:
|
|
21
|
+
return (
|
|
22
|
+
f"[SECRET LEAK] {self.file_path}:{self.line_number}: "
|
|
23
|
+
f"Detected {self.rule_name} ('{self.secret_preview}')"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _mask_secret(secret: str) -> str:
|
|
28
|
+
"""Mask sensitive string leaving only small prefix and suffix visible."""
|
|
29
|
+
if len(secret) <= 8:
|
|
30
|
+
return "***"
|
|
31
|
+
return secret[:4] + "..." + secret[-4:]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Precompiled secret detection rules
|
|
35
|
+
SECRET_RULES: List[Tuple[str, re.Pattern]] = [
|
|
36
|
+
("AWS Access Key ID", re.compile(r"\b(?:AKIA|ABIA|ACCA|ASIA)[A-Z0-9]{16}\b")),
|
|
37
|
+
(
|
|
38
|
+
"GitHub Token",
|
|
39
|
+
re.compile(
|
|
40
|
+
r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b|\bgithub_pat_[A-Za-z0-9_]{82}\b"
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
("OpenAI API Key", re.compile(r"\bsk-[a-zA-Z0-9]{20,64}\b")),
|
|
44
|
+
("Anthropic API Key", re.compile(r"\bsk-ant-[a-zA-Z0-9_\-]{20,128}\b")),
|
|
45
|
+
("Slack Token", re.compile(r"\bxox[baprs](?:-[0-9a-zA-Z]{5,48})+\b")),
|
|
46
|
+
(
|
|
47
|
+
"Stripe Secret / Live Key",
|
|
48
|
+
re.compile(r"\b(?:sk|pk)_(?:test|live)_[0-9a-zA-Z]{24,34}\b"),
|
|
49
|
+
),
|
|
50
|
+
("Google Cloud API Key", re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b")),
|
|
51
|
+
(
|
|
52
|
+
"PEM Private Key Header",
|
|
53
|
+
re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"),
|
|
54
|
+
),
|
|
55
|
+
(
|
|
56
|
+
"Generic Hardcoded Password / Secret",
|
|
57
|
+
re.compile(
|
|
58
|
+
r"""(?i)(?:[a-zA-Z0-9_\-\.]*(?:password|passwd|secret|api_key|apikey|access_token|auth_token))\s*[:=]\s*['"]([A-Za-z0-9_\-!@#$%^&*()+=]{8,})['"]"""
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
# Patterns that indicate false positives / test dummies / placeholders
|
|
64
|
+
ALLOWLIST_PATTERNS = [
|
|
65
|
+
re.compile(
|
|
66
|
+
r"(?i)(?:example|placeholder|dummy|test_fake|<YOUR_|fake_|YOUR_KEY|REPLACE_ME)"
|
|
67
|
+
),
|
|
68
|
+
re.compile(r"AKIAIOSFODNN7EXAMPLE"),
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _is_false_positive(line: str, matched_secret: str) -> bool:
|
|
73
|
+
"""Check if match appears to be an obvious mock/placeholder or comment."""
|
|
74
|
+
for pattern in ALLOWLIST_PATTERNS:
|
|
75
|
+
if pattern.search(line) or pattern.search(matched_secret):
|
|
76
|
+
return True
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def scan_text(text: str, file_path: str = "<staged>") -> List[SecretFinding]:
|
|
81
|
+
"""Scan arbitrary text content line by line for secrets."""
|
|
82
|
+
findings: List[SecretFinding] = []
|
|
83
|
+
lines = text.splitlines()
|
|
84
|
+
|
|
85
|
+
for idx, line in enumerate(lines, start=1):
|
|
86
|
+
stripped = line.strip()
|
|
87
|
+
if not stripped:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
for rule_name, regex in SECRET_RULES:
|
|
91
|
+
match = regex.search(stripped)
|
|
92
|
+
if match:
|
|
93
|
+
matched_secret = match.group(0)
|
|
94
|
+
if not _is_false_positive(stripped, matched_secret):
|
|
95
|
+
findings.append(
|
|
96
|
+
SecretFinding(
|
|
97
|
+
rule_name=rule_name,
|
|
98
|
+
file_path=file_path,
|
|
99
|
+
line_number=idx,
|
|
100
|
+
secret_preview=_mask_secret(matched_secret),
|
|
101
|
+
line_content=stripped[:120],
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
break
|
|
105
|
+
|
|
106
|
+
return findings
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def scan_git_diff(diff_output: str) -> List[SecretFinding]:
|
|
110
|
+
"""
|
|
111
|
+
Parse unified git diff output (e.g. git diff --cached -U0) and scan only
|
|
112
|
+
added lines ('+...') for committed secrets.
|
|
113
|
+
"""
|
|
114
|
+
findings: List[SecretFinding] = []
|
|
115
|
+
current_file: Optional[str] = None
|
|
116
|
+
current_line_num: int = 0
|
|
117
|
+
|
|
118
|
+
diff_file_re = re.compile(r"^\+\+\+\s+b/(.*)$")
|
|
119
|
+
hunk_header_re = re.compile(r"^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@")
|
|
120
|
+
|
|
121
|
+
for raw_line in diff_output.splitlines():
|
|
122
|
+
# Check for new file header: +++ b/path/to/file
|
|
123
|
+
file_match = diff_file_re.match(raw_line)
|
|
124
|
+
if file_match:
|
|
125
|
+
current_file = file_match.group(1).strip()
|
|
126
|
+
continue
|
|
127
|
+
|
|
128
|
+
# Check for hunk header: @@ -1,1 +10,1 @@
|
|
129
|
+
hunk_match = hunk_header_re.match(raw_line)
|
|
130
|
+
if hunk_match:
|
|
131
|
+
current_line_num = int(hunk_match.group(1))
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Check for added lines
|
|
135
|
+
if raw_line.startswith("+") and not raw_line.startswith("+++"):
|
|
136
|
+
added_content = raw_line[1:] # strip leading '+'
|
|
137
|
+
file_name = current_file or "<unknown>"
|
|
138
|
+
|
|
139
|
+
# Scan the line for secrets
|
|
140
|
+
for rule_name, regex in SECRET_RULES:
|
|
141
|
+
match = regex.search(added_content)
|
|
142
|
+
if match:
|
|
143
|
+
matched_secret = match.group(0)
|
|
144
|
+
if not _is_false_positive(added_content, matched_secret):
|
|
145
|
+
findings.append(
|
|
146
|
+
SecretFinding(
|
|
147
|
+
rule_name=rule_name,
|
|
148
|
+
file_path=file_name,
|
|
149
|
+
line_number=current_line_num,
|
|
150
|
+
secret_preview=_mask_secret(matched_secret),
|
|
151
|
+
line_content=added_content.strip()[:120],
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
break
|
|
155
|
+
|
|
156
|
+
current_line_num += 1
|
|
157
|
+
elif not raw_line.startswith("-"):
|
|
158
|
+
current_line_num += 1
|
|
159
|
+
|
|
160
|
+
return findings
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive commit wizard for writing Conventional Commits.
|
|
3
|
+
Inspired by Commitizen (cz commit) with zero external dependencies.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from commit_guard.checkers import check_commit_message
|
|
10
|
+
from commit_guard.config import Config
|
|
11
|
+
from commit_guard.gitutil import run_git
|
|
12
|
+
|
|
13
|
+
COMMIT_TYPES: List[Tuple[str, str]] = [
|
|
14
|
+
("feat", "A new feature for the user or system"),
|
|
15
|
+
("fix", "A bug fix"),
|
|
16
|
+
("docs", "Documentation only changes"),
|
|
17
|
+
("style", "Code style & formatting changes (no logic changes)"),
|
|
18
|
+
("refactor", "Code refactoring (neither fixes a bug nor adds a feature)"),
|
|
19
|
+
("perf", "A code change that improves performance"),
|
|
20
|
+
("test", "Adding or updating unit/integration tests"),
|
|
21
|
+
("build", "Changes that affect the build system or packaging"),
|
|
22
|
+
("ci", "Changes to CI/CD configuration files and scripts"),
|
|
23
|
+
("chore", "Routine maintenance tasks, dependencies, tooling"),
|
|
24
|
+
("revert", "Reverts a previous commit"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def prompt_choice(prompt: str, choices: List[Tuple[str, str]]) -> str:
|
|
29
|
+
"""Display a numbered menu and prompt the user to select one."""
|
|
30
|
+
print(f"\n{prompt}\n")
|
|
31
|
+
for idx, (code, desc) in enumerate(choices, start=1):
|
|
32
|
+
print(f" [{idx:2d}] {code:10s} - {desc}")
|
|
33
|
+
print()
|
|
34
|
+
|
|
35
|
+
while True:
|
|
36
|
+
try:
|
|
37
|
+
raw = input("Select type (1-11): ").strip()
|
|
38
|
+
except (EOFError, KeyboardInterrupt):
|
|
39
|
+
print("\nAborted.")
|
|
40
|
+
sys.exit(1)
|
|
41
|
+
|
|
42
|
+
if raw.isdigit():
|
|
43
|
+
val = int(raw)
|
|
44
|
+
if 1 <= val <= len(choices):
|
|
45
|
+
return choices[val - 1][0]
|
|
46
|
+
else:
|
|
47
|
+
# User typed the code name directly
|
|
48
|
+
matching = [c for c, _ in choices if c == raw.lower()]
|
|
49
|
+
if matching:
|
|
50
|
+
return matching[0]
|
|
51
|
+
|
|
52
|
+
print(f"Invalid selection. Please enter a number between 1 and {len(choices)}.")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def prompt_text(prompt: str, required: bool = False, default: str = "") -> str:
|
|
56
|
+
"""Prompt the user for a text input."""
|
|
57
|
+
suffix = f" (default: {default})" if default else ""
|
|
58
|
+
suffix += " [required]" if required else " [optional, Enter to skip]"
|
|
59
|
+
|
|
60
|
+
while True:
|
|
61
|
+
try:
|
|
62
|
+
val = input(f"{prompt}{suffix}: ").strip()
|
|
63
|
+
except (EOFError, KeyboardInterrupt):
|
|
64
|
+
print("\nAborted.")
|
|
65
|
+
sys.exit(1)
|
|
66
|
+
|
|
67
|
+
if not val and default:
|
|
68
|
+
return default
|
|
69
|
+
if not val and required:
|
|
70
|
+
print("This field is required. Please provide a description.")
|
|
71
|
+
continue
|
|
72
|
+
return val
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def prompt_yes_no(prompt: str, default: bool = False) -> bool:
|
|
76
|
+
"""Prompt for a yes/no question."""
|
|
77
|
+
hint = "[y/N]" if not default else "[Y/n]"
|
|
78
|
+
while True:
|
|
79
|
+
try:
|
|
80
|
+
ans = input(f"{prompt} {hint}: ").strip().lower()
|
|
81
|
+
except (EOFError, KeyboardInterrupt):
|
|
82
|
+
print("\nAborted.")
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
if not ans:
|
|
86
|
+
return default
|
|
87
|
+
if ans in ("y", "yes"):
|
|
88
|
+
return True
|
|
89
|
+
if ans in ("n", "no"):
|
|
90
|
+
return False
|
|
91
|
+
print("Please answer 'y' or 'n'.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def compose_commit_message(
|
|
95
|
+
commit_type: str,
|
|
96
|
+
scope: str,
|
|
97
|
+
subject: str,
|
|
98
|
+
body: str = "",
|
|
99
|
+
is_breaking: bool = False,
|
|
100
|
+
breaking_description: str = "",
|
|
101
|
+
issues_closed: str = "",
|
|
102
|
+
) -> str:
|
|
103
|
+
"""Format components into a standard Conventional Commit string."""
|
|
104
|
+
breaking_mark = "!" if is_breaking else ""
|
|
105
|
+
scope_part = f"({scope})" if scope else ""
|
|
106
|
+
|
|
107
|
+
# Strip any trailing period from subject
|
|
108
|
+
subject_clean = subject.rstrip(".")
|
|
109
|
+
|
|
110
|
+
header = f"{commit_type}{scope_part}{breaking_mark}: {subject_clean}"
|
|
111
|
+
sections = [header]
|
|
112
|
+
|
|
113
|
+
if body.strip():
|
|
114
|
+
sections.append(body.strip())
|
|
115
|
+
|
|
116
|
+
footer_items = []
|
|
117
|
+
if is_breaking and breaking_description.strip():
|
|
118
|
+
footer_items.append(f"BREAKING CHANGE: {breaking_description.strip()}")
|
|
119
|
+
|
|
120
|
+
if issues_closed.strip():
|
|
121
|
+
for item in issues_closed.split(","):
|
|
122
|
+
clean_item = item.strip()
|
|
123
|
+
if clean_item:
|
|
124
|
+
if not clean_item.lower().startswith(("closes", "fixes", "resolves")):
|
|
125
|
+
clean_item = f"Closes {clean_item}"
|
|
126
|
+
footer_items.append(clean_item)
|
|
127
|
+
|
|
128
|
+
if footer_items:
|
|
129
|
+
sections.append("\n".join(footer_items))
|
|
130
|
+
|
|
131
|
+
return "\n\n".join(sections)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_wizard(config: Optional[Config] = None, dry_run: bool = False) -> int:
|
|
135
|
+
"""
|
|
136
|
+
Run interactive wizard in terminal to construct and execute a Conventional Commit.
|
|
137
|
+
"""
|
|
138
|
+
print("\n" + "=" * 60)
|
|
139
|
+
print("🛡️ commit-shield: Interactive Conventional Commit Wizard")
|
|
140
|
+
print("=" * 60)
|
|
141
|
+
|
|
142
|
+
# 1. Type
|
|
143
|
+
commit_type = prompt_choice(
|
|
144
|
+
"Select the type of change you are committing:", COMMIT_TYPES
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# 2. Scope
|
|
148
|
+
scope = prompt_text("Scope of this change (e.g. auth, api, cli, config)")
|
|
149
|
+
|
|
150
|
+
# 3. Subject
|
|
151
|
+
subject = prompt_text(
|
|
152
|
+
"Short imperative description (e.g. add google login)", required=True
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# 4. Long Body
|
|
156
|
+
print("\nDetailed body description (optional). Press Enter twice to finish:")
|
|
157
|
+
body_lines: List[str] = []
|
|
158
|
+
while True:
|
|
159
|
+
try:
|
|
160
|
+
line = input()
|
|
161
|
+
except (EOFError, KeyboardInterrupt):
|
|
162
|
+
break
|
|
163
|
+
if not line and (not body_lines or not body_lines[-1]):
|
|
164
|
+
break
|
|
165
|
+
body_lines.append(line)
|
|
166
|
+
body = "\n".join(body_lines).strip()
|
|
167
|
+
|
|
168
|
+
# 5. Breaking change
|
|
169
|
+
is_breaking = prompt_yes_no(
|
|
170
|
+
"Are there any BREAKING CHANGES in this commit?", default=False
|
|
171
|
+
)
|
|
172
|
+
breaking_desc = ""
|
|
173
|
+
if is_breaking:
|
|
174
|
+
breaking_desc = prompt_text("Describe the breaking changes", required=True)
|
|
175
|
+
|
|
176
|
+
# 6. Issue reference
|
|
177
|
+
issues = prompt_text("Issues closed by this commit (e.g. #123, PROJ-45)")
|
|
178
|
+
|
|
179
|
+
# Compose
|
|
180
|
+
commit_msg = compose_commit_message(
|
|
181
|
+
commit_type=commit_type,
|
|
182
|
+
scope=scope,
|
|
183
|
+
subject=subject,
|
|
184
|
+
body=body,
|
|
185
|
+
is_breaking=is_breaking,
|
|
186
|
+
breaking_description=breaking_desc,
|
|
187
|
+
issues_closed=issues,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Validate composed message
|
|
191
|
+
valid, errors = check_commit_message(commit_msg, config=config)
|
|
192
|
+
print("\n" + "-" * 60)
|
|
193
|
+
print("Generated Commit Message:")
|
|
194
|
+
print("-" * 60)
|
|
195
|
+
print(commit_msg)
|
|
196
|
+
print("-" * 60)
|
|
197
|
+
|
|
198
|
+
if not valid:
|
|
199
|
+
print("\n❌ Warning: Composed message failed validation:", file=sys.stderr)
|
|
200
|
+
for err in errors:
|
|
201
|
+
print(f" - {err}", file=sys.stderr)
|
|
202
|
+
print()
|
|
203
|
+
|
|
204
|
+
if dry_run:
|
|
205
|
+
print("[Dry Run] Commit message generated. Not committing.")
|
|
206
|
+
return 0
|
|
207
|
+
|
|
208
|
+
# Confirmation
|
|
209
|
+
confirm = prompt_yes_no(
|
|
210
|
+
"Proceed to commit staged changes with this message?", default=True
|
|
211
|
+
)
|
|
212
|
+
if not confirm:
|
|
213
|
+
print("Commit aborted.")
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
run_git(["commit", "-m", commit_msg])
|
|
218
|
+
print("✅ Commit created successfully!")
|
|
219
|
+
return 0
|
|
220
|
+
except Exception as e:
|
|
221
|
+
print(f"❌ Git commit failed: {e}", file=sys.stderr)
|
|
222
|
+
return 1
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for secret_scanner and wizard in commit-shield.
|
|
3
|
+
Uses standard library unittest (zero external dependencies).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from commit_guard.secret_scanner import _mask_secret, scan_git_diff, scan_text
|
|
9
|
+
from commit_guard.wizard import compose_commit_message
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestSecretScanner(unittest.TestCase):
|
|
13
|
+
def test_mask_secret(self):
|
|
14
|
+
self.assertEqual(_mask_secret("1234"), "***")
|
|
15
|
+
masked = _mask_secret("sk-ant-1234567890abcdef")
|
|
16
|
+
self.assertTrue(masked.startswith("sk-a"))
|
|
17
|
+
self.assertTrue(masked.endswith("cdef"))
|
|
18
|
+
self.assertIn("...", masked)
|
|
19
|
+
|
|
20
|
+
def test_detect_aws_key(self):
|
|
21
|
+
text = "aws_key = 'AKIA1234567890ABCDEF'"
|
|
22
|
+
findings = scan_text(text)
|
|
23
|
+
self.assertEqual(len(findings), 1)
|
|
24
|
+
self.assertEqual(findings[0].rule_name, "AWS Access Key ID")
|
|
25
|
+
|
|
26
|
+
def test_detect_github_token(self):
|
|
27
|
+
# Construct exact 36 char token for matching
|
|
28
|
+
full_token = "ghp_" + ("a" * 36)
|
|
29
|
+
findings = scan_text(f"GH_TOKEN='{full_token}'")
|
|
30
|
+
self.assertEqual(len(findings), 1)
|
|
31
|
+
self.assertEqual(findings[0].rule_name, "GitHub Token")
|
|
32
|
+
|
|
33
|
+
def test_detect_openai_and_anthropic(self):
|
|
34
|
+
token_oa = "sk-" + ("a" * 25)
|
|
35
|
+
token_ant = "sk-ant-" + ("b" * 25)
|
|
36
|
+
findings = scan_text(f"OA = '{token_oa}'\nANT = '{token_ant}'")
|
|
37
|
+
self.assertEqual(len(findings), 2)
|
|
38
|
+
rule_names = {f.rule_name for f in findings}
|
|
39
|
+
self.assertIn("OpenAI API Key", rule_names)
|
|
40
|
+
self.assertIn("Anthropic API Key", rule_names)
|
|
41
|
+
|
|
42
|
+
def test_detect_pem_private_key(self):
|
|
43
|
+
pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"
|
|
44
|
+
findings = scan_text(pem)
|
|
45
|
+
self.assertGreaterEqual(len(findings), 1)
|
|
46
|
+
self.assertEqual(findings[0].rule_name, "PEM Private Key Header")
|
|
47
|
+
|
|
48
|
+
def test_detect_hardcoded_password(self):
|
|
49
|
+
text = "database_password = 'MySecretP@ssw0rd!123'"
|
|
50
|
+
findings = scan_text(text)
|
|
51
|
+
self.assertEqual(len(findings), 1)
|
|
52
|
+
self.assertEqual(findings[0].rule_name, "Generic Hardcoded Password / Secret")
|
|
53
|
+
|
|
54
|
+
def test_false_positive_filtering(self):
|
|
55
|
+
text = (
|
|
56
|
+
"api_key = '<YOUR_API_KEY_HERE>'\n"
|
|
57
|
+
"password = 'example_dummy_password'\n"
|
|
58
|
+
"aws = 'AKIAIOSFODNN7EXAMPLE'" # Public doc example
|
|
59
|
+
)
|
|
60
|
+
findings = scan_text(text)
|
|
61
|
+
self.assertEqual(len(findings), 0)
|
|
62
|
+
|
|
63
|
+
def test_scan_git_diff_hunk(self):
|
|
64
|
+
fake_diff = (
|
|
65
|
+
"diff --git a/app.py b/app.py\n"
|
|
66
|
+
"--- a/app.py\n"
|
|
67
|
+
"+++ b/app.py\n"
|
|
68
|
+
"@@ -10,3 +10,4 @@\n"
|
|
69
|
+
" import os\n"
|
|
70
|
+
"+STRIPE_KEY = 'sk_live_' + '123456789012345678901234'\n"
|
|
71
|
+
"+# normal line\n"
|
|
72
|
+
"-old_line\n"
|
|
73
|
+
)
|
|
74
|
+
stripe_live = "sk_live_" + ("1" * 24)
|
|
75
|
+
sample_diff = fake_diff.replace(
|
|
76
|
+
"'sk_live_' + '123456789012345678901234'", f"'{stripe_live}'"
|
|
77
|
+
)
|
|
78
|
+
findings = scan_git_diff(sample_diff)
|
|
79
|
+
self.assertEqual(len(findings), 1)
|
|
80
|
+
self.assertEqual(findings[0].file_path, "app.py")
|
|
81
|
+
self.assertEqual(findings[0].rule_name, "Stripe Secret / Live Key")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class TestWizard(unittest.TestCase):
|
|
85
|
+
def test_compose_standard_message(self):
|
|
86
|
+
msg = compose_commit_message(
|
|
87
|
+
commit_type="feat",
|
|
88
|
+
scope="auth",
|
|
89
|
+
subject="add oauth2 login provider",
|
|
90
|
+
body="Implements Google OAuth2 provider using standard libraries.",
|
|
91
|
+
is_breaking=False,
|
|
92
|
+
issues_closed="#42",
|
|
93
|
+
)
|
|
94
|
+
expected = (
|
|
95
|
+
"feat(auth): add oauth2 login provider\n\n"
|
|
96
|
+
"Implements Google OAuth2 provider using standard libraries.\n\n"
|
|
97
|
+
"Closes #42"
|
|
98
|
+
)
|
|
99
|
+
self.assertEqual(msg, expected)
|
|
100
|
+
|
|
101
|
+
def test_compose_breaking_change_message(self):
|
|
102
|
+
msg = compose_commit_message(
|
|
103
|
+
commit_type="refactor",
|
|
104
|
+
scope="api",
|
|
105
|
+
subject="drop legacy v1 endpoints",
|
|
106
|
+
is_breaking=True,
|
|
107
|
+
breaking_description="v1 API endpoints have been removed in favor of v2.",
|
|
108
|
+
)
|
|
109
|
+
self.assertIn("refactor(api)!: drop legacy v1 endpoints", msg)
|
|
110
|
+
self.assertIn("BREAKING CHANGE: v1 API endpoints have been removed", msg)
|
|
111
|
+
|
|
112
|
+
def test_cli_json_check_msg_valid(self):
|
|
113
|
+
import io
|
|
114
|
+
import json
|
|
115
|
+
from contextlib import redirect_stdout
|
|
116
|
+
|
|
117
|
+
from commit_guard.cli import main
|
|
118
|
+
|
|
119
|
+
buf = io.StringIO()
|
|
120
|
+
with redirect_stdout(buf):
|
|
121
|
+
ret = main(["check-msg", "-m", "feat(cli): add json flag", "--json"])
|
|
122
|
+
self.assertEqual(ret, 0)
|
|
123
|
+
data = json.loads(buf.getvalue())
|
|
124
|
+
self.assertTrue(data["valid"])
|
|
125
|
+
self.assertEqual(data["errors"], [])
|
|
126
|
+
|
|
127
|
+
def test_cli_json_check_msg_invalid(self):
|
|
128
|
+
import io
|
|
129
|
+
import json
|
|
130
|
+
from contextlib import redirect_stdout
|
|
131
|
+
|
|
132
|
+
from commit_guard.cli import main
|
|
133
|
+
|
|
134
|
+
buf = io.StringIO()
|
|
135
|
+
with redirect_stdout(buf):
|
|
136
|
+
ret = main(["check-msg", "-m", "bad commit message", "--json"])
|
|
137
|
+
self.assertEqual(ret, 1)
|
|
138
|
+
data = json.loads(buf.getvalue())
|
|
139
|
+
self.assertFalse(data["valid"])
|
|
140
|
+
self.assertGreater(len(data["errors"]), 0)
|
|
141
|
+
|
|
142
|
+
def test_cli_check_staged_alias(self):
|
|
143
|
+
from commit_guard.cli import build_parser
|
|
144
|
+
|
|
145
|
+
parser = build_parser()
|
|
146
|
+
# Parse arguments without --help to avoid SystemExit
|
|
147
|
+
args = parser.parse_args(
|
|
148
|
+
["check-staged", "file1.py", "--no-config", "--no-secrets"]
|
|
149
|
+
)
|
|
150
|
+
self.assertEqual(args.subcommand, "check-staged")
|
|
151
|
+
self.assertEqual(args.files, ["file1.py"])
|
|
152
|
+
|
|
153
|
+
def test_wizard_dry_run_cli(self):
|
|
154
|
+
from commit_guard.wizard import check_commit_message, compose_commit_message
|
|
155
|
+
|
|
156
|
+
msg = compose_commit_message("fix", "core", "resolve memory leak")
|
|
157
|
+
valid, errors = check_commit_message(msg)
|
|
158
|
+
self.assertTrue(valid)
|
|
159
|
+
self.assertEqual(errors, [])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|