gitsentry 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.
gitsentry/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
gitsentry/cli/main.py ADDED
@@ -0,0 +1,275 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ import typer
5
+ from rich.console import Console
6
+ from rich.table import Table
7
+ from rich import print as rprint
8
+
9
+ from gitsentry.core.patterns import Severity
10
+
11
+ app = typer.Typer(
12
+ name="gitsentry",
13
+ help="LLM 개발 환경의 GitHub 보안 감사 도구",
14
+ no_args_is_help=True,
15
+ )
16
+ console = Console()
17
+
18
+ SEVERITY_COLORS = {
19
+ Severity.DANGER: "red",
20
+ Severity.WARNING: "yellow",
21
+ Severity.INFO: "cyan",
22
+ }
23
+
24
+
25
+ @app.command()
26
+ def audit(
27
+ path: Path = typer.Argument(Path("."), help="감사할 로컬 저장소 경로"),
28
+ deep: bool = typer.Option(False, "--deep", help=".gitignore 파일도 포함해서 검사"),
29
+ llm: bool = typer.Option(False, "--llm", help="Claude API로 결과 분석 요청"),
30
+ ):
31
+ """F1: 현재 저장소 공개 파일 보안 감사."""
32
+ from gitsentry.core.auditor import audit_repo, summarize
33
+
34
+ repo_path = path.resolve()
35
+ console.print(f"[bold]감사 중:[/bold] {repo_path}")
36
+
37
+ with console.status("파일 스캔 중..."):
38
+ findings = audit_repo(repo_path, deep=deep)
39
+
40
+ result = summarize(findings)
41
+ _print_findings_table(result["findings"])
42
+ _print_summary(result)
43
+
44
+ if llm and findings:
45
+ _run_llm_analysis(result)
46
+
47
+ if result["danger"] > 0:
48
+ raise typer.Exit(code=1)
49
+
50
+
51
+ @app.command()
52
+ def history(
53
+ path: Path = typer.Argument(Path("."), help="감사할 로컬 저장소 경로"),
54
+ max_commits: int = typer.Option(200, "--max-commits", "-n", help="검사할 최대 커밋 수"),
55
+ llm: bool = typer.Option(False, "--llm", help="Claude API로 결과 분석 요청"),
56
+ ):
57
+ """F2: Git 커밋 히스토리 보안 감사."""
58
+ from gitsentry.core.history import audit_history
59
+
60
+ repo_path = path.resolve()
61
+ console.print(f"[bold]히스토리 감사 중:[/bold] {repo_path} (최대 {max_commits}개 커밋)")
62
+
63
+ with console.status("커밋 히스토리 분석 중..."):
64
+ findings = audit_history(repo_path, max_commits=max_commits)
65
+
66
+ if not findings:
67
+ console.print("[green]히스토리에서 민감 정보를 발견하지 못했습니다.[/green]")
68
+ return
69
+
70
+ table = Table(title=f"히스토리 감사 결과 ({len(findings)}건)")
71
+ table.add_column("커밋", style="dim")
72
+ table.add_column("파일")
73
+ table.add_column("내용 (마스킹)")
74
+ table.add_column("유형")
75
+ table.add_column("위험도")
76
+
77
+ for f in findings:
78
+ color = SEVERITY_COLORS[f.severity]
79
+ table.add_row(
80
+ f.commit_hash,
81
+ f.file_path,
82
+ f.masked_content()[:80],
83
+ f.description,
84
+ f"[{color}]{f.severity.value}[/{color}]",
85
+ )
86
+ console.print(table)
87
+
88
+ if llm and findings:
89
+ summary_text = _format_findings_for_llm(findings)
90
+ _run_llm_analysis_raw(summary_text)
91
+
92
+
93
+ @app.command()
94
+ def scan(
95
+ all_repos: bool = typer.Option(False, "--all", help="계정 전체 저장소 감사"),
96
+ repo: Optional[str] = typer.Option(None, "--repo", help="특정 저장소 (owner/repo)"),
97
+ ):
98
+ """F3: GitHub 원격 저장소 감사."""
99
+ from rich.progress import Progress, SpinnerColumn, TextColumn
100
+
101
+ if not all_repos and not repo:
102
+ console.print("[red]--all 또는 --repo owner/repo 옵션을 지정하세요.[/red]")
103
+ raise typer.Exit(code=1)
104
+
105
+ if all_repos:
106
+ from gitsentry.core.scanner import scan_all_repos
107
+ findings = []
108
+
109
+ with Progress(SpinnerColumn(), TextColumn("{task.description}"), console=console) as progress:
110
+ task = progress.add_task("저장소 스캔 중...", total=None)
111
+
112
+ def on_progress(repo_name: str):
113
+ progress.update(task, description=f"스캔 중: {repo_name}")
114
+
115
+ findings = scan_all_repos(progress_callback=on_progress)
116
+
117
+ if not findings:
118
+ console.print("[green]전체 저장소에서 민감 정보를 발견하지 못했습니다.[/green]")
119
+ return
120
+
121
+ table = Table(title=f"전체 저장소 감사 결과 ({len(findings)}건)")
122
+ table.add_column("저장소")
123
+ table.add_column("파일")
124
+ table.add_column("유형")
125
+ table.add_column("위험도")
126
+
127
+ for f in findings:
128
+ color = SEVERITY_COLORS[f.severity]
129
+ table.add_row(
130
+ f.repo,
131
+ f.file_path,
132
+ f.description,
133
+ f"[{color}]{f.severity.value}[/{color}]",
134
+ )
135
+ console.print(table)
136
+
137
+
138
+ @app.command()
139
+ def preview(
140
+ path: Path = typer.Argument(Path("."), help="미리보기할 로컬 저장소 경로"),
141
+ ):
142
+ """F4: Push 대상 vs 제외 파일 시각화."""
143
+ from gitsentry.core.preview import get_push_preview
144
+
145
+ repo_path = path.resolve()
146
+ result = get_push_preview(repo_path)
147
+
148
+ tracked = result["tracked"]
149
+ untracked = result["untracked"]
150
+ gitignored = result["gitignored"]
151
+
152
+ table = Table(title="Push 미리보기")
153
+ table.add_column("상태")
154
+ table.add_column("파일")
155
+
156
+ for f in tracked:
157
+ rel = f.relative_to(repo_path) if f.is_absolute() else f
158
+ table.add_row("[green]PUSH됨[/green]", str(rel))
159
+
160
+ for f in untracked:
161
+ rel = f.relative_to(repo_path) if f.is_absolute() else f
162
+ table.add_row("[yellow]UNTRACKED[/yellow]", str(rel))
163
+
164
+ for f in gitignored:
165
+ rel = f.relative_to(repo_path) if f.is_absolute() else f
166
+ table.add_row("[dim]제외됨(.gitignore)[/dim]", str(rel))
167
+
168
+ console.print(table)
169
+ console.print(
170
+ f"\n[green]PUSH됨: {len(tracked)}[/green] "
171
+ f"[yellow]UNTRACKED: {len(untracked)}[/yellow] "
172
+ f"[dim]제외됨: {len(gitignored)}[/dim]"
173
+ )
174
+
175
+
176
+ @app.command(name="pre-push")
177
+ def pre_push(
178
+ path: Path = typer.Argument(Path("."), help="감사할 저장소 경로"),
179
+ ):
180
+ """F5: Pre-push 훅 — push 전 자동 보안 감사."""
181
+ from gitsentry.core.auditor import audit_repo, summarize
182
+
183
+ repo_path = path.resolve()
184
+ findings = audit_repo(repo_path)
185
+ result = summarize(findings)
186
+
187
+ if result["danger"] > 0:
188
+ console.print(f"[bold red]DANGER: {result['danger']}건의 심각한 보안 문제 발견 — push 중단 권고[/bold red]")
189
+ _print_findings_table([f for f in findings if f.severity == Severity.DANGER])
190
+ raise typer.Exit(code=1)
191
+ elif result["warning"] > 0:
192
+ console.print(f"[yellow]WARNING: {result['warning']}건의 경고 발견[/yellow]")
193
+ _print_findings_table([f for f in findings if f.severity == Severity.WARNING])
194
+ proceed = typer.confirm("계속 push하시겠습니까?")
195
+ if not proceed:
196
+ raise typer.Exit(code=1)
197
+ else:
198
+ console.print("[green]보안 검사 통과 ✓[/green]")
199
+
200
+
201
+ @app.command(name="generate-skill")
202
+ def generate_skill(
203
+ output_dir: Path = typer.Argument(
204
+ Path(".claude/skills/pre-push-audit"),
205
+ help="SKILL.md를 저장할 디렉토리",
206
+ ),
207
+ ):
208
+ """F6: Claude Code pre-push-audit 메타 스킬 생성."""
209
+ from gitsentry.llm.skill_gen import generate_skill as gen
210
+
211
+ skill_path = gen(output_dir.resolve())
212
+ console.print(f"[green]스킬 생성 완료:[/green] {skill_path}")
213
+ console.print("Claude Code에서 [bold]/pre-push-audit[/bold] 명령으로 사용할 수 있습니다.")
214
+
215
+
216
+ def _print_findings_table(findings) -> None:
217
+ if not findings:
218
+ return
219
+
220
+ table = Table(title=f"감사 결과 ({len(findings)}건)")
221
+ table.add_column("파일")
222
+ table.add_column("라인", justify="right")
223
+ table.add_column("내용 (마스킹)", no_wrap=False)
224
+ table.add_column("유형")
225
+ table.add_column("위험도")
226
+
227
+ for f in findings:
228
+ color = SEVERITY_COLORS[f.severity]
229
+ table.add_row(
230
+ str(Path(f.file_path).name),
231
+ str(f.line_number),
232
+ f.masked_content()[:80],
233
+ f.description,
234
+ f"[{color}]{f.severity.value}[/{color}]",
235
+ )
236
+ console.print(table)
237
+
238
+
239
+ def _print_summary(result: dict) -> None:
240
+ console.print(
241
+ f"\n합계: [bold]{result['total']}[/bold]건 "
242
+ f"[red]DANGER: {result['danger']}[/red] "
243
+ f"[yellow]WARNING: {result['warning']}[/yellow] "
244
+ f"[cyan]INFO: {result['info']}[/cyan]"
245
+ )
246
+
247
+
248
+ def _run_llm_analysis(result: dict) -> None:
249
+ summary_text = _format_findings_for_llm(result["findings"])
250
+ _run_llm_analysis_raw(summary_text)
251
+
252
+
253
+ def _run_llm_analysis_raw(summary_text: str) -> None:
254
+ from gitsentry.llm.client import analyze_findings
255
+
256
+ console.print("\n[bold]Claude 분석 중...[/bold]")
257
+ with console.status("Claude API 요청 중..."):
258
+ analysis = analyze_findings(summary_text)
259
+ console.print("\n[bold underline]Claude 분석 결과[/bold underline]")
260
+ console.print(analysis)
261
+
262
+
263
+ def _format_findings_for_llm(findings) -> str:
264
+ lines = [f"총 {len(findings)}건 발견\n"]
265
+ for f in findings:
266
+ lines.append(
267
+ f"- [{f.severity.value}] {getattr(f, 'file_path', '')} "
268
+ f"(라인 {getattr(f, 'line_number', '?')}): "
269
+ f"{f.description} — {f.masked_content()[:60]}"
270
+ )
271
+ return "\n".join(lines)
272
+
273
+
274
+ if __name__ == "__main__":
275
+ app()
File without changes
@@ -0,0 +1,64 @@
1
+ """F1: 현재 저장소 공개 파일 보안 감사."""
2
+ import re
3
+ from pathlib import Path
4
+
5
+ from gitsentry.core.patterns import PATTERNS, Finding, Severity
6
+ from gitsentry.utils.git import get_tracked_files, is_git_repo
7
+
8
+ SKIP_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
9
+ ".woff", ".woff2", ".ttf", ".eot", ".pdf", ".zip",
10
+ ".tar", ".gz", ".dmg", ".exe", ".dylib", ".so"}
11
+
12
+
13
+ def audit_file(file_path: Path) -> list[Finding]:
14
+ if file_path.suffix.lower() in SKIP_EXTENSIONS:
15
+ return []
16
+ try:
17
+ text = file_path.read_text(encoding="utf-8", errors="ignore")
18
+ except (OSError, PermissionError):
19
+ return []
20
+
21
+ findings = []
22
+ compiled = [(p, p.compile()) for p in PATTERNS]
23
+ for i, line in enumerate(text.splitlines(), start=1):
24
+ for pattern, regex in compiled:
25
+ m = regex.search(line)
26
+ if m:
27
+ findings.append(Finding(
28
+ file_path=str(file_path),
29
+ line_number=i,
30
+ line_content=line.strip(),
31
+ pattern=pattern,
32
+ matched_text=m.group(0),
33
+ ))
34
+ return findings
35
+
36
+
37
+ def audit_repo(repo_path: Path, deep: bool = False) -> list[Finding]:
38
+ """저장소 감사 — deep=True면 .gitignore 파일 포함."""
39
+ if not is_git_repo(repo_path):
40
+ raise ValueError(f"{repo_path}은 git 저장소가 아닙니다.")
41
+
42
+ if deep:
43
+ files = [f for f in repo_path.rglob("*") if f.is_file()
44
+ and ".git" not in f.parts]
45
+ else:
46
+ files = get_tracked_files(repo_path)
47
+
48
+ all_findings: list[Finding] = []
49
+ for f in files:
50
+ all_findings.extend(audit_file(f))
51
+ return all_findings
52
+
53
+
54
+ def summarize(findings: list[Finding]) -> dict:
55
+ danger = [f for f in findings if f.severity == Severity.DANGER]
56
+ warning = [f for f in findings if f.severity == Severity.WARNING]
57
+ info = [f for f in findings if f.severity == Severity.INFO]
58
+ return {
59
+ "total": len(findings),
60
+ "danger": len(danger),
61
+ "warning": len(warning),
62
+ "info": len(info),
63
+ "findings": findings,
64
+ }
@@ -0,0 +1,55 @@
1
+ """F2: Git 커밋 히스토리 보안 감사."""
2
+ import re
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from gitsentry.core.patterns import PATTERNS, Severity
7
+ from gitsentry.utils.git import get_commit_diff, get_commit_log, is_git_repo
8
+
9
+
10
+ @dataclass
11
+ class CommitFinding:
12
+ commit_hash: str
13
+ file_path: str
14
+ line_content: str
15
+ description: str
16
+ severity: Severity
17
+ matched_text: str
18
+
19
+ def masked_content(self) -> str:
20
+ return self.line_content.replace(self.matched_text, f"[{self.description}]")
21
+
22
+
23
+ def audit_history(repo_path: Path, max_commits: int = 200) -> list[CommitFinding]:
24
+ if not is_git_repo(repo_path):
25
+ raise ValueError(f"{repo_path}은 git 저장소가 아닙니다.")
26
+
27
+ commits = get_commit_log(repo_path, max_count=max_commits)
28
+ compiled = [(p, p.compile()) for p in PATTERNS]
29
+ findings: list[CommitFinding] = []
30
+
31
+ for commit_hash in commits:
32
+ try:
33
+ diff_text = get_commit_diff(repo_path, commit_hash)
34
+ except RuntimeError:
35
+ continue
36
+
37
+ current_file = ""
38
+ for line in diff_text.splitlines():
39
+ if line.startswith("+++ b/"):
40
+ current_file = line[6:]
41
+ elif line.startswith("+") and not line.startswith("+++"):
42
+ content = line[1:]
43
+ for pattern, regex in compiled:
44
+ m = regex.search(content)
45
+ if m:
46
+ findings.append(CommitFinding(
47
+ commit_hash=commit_hash[:8],
48
+ file_path=current_file,
49
+ line_content=content.strip(),
50
+ description=pattern.description,
51
+ severity=pattern.severity,
52
+ matched_text=m.group(0),
53
+ ))
54
+
55
+ return findings
@@ -0,0 +1,63 @@
1
+ import re
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+
5
+
6
+ class Severity(Enum):
7
+ DANGER = "DANGER"
8
+ WARNING = "WARNING"
9
+ INFO = "INFO"
10
+
11
+
12
+ @dataclass
13
+ class Pattern:
14
+ regex: str
15
+ description: str
16
+ severity: Severity
17
+
18
+ def compile(self) -> re.Pattern:
19
+ return re.compile(self.regex)
20
+
21
+
22
+ PATTERNS: list[Pattern] = [
23
+ # API Keys — DANGER
24
+ Pattern(r"sk-[A-Za-z0-9]{20,}", "Anthropic/OpenAI API Key", Severity.DANGER),
25
+ Pattern(r"sk-ant-[A-Za-z0-9\-]{20,}", "Anthropic API Key", Severity.DANGER),
26
+ Pattern(r"ghp_[A-Za-z0-9]{36}", "GitHub Personal Access Token", Severity.DANGER),
27
+ Pattern(r"ghs_[A-Za-z0-9]{36}", "GitHub Server Token", Severity.DANGER),
28
+ Pattern(r"Bearer [A-Za-z0-9\-._~+/]{20,}=*", "Bearer Token", Severity.DANGER),
29
+ Pattern(r"[Aa][Pp][Ii][_-]?[Kk][Ee][Yy]\s*[:=]\s*['\"]?\S{8,}", "API Key Assignment", Severity.DANGER),
30
+ Pattern(r"[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]\s*[:=]\s*['\"]?\S{4,}", "Password Assignment", Severity.DANGER),
31
+ Pattern(r"[Ss][Ee][Cc][Rr][Ee][Tt]\s*[:=]\s*['\"]?\S{4,}", "Secret Assignment", Severity.DANGER),
32
+ Pattern(r"AKIA[A-Z0-9]{16}", "AWS Access Key ID", Severity.DANGER),
33
+ # LLM 개발 내부 문서 — WARNING
34
+ Pattern(r"CLAUDE\.md", "Claude Code Internal Doc", Severity.WARNING),
35
+ Pattern(r"LESSONS_LEARNED\.md", "Internal Dev Lessons", Severity.WARNING),
36
+ Pattern(r"DEVELOPMENT\.md", "Internal Dev Doc", Severity.WARNING),
37
+ Pattern(r"RESEARCH_[A-Z_]+\.md", "Internal Research Doc", Severity.WARNING),
38
+ Pattern(r"\.env(\.\w+)?$", "Environment File", Severity.WARNING),
39
+ # 내부 경로/설정 — INFO
40
+ Pattern(r"/Users/[A-Za-z0-9_\-]+/", "Local User Path", Severity.INFO),
41
+ Pattern(r"localhost:[0-9]{4,5}", "Localhost Reference", Severity.INFO),
42
+ ]
43
+
44
+
45
+ @dataclass
46
+ class Finding:
47
+ file_path: str
48
+ line_number: int
49
+ line_content: str
50
+ pattern: Pattern
51
+ matched_text: str
52
+
53
+ @property
54
+ def severity(self) -> Severity:
55
+ return self.pattern.severity
56
+
57
+ @property
58
+ def description(self) -> str:
59
+ return self.pattern.description
60
+
61
+ def masked_content(self) -> str:
62
+ """민감 값을 마스킹한 라인 반환."""
63
+ return self.line_content.replace(self.matched_text, f"[{self.description}]")
@@ -0,0 +1,39 @@
1
+ """F4: Push 미리보기 — push 대상 vs .gitignore 제외 파일 시각화."""
2
+ import subprocess
3
+ from pathlib import Path
4
+
5
+ from gitsentry.utils.git import get_tracked_files, get_untracked_files, is_git_repo
6
+
7
+
8
+ def get_push_preview(repo_path: Path) -> dict:
9
+ """
10
+ push될 파일과 제외될 파일을 분리해서 반환.
11
+
12
+ Returns:
13
+ {
14
+ "tracked": [tracked 파일 목록],
15
+ "untracked": [untracked 파일 목록],
16
+ "gitignored": [.gitignore로 제외된 파일 목록],
17
+ }
18
+ """
19
+ if not is_git_repo(repo_path):
20
+ raise ValueError(f"{repo_path}은 git 저장소가 아닙니다.")
21
+
22
+ tracked = get_tracked_files(repo_path)
23
+ untracked = get_untracked_files(repo_path)
24
+
25
+ # .gitignore로 실제로 제외된 파일 목록
26
+ result = subprocess.run(
27
+ ["git", "ls-files", "--others", "--ignored", "--exclude-standard"],
28
+ cwd=repo_path,
29
+ capture_output=True,
30
+ text=True,
31
+ timeout=10,
32
+ )
33
+ gitignored = [repo_path / line for line in result.stdout.splitlines() if line]
34
+
35
+ return {
36
+ "tracked": tracked,
37
+ "untracked": untracked,
38
+ "gitignored": gitignored,
39
+ }
@@ -0,0 +1,68 @@
1
+ """F3: 계정 전체 GitHub 저장소 순차 감사."""
2
+ from dataclasses import dataclass, field
3
+ from pathlib import Path
4
+
5
+ from gitsentry.utils.github_api import get_file_content, list_user_repos
6
+ from gitsentry.core.patterns import PATTERNS, Severity
7
+
8
+
9
+ @dataclass
10
+ class RemoteFinding:
11
+ repo: str
12
+ file_path: str
13
+ line_number: int
14
+ line_content: str
15
+ description: str
16
+ severity: Severity
17
+ matched_text: str
18
+
19
+
20
+ def scan_remote_file(repo_name: str, file_path: str, content: str) -> list[RemoteFinding]:
21
+ compiled = [(p, p.compile()) for p in PATTERNS]
22
+ findings = []
23
+ for i, line in enumerate(content.splitlines(), start=1):
24
+ for pattern, regex in compiled:
25
+ m = regex.search(line)
26
+ if m:
27
+ findings.append(RemoteFinding(
28
+ repo=repo_name,
29
+ file_path=file_path,
30
+ line_number=i,
31
+ line_content=line.strip(),
32
+ description=pattern.description,
33
+ severity=pattern.severity,
34
+ matched_text=m.group(0),
35
+ ))
36
+ return findings
37
+
38
+
39
+ def scan_all_repos(progress_callback=None) -> list[RemoteFinding]:
40
+ """계정 전체 저장소를 GitHub API로 감사."""
41
+ from gitsentry.utils.github_api import get_repo_files
42
+
43
+ repos = list_user_repos()
44
+ all_findings: list[RemoteFinding] = []
45
+
46
+ SKIP_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
47
+ ".woff", ".woff2", ".ttf", ".pdf", ".zip", ".tar",
48
+ ".gz", ".dmg", ".exe", ".dylib", ".so", ".pyc"}
49
+
50
+ for repo in repos:
51
+ if progress_callback:
52
+ progress_callback(repo["full_name"])
53
+ try:
54
+ files = get_repo_files(repo["full_name"])
55
+ for f in files:
56
+ ext = Path(f["path"]).suffix.lower()
57
+ if ext in SKIP_EXTENSIONS or f["size"] > 1_000_000:
58
+ continue
59
+ try:
60
+ content = get_file_content(repo["full_name"], f["path"])
61
+ findings = scan_remote_file(repo["full_name"], f["path"], content)
62
+ all_findings.extend(findings)
63
+ except Exception:
64
+ continue
65
+ except Exception:
66
+ continue
67
+
68
+ return all_findings
File without changes
@@ -0,0 +1,38 @@
1
+ """F5: Claude API 연동 — 보안 감사 결과 분석."""
2
+ import os
3
+
4
+ from dotenv import load_dotenv
5
+
6
+
7
+ def get_anthropic_client():
8
+ load_dotenv()
9
+ import anthropic
10
+ api_key = os.getenv("ANTHROPIC_API_KEY")
11
+ if not api_key:
12
+ raise RuntimeError("ANTHROPIC_API_KEY가 설정되지 않았습니다.")
13
+ return anthropic.Anthropic(api_key=api_key)
14
+
15
+
16
+ def analyze_findings(findings_summary: str) -> str:
17
+ """감사 결과를 Claude에게 전달해 리스크 평가 + 권장 조치 수신."""
18
+ client = get_anthropic_client()
19
+
20
+ prompt = f"""당신은 Git 저장소 보안 전문가입니다.
21
+ 아래는 gitsentry 보안 감사 도구가 발견한 결과입니다.
22
+
23
+ {findings_summary}
24
+
25
+ 다음을 분석해 주세요:
26
+ 1. 각 DANGER/WARNING 항목의 실제 리스크 수준 평가
27
+ 2. 즉시 조치가 필요한 항목과 그 이유
28
+ 3. .gitignore 또는 git history rewrite가 필요한 경우 구체적 방법
29
+ 4. 향후 재발 방지를 위한 개발 프로세스 권장사항
30
+
31
+ 간결하고 실행 가능한 조치 중심으로 응답해 주세요."""
32
+
33
+ response = client.messages.create(
34
+ model="claude-sonnet-4-6",
35
+ max_tokens=2000,
36
+ messages=[{"role": "user", "content": prompt}],
37
+ )
38
+ return response.content[0].text
@@ -0,0 +1,89 @@
1
+ """F6: Claude Code 메타 스킬 자동 생성."""
2
+ from pathlib import Path
3
+
4
+ from gitsentry.core.patterns import PATTERNS, Severity
5
+
6
+
7
+ def generate_skill(output_dir: Path) -> Path:
8
+ """pre-push-audit SKILL.md 생성."""
9
+ output_dir.mkdir(parents=True, exist_ok=True)
10
+ skill_path = output_dir / "SKILL.md"
11
+
12
+ danger_patterns = [p for p in PATTERNS if p.severity == Severity.DANGER]
13
+ warning_patterns = [p for p in PATTERNS if p.severity == Severity.WARNING]
14
+
15
+ danger_list = "\n".join(f" - `{p.regex}` — {p.description}" for p in danger_patterns)
16
+ warning_list = "\n".join(f" - `{p.regex}` — {p.description}" for p in warning_patterns)
17
+
18
+ content = f"""# Pre-push Security Audit Skill
19
+
20
+ 당신은 Git push 전 보안 감사 에이전트입니다.
21
+
22
+ ## 트리거
23
+ Claude Code에서 `/pre-push-audit` 호출 시 실행.
24
+ 또는 사용자가 "push 전에 보안 검사해줘", "보안 감사" 등을 요청할 때.
25
+
26
+ ## 감사 절차
27
+
28
+ ### Step 1: Push 대상 파일 확인
29
+ ```bash
30
+ git diff --name-only HEAD origin/main 2>/dev/null || git ls-files
31
+ ```
32
+
33
+ ### Step 2: 민감 정보 패턴 검사
34
+
35
+ **DANGER 패턴 (즉시 차단):**
36
+ {danger_list}
37
+
38
+ **WARNING 패턴 (주의 필요):**
39
+ {warning_list}
40
+
41
+ ### Step 3: .gitignore 설정 검증
42
+ 다음 파일들이 .gitignore에 포함되어 있는지 확인:
43
+ - `CLAUDE.md`, `**/CLAUDE.md`
44
+ - `LESSONS_LEARNED.md`, `DEVELOPMENT.md`, `RESEARCH_*.md`
45
+ - `.env`, `.env.*`
46
+ - `.claude/`
47
+
48
+ ### Step 4: 결과 보고
49
+
50
+ 결과를 아래 형식으로 표시하라:
51
+
52
+ | 파일 | 라인 | 유형 | 위험도 |
53
+ |------|------|------|--------|
54
+ | 파일명 | 번호 | 탐지 내용 | DANGER/WARNING/INFO |
55
+
56
+ ### Step 5: 판정
57
+
58
+ - **DANGER 항목 존재**: push 중단 권고 + 수정 지침 제공
59
+ - **WARNING만 존재**: 확인 요청 후 진행 여부 사용자 결정
60
+ - **클린**: "보안 검사 통과 ✓" 출력
61
+
62
+ ## 서브에이전트 구성 (하네스 사용 시)
63
+
64
+ ```yaml
65
+ agents:
66
+ - name: git-analyst
67
+ role: Git 히스토리 및 diff 분석
68
+ task: 커밋 히스토리에서 민감 정보 탐지
69
+
70
+ - name: security-reviewer
71
+ role: 패턴 매칭 및 리스크 평가
72
+ task: 현재 파일 내용에서 패턴 검사
73
+
74
+ - name: report-generator
75
+ role: 결과 요약 및 권장 조치
76
+ task: 발견 항목 종합 + 조치 방법 제시
77
+ ```
78
+
79
+ ## 관련 명령
80
+
81
+ ```bash
82
+ gitsentry audit . # 현재 저장소 감사
83
+ gitsentry history . # 히스토리 감사
84
+ gitsentry preview . # push 대상 미리보기
85
+ gitsentry pre-push # pre-push 훅 수동 실행
86
+ ```
87
+ """
88
+ skill_path.write_text(content, encoding="utf-8")
89
+ return skill_path
File without changes
gitsentry/utils/git.py ADDED
@@ -0,0 +1,65 @@
1
+ import subprocess
2
+ from pathlib import Path
3
+
4
+
5
+ def run_git(args: list[str], cwd: Path) -> str:
6
+ result = subprocess.run(
7
+ ["git"] + args,
8
+ cwd=cwd,
9
+ capture_output=True,
10
+ text=True,
11
+ timeout=30,
12
+ )
13
+ if result.returncode != 0:
14
+ raise RuntimeError(f"git {' '.join(args)} 실패: {result.stderr.strip()}")
15
+ return result.stdout
16
+
17
+
18
+ def is_git_repo(path: Path) -> bool:
19
+ try:
20
+ run_git(["rev-parse", "--git-dir"], cwd=path)
21
+ return True
22
+ except (RuntimeError, subprocess.TimeoutExpired):
23
+ return False
24
+
25
+
26
+ def get_tracked_files(repo_path: Path) -> list[Path]:
27
+ """현재 git이 추적하는 파일 목록."""
28
+ output = run_git(["ls-files"], cwd=repo_path)
29
+ return [repo_path / line for line in output.splitlines() if line]
30
+
31
+
32
+ def get_staged_files(repo_path: Path) -> list[Path]:
33
+ """staged(커밋 예정) 파일 목록."""
34
+ output = run_git(["diff", "--cached", "--name-only"], cwd=repo_path)
35
+ return [repo_path / line for line in output.splitlines() if line]
36
+
37
+
38
+ def get_untracked_files(repo_path: Path) -> list[Path]:
39
+ """추적되지 않는 파일 목록 (git status --short 기반)."""
40
+ output = run_git(["status", "--short"], cwd=repo_path)
41
+ untracked = []
42
+ for line in output.splitlines():
43
+ if line.startswith("??"):
44
+ rel_path = line[3:].strip()
45
+ untracked.append(repo_path / rel_path)
46
+ return untracked
47
+
48
+
49
+ def get_commit_log(repo_path: Path, max_count: int = 100) -> list[str]:
50
+ """커밋 해시 목록 (최신순)."""
51
+ output = run_git(["log", f"--max-count={max_count}", "--format=%H"], cwd=repo_path)
52
+ return [h for h in output.splitlines() if h]
53
+
54
+
55
+ def get_commit_diff(repo_path: Path, commit_hash: str) -> str:
56
+ """특정 커밋의 diff 텍스트."""
57
+ return run_git(["show", "--unified=0", commit_hash], cwd=repo_path)
58
+
59
+
60
+ def get_remote_url(repo_path: Path) -> str | None:
61
+ try:
62
+ url = run_git(["remote", "get-url", "origin"], cwd=repo_path)
63
+ return url.strip()
64
+ except RuntimeError:
65
+ return None
@@ -0,0 +1,60 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from dotenv import load_dotenv
5
+
6
+
7
+ def get_github_token() -> str:
8
+ load_dotenv()
9
+ token = os.getenv("GITHUB_TOKEN")
10
+ if not token:
11
+ raise RuntimeError("GITHUB_TOKEN이 설정되지 않았습니다. .env 파일을 확인하세요.")
12
+ return token
13
+
14
+
15
+ def get_github_client():
16
+ from github import Github
17
+ return Github(get_github_token())
18
+
19
+
20
+ def list_user_repos(include_private: bool = True) -> list[dict]:
21
+ g = get_github_client()
22
+ user = g.get_user()
23
+ repos = []
24
+ for repo in user.get_repos():
25
+ repos.append({
26
+ "name": repo.name,
27
+ "full_name": repo.full_name,
28
+ "private": repo.private,
29
+ "url": repo.html_url,
30
+ "clone_url": repo.clone_url,
31
+ })
32
+ return repos
33
+
34
+
35
+ def get_repo_files(full_repo_name: str, path: str = "") -> list[dict]:
36
+ """GitHub 저장소의 파일 목록 반환."""
37
+ g = get_github_client()
38
+ repo = g.get_repo(full_repo_name)
39
+ contents = repo.get_contents(path)
40
+ files = []
41
+ while contents:
42
+ item = contents.pop(0)
43
+ if item.type == "dir":
44
+ contents.extend(repo.get_contents(item.path))
45
+ else:
46
+ files.append({
47
+ "path": item.path,
48
+ "name": item.name,
49
+ "size": item.size,
50
+ "download_url": item.download_url,
51
+ })
52
+ return files
53
+
54
+
55
+ def get_file_content(full_repo_name: str, file_path: str) -> str:
56
+ """GitHub 저장소에서 파일 내용 반환 (base64 디코딩)."""
57
+ g = get_github_client()
58
+ repo = g.get_repo(full_repo_name)
59
+ content = repo.get_contents(file_path)
60
+ return content.decoded_content.decode("utf-8", errors="replace")
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.4
2
+ Name: gitsentry
3
+ Version: 0.1.0
4
+ Summary: GitHub security audit CLI for LLM-assisted development environments
5
+ Project-URL: Homepage, https://github.com/beret21/GitSentry
6
+ Project-URL: Repository, https://github.com/beret21/GitSentry
7
+ Project-URL: Issues, https://github.com/beret21/GitSentry/issues
8
+ Author-email: Yoonseok Jang <beret21@gmail.com>
9
+ License: MIT
10
+ Keywords: audit,claude,cli,github,llm,security
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Security
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: anthropic>=0.40.0
21
+ Requires-Dist: gitpython>=3.1
22
+ Requires-Dist: pydantic>=2.0
23
+ Requires-Dist: pygithub>=2.0
24
+ Requires-Dist: python-dotenv>=1.0
25
+ Requires-Dist: rich>=13.0
26
+ Requires-Dist: typer>=0.12
27
+ Provides-Extra: dev
28
+ Requires-Dist: mypy>=1.10; extra == 'dev'
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.4; extra == 'dev'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # GitSentry
35
+
36
+ LLM 개발 환경(Claude Code, Codex 등)에서 GitHub push 전 보안 감사 CLI 도구.
37
+
38
+ ## 기능
39
+
40
+ | 명령 | 설명 |
41
+ |------|------|
42
+ | `gitsentry audit .` | 현재 저장소 공개 파일 보안 감사 |
43
+ | `gitsentry history .` | Git 커밋 히스토리 보안 감사 |
44
+ | `gitsentry scan --all` | 계정 전체 GitHub 저장소 감사 |
45
+ | `gitsentry preview .` | Push 대상 vs 제외 파일 시각화 |
46
+ | `gitsentry pre-push` | Pre-push 훅 수동 실행 |
47
+ | `gitsentry generate-skill` | Claude Code 보안 감사 스킬 생성 |
48
+
49
+ ## 설치
50
+
51
+ ```bash
52
+ # pipx 권장 (전역 설치, 가상환경 불필요)
53
+ pipx install git+https://github.com/beret21/GitSentry.git
54
+
55
+ # 업데이트
56
+ pipx upgrade gitsentry
57
+ ```
58
+
59
+ pipx가 없다면:
60
+
61
+ ```bash
62
+ brew install pipx
63
+ pipx ensurepath
64
+ ```
65
+
66
+ 개발용 설치:
67
+
68
+ ```bash
69
+ git clone https://github.com/beret21/GitSentry.git
70
+ cd GitSentry
71
+ pip install -e ".[dev]"
72
+ ```
73
+
74
+ ## 빠른 시작
75
+
76
+ ```bash
77
+ # 현재 저장소 감사
78
+ gitsentry audit .
79
+
80
+ # Push 전 미리보기
81
+ gitsentry preview .
82
+
83
+ # pre-push 훅 설치
84
+ ./scripts/install-hook.sh .
85
+ ```
86
+
87
+ ## 환경 변수 (.env)
88
+
89
+ ```
90
+ GITHUB_TOKEN=ghp_your_token # 원격 저장소 감사에 필요
91
+ ANTHROPIC_API_KEY=sk-ant-... # --llm 옵션 사용 시 필요
92
+ ```
93
+
94
+ ## 탐지 패턴
95
+
96
+ **DANGER (push 차단):** API 키(`sk-`, `ghp_`, `AKIA`), 비밀번호, Bearer 토큰
97
+
98
+ **WARNING (주의):** LLM 내부 문서 (CLAUDE.md, LESSONS_LEARNED.md, DEVELOPMENT.md)
@@ -0,0 +1,19 @@
1
+ gitsentry/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ gitsentry/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ gitsentry/cli/main.py,sha256=2FqM8WLew3IDeQqeKe8uJJP5Ld2vC7WBynduySdvjMU,9151
4
+ gitsentry/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ gitsentry/core/auditor.py,sha256=OlwhyKikk48DIVhdpNmQ1nZXR07F6E1JeAMRIFOJ0L0,2173
6
+ gitsentry/core/history.py,sha256=WelkjV9TyUFJE2I4ugfdweK9oJf1hGzsMc_WDI9NSkI,1863
7
+ gitsentry/core/patterns.py,sha256=Tpw8d8IWRrb35eJSt-vxlUW-w7cp-9DhD6F_F1gWmEc,2272
8
+ gitsentry/core/preview.py,sha256=-2HPXvjrbElgQc7jnI-3yfjGZFjMLXzqF0TGbDSbG9c,1195
9
+ gitsentry/core/scanner.py,sha256=ouSVB4SsKB3sdj68MSKyqm0afbEFAqc6-0Ic7BH5IHM,2330
10
+ gitsentry/llm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ gitsentry/llm/client.py,sha256=F92ApACz-6YeCTZJvCnP-qAnh-GUPkLQmWBi9CCrLzA,1271
12
+ gitsentry/llm/skill_gen.py,sha256=pQK6svph8ZqPtihAmHq8ZFovvWFLTpdcSD43i1anFlM,2621
13
+ gitsentry/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ gitsentry/utils/git.py,sha256=YsCh3C9uW40S8x7yuq35dmjPMfTy1sKEcUHcCVpnAHk,2078
15
+ gitsentry/utils/github_api.py,sha256=m9DSGQneJHDZu8KBa680Ywy0yy62OAbMpXiTPH_mWaQ,1731
16
+ gitsentry-0.1.0.dist-info/METADATA,sha256=mlBTFUib7nLZS7vBKnk_jM_We73Vu4iJ7lECQLvhDO4,2694
17
+ gitsentry-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
18
+ gitsentry-0.1.0.dist-info/entry_points.txt,sha256=Uz5BKlhWUHx4FfBFPgMqbHpeNWK-0P303LI4W9IqPQQ,53
19
+ gitsentry-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gitsentry = gitsentry.cli.main:app