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 +1 -0
- gitsentry/cli/__init__.py +0 -0
- gitsentry/cli/main.py +275 -0
- gitsentry/core/__init__.py +0 -0
- gitsentry/core/auditor.py +64 -0
- gitsentry/core/history.py +55 -0
- gitsentry/core/patterns.py +63 -0
- gitsentry/core/preview.py +39 -0
- gitsentry/core/scanner.py +68 -0
- gitsentry/llm/__init__.py +0 -0
- gitsentry/llm/client.py +38 -0
- gitsentry/llm/skill_gen.py +89 -0
- gitsentry/utils/__init__.py +0 -0
- gitsentry/utils/git.py +65 -0
- gitsentry/utils/github_api.py +60 -0
- gitsentry-0.1.0.dist-info/METADATA +98 -0
- gitsentry-0.1.0.dist-info/RECORD +19 -0
- gitsentry-0.1.0.dist-info/WHEEL +4 -0
- gitsentry-0.1.0.dist-info/entry_points.txt +2 -0
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
|
gitsentry/llm/client.py
ADDED
|
@@ -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,,
|