sober-audit 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sober/__init__.py +7 -0
- sober/cli.py +491 -0
- sober/core/__init__.py +1 -0
- sober/core/ci.py +61 -0
- sober/core/config.py +89 -0
- sober/core/git_helper.py +174 -0
- sober/core/manifest.py +107 -0
- sober/core/registry_cache.py +82 -0
- sober/core/remediation.py +61 -0
- sober/core/sanitizer.py +78 -0
- sober/core/scanner.py +146 -0
- sober/core/scorer.py +176 -0
- sober/core/watcher.py +59 -0
- sober/detectors/__init__.py +47 -0
- sober/detectors/antipatterns.py +217 -0
- sober/detectors/chatter.py +54 -0
- sober/detectors/duplicates.py +88 -0
- sober/detectors/ghost_code.py +201 -0
- sober/detectors/hallucinations.py +550 -0
- sober/detectors/security.py +118 -0
- sober/detectors/slopsquatting.py +83 -0
- sober/ui/__init__.py +1 -0
- sober/ui/console.py +146 -0
- sober_audit-2.0.0.dist-info/METADATA +450 -0
- sober_audit-2.0.0.dist-info/RECORD +29 -0
- sober_audit-2.0.0.dist-info/WHEEL +5 -0
- sober_audit-2.0.0.dist-info/entry_points.txt +2 -0
- sober_audit-2.0.0.dist-info/licenses/LICENSE +21 -0
- sober_audit-2.0.0.dist-info/top_level.txt +1 -0
sober/__init__.py
ADDED
sober/cli.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI Entrypoint for SOBER - The Vibe Coding Hangover Cure.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from sober import __version__
|
|
12
|
+
from sober.core.ci import init_github_actions_workflow
|
|
13
|
+
from sober.core.config import load_config
|
|
14
|
+
from sober.core.git_helper import (
|
|
15
|
+
get_git_diff_range_files,
|
|
16
|
+
get_git_hook_status,
|
|
17
|
+
get_git_hooks_dir,
|
|
18
|
+
install_git_hook,
|
|
19
|
+
uninstall_git_hook,
|
|
20
|
+
)
|
|
21
|
+
from sober.core.manifest import ManifestInspector
|
|
22
|
+
from sober.core.remediation import generate_agent_remediation_prompt
|
|
23
|
+
from sober.core.sanitizer import apply_cure
|
|
24
|
+
from sober.core.scanner import FileToAudit, get_all_code_files, get_git_modified_files
|
|
25
|
+
from sober.core.scorer import SoberAuditReport
|
|
26
|
+
from sober.core.watcher import LiveSoberWatcher
|
|
27
|
+
from sober.detectors.antipatterns import audit_antipatterns
|
|
28
|
+
from sober.detectors.chatter import audit_chatter
|
|
29
|
+
from sober.detectors.duplicates import audit_duplicates
|
|
30
|
+
from sober.detectors.ghost_code import audit_ghost_code
|
|
31
|
+
from sober.detectors.hallucinations import audit_hallucinations
|
|
32
|
+
from sober.detectors.security import audit_security
|
|
33
|
+
from sober.ui.console import (
|
|
34
|
+
console,
|
|
35
|
+
print_banner,
|
|
36
|
+
render_cure_results,
|
|
37
|
+
render_hook_status,
|
|
38
|
+
render_report,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
app = typer.Typer(
|
|
42
|
+
help="🍸 The Vibe Coding Hangover Cure. Audit, sanitize, and proofread AI-generated code before pushing to production.",
|
|
43
|
+
add_completion=False,
|
|
44
|
+
no_args_is_help=False,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
hook_app = typer.Typer(help="Manage Git pre-commit hooks for automated sobriety checks.", add_completion=False)
|
|
48
|
+
app.add_typer(hook_app, name="hook")
|
|
49
|
+
|
|
50
|
+
ci_app = typer.Typer(help="Manage CI/CD workflows and automated GitHub Actions gates.", add_completion=False)
|
|
51
|
+
app.add_typer(ci_app, name="ci")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def version_callback(value: bool):
|
|
55
|
+
if value:
|
|
56
|
+
console.print(f"[bold cyan]sober[/] version [bold green]{__version__}[/]")
|
|
57
|
+
raise typer.Exit()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.callback(invoke_without_command=True)
|
|
61
|
+
def _main_callback(
|
|
62
|
+
ctx: typer.Context,
|
|
63
|
+
_version: bool | None = typer.Option(
|
|
64
|
+
None, "--version", "-v", help="Show version and exit.", callback=version_callback, is_eager=True
|
|
65
|
+
),
|
|
66
|
+
):
|
|
67
|
+
if ctx.invoked_subcommand is None:
|
|
68
|
+
scan(Path("."))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _execute_audit(
|
|
72
|
+
files: list[FileToAudit],
|
|
73
|
+
path: Path,
|
|
74
|
+
json_output: bool = False,
|
|
75
|
+
sarif_output: bool = False,
|
|
76
|
+
markdown_output: bool = False,
|
|
77
|
+
cure: bool = False,
|
|
78
|
+
strict: bool = False,
|
|
79
|
+
min_score_override: float | None = None,
|
|
80
|
+
offline: bool = False,
|
|
81
|
+
) -> SoberAuditReport:
|
|
82
|
+
"""Core audit runner for scan, diff, and explain commands."""
|
|
83
|
+
start_time = time.time()
|
|
84
|
+
config = load_config(path)
|
|
85
|
+
if offline:
|
|
86
|
+
config.offline_mode = True
|
|
87
|
+
manifest = ManifestInspector(path)
|
|
88
|
+
|
|
89
|
+
# Filter out files ignored by config
|
|
90
|
+
filtered_files = []
|
|
91
|
+
for f in files:
|
|
92
|
+
rel = str(f.path.relative_to(path) if f.path.is_relative_to(path) else f.path)
|
|
93
|
+
if not config.is_path_ignored(rel):
|
|
94
|
+
filtered_files.append(f)
|
|
95
|
+
files = filtered_files
|
|
96
|
+
|
|
97
|
+
if not files:
|
|
98
|
+
empty_report = SoberAuditReport([], files_scanned=0, duration_seconds=0.0)
|
|
99
|
+
if json_output:
|
|
100
|
+
print(json.dumps({"error": "No supported source files found to audit."}))
|
|
101
|
+
elif sarif_output:
|
|
102
|
+
print(json.dumps(empty_report.to_sarif(), indent=2))
|
|
103
|
+
elif markdown_output:
|
|
104
|
+
print(empty_report.to_markdown())
|
|
105
|
+
else:
|
|
106
|
+
console.print("[bold yellow]No supported source files (.py, .ts, .tsx, .js, .jsx) found to audit.[/]")
|
|
107
|
+
return empty_report
|
|
108
|
+
|
|
109
|
+
# Run detectors
|
|
110
|
+
all_issues = []
|
|
111
|
+
for f in files:
|
|
112
|
+
if config.is_detector_enabled("hallucinations"):
|
|
113
|
+
all_issues.extend(audit_hallucinations(f, path, manifest_inspector=manifest, config=config))
|
|
114
|
+
if config.is_detector_enabled("antipatterns"):
|
|
115
|
+
all_issues.extend(audit_antipatterns(f, path))
|
|
116
|
+
if config.is_detector_enabled("ghost_code"):
|
|
117
|
+
all_issues.extend(audit_ghost_code(f, path))
|
|
118
|
+
if config.is_detector_enabled("security"):
|
|
119
|
+
all_issues.extend(audit_security(f, path, custom_secret_patterns=config.custom_secret_patterns))
|
|
120
|
+
if config.is_detector_enabled("chatter"):
|
|
121
|
+
all_issues.extend(audit_chatter(f, path))
|
|
122
|
+
|
|
123
|
+
if config.is_detector_enabled("duplicates"):
|
|
124
|
+
all_issues.extend(audit_duplicates(files, path))
|
|
125
|
+
|
|
126
|
+
duration = time.time() - start_time
|
|
127
|
+
report = SoberAuditReport(all_issues, files_scanned=len(files), duration_seconds=duration)
|
|
128
|
+
|
|
129
|
+
# Render outputs
|
|
130
|
+
if json_output:
|
|
131
|
+
print(json.dumps(report.to_dict(), indent=2))
|
|
132
|
+
elif sarif_output:
|
|
133
|
+
print(json.dumps(report.to_sarif(), indent=2))
|
|
134
|
+
elif markdown_output:
|
|
135
|
+
print(report.to_markdown())
|
|
136
|
+
else:
|
|
137
|
+
render_report(report)
|
|
138
|
+
|
|
139
|
+
# Optional auto-cure
|
|
140
|
+
if cure:
|
|
141
|
+
cured = apply_cure(report.issues, path)
|
|
142
|
+
if not (json_output or sarif_output or markdown_output):
|
|
143
|
+
render_cure_results(cured)
|
|
144
|
+
|
|
145
|
+
# Strict mode validation
|
|
146
|
+
effective_min_score = min_score_override if min_score_override is not None else config.min_score
|
|
147
|
+
should_fail = False
|
|
148
|
+
|
|
149
|
+
if strict:
|
|
150
|
+
if report.score < effective_min_score:
|
|
151
|
+
should_fail = True
|
|
152
|
+
if config.fail_on_critical and report.critical_count > 0:
|
|
153
|
+
should_fail = True
|
|
154
|
+
|
|
155
|
+
if should_fail:
|
|
156
|
+
if not (json_output or sarif_output or markdown_output):
|
|
157
|
+
console.print(
|
|
158
|
+
f"\n[bold red]❌ STRICT CHECK FAILED: Sober Score is {report.score}% (<{effective_min_score}%) or critical issues detected.[/]\n"
|
|
159
|
+
)
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
|
|
162
|
+
return report
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
@app.command(name="scan")
|
|
166
|
+
def scan(
|
|
167
|
+
path: Path = typer.Argument(
|
|
168
|
+
Path("."),
|
|
169
|
+
help="Target repository or folder path to audit (defaults to current directory).",
|
|
170
|
+
exists=True,
|
|
171
|
+
file_okay=False,
|
|
172
|
+
dir_okay=True,
|
|
173
|
+
resolve_path=True,
|
|
174
|
+
),
|
|
175
|
+
all_files: bool = typer.Option(
|
|
176
|
+
False,
|
|
177
|
+
"--all",
|
|
178
|
+
"-a",
|
|
179
|
+
help="Audit all codebase files instead of only uncommitted / modified git changes.",
|
|
180
|
+
),
|
|
181
|
+
cure: bool = typer.Option(
|
|
182
|
+
False,
|
|
183
|
+
"--cure",
|
|
184
|
+
"-c",
|
|
185
|
+
help="Automatically sanitize and strip leftover debug statements, markdown fences, & AI debris.",
|
|
186
|
+
),
|
|
187
|
+
strict: bool = typer.Option(
|
|
188
|
+
False,
|
|
189
|
+
"--strict",
|
|
190
|
+
"-s",
|
|
191
|
+
help="Fail with exit code 1 if the Sober Score is below threshold or has critical issues.",
|
|
192
|
+
),
|
|
193
|
+
min_score: float | None = typer.Option(
|
|
194
|
+
None,
|
|
195
|
+
"--min-score",
|
|
196
|
+
help="Override minimum score threshold for strict mode (default: 95.0).",
|
|
197
|
+
),
|
|
198
|
+
offline: bool = typer.Option(
|
|
199
|
+
False,
|
|
200
|
+
"--offline",
|
|
201
|
+
help="Disable external network queries (PyPI/npm) and perform air-gapped manifest-only audits.",
|
|
202
|
+
),
|
|
203
|
+
json_output: bool = typer.Option(
|
|
204
|
+
False,
|
|
205
|
+
"--json",
|
|
206
|
+
"-j",
|
|
207
|
+
help="Output raw JSON results for CI/CD integrations.",
|
|
208
|
+
),
|
|
209
|
+
sarif_output: bool = typer.Option(
|
|
210
|
+
False,
|
|
211
|
+
"--sarif",
|
|
212
|
+
help="Output SARIF 2.1.0 format for GitHub Code Scanning Alerts.",
|
|
213
|
+
),
|
|
214
|
+
markdown_output: bool = typer.Option(
|
|
215
|
+
False,
|
|
216
|
+
"--markdown",
|
|
217
|
+
"-m",
|
|
218
|
+
help="Output GitHub Flavored Markdown summary suitable for PR comments.",
|
|
219
|
+
),
|
|
220
|
+
):
|
|
221
|
+
"""Scan code for AI hallucinations, slopsquatting, ghost code, security leaks, and hangover slop."""
|
|
222
|
+
if not (json_output or sarif_output or markdown_output):
|
|
223
|
+
print_banner()
|
|
224
|
+
|
|
225
|
+
files = []
|
|
226
|
+
if not all_files:
|
|
227
|
+
files = get_git_modified_files(path)
|
|
228
|
+
if not files and not (json_output or sarif_output or markdown_output):
|
|
229
|
+
console.print("[dim]No uncommitted git changes detected. Auditing all repository files...[/]\n")
|
|
230
|
+
files = get_all_code_files(path)
|
|
231
|
+
else:
|
|
232
|
+
files = get_all_code_files(path)
|
|
233
|
+
|
|
234
|
+
_execute_audit(
|
|
235
|
+
files=files,
|
|
236
|
+
path=path,
|
|
237
|
+
json_output=json_output,
|
|
238
|
+
sarif_output=sarif_output,
|
|
239
|
+
markdown_output=markdown_output,
|
|
240
|
+
cure=cure,
|
|
241
|
+
strict=strict,
|
|
242
|
+
min_score_override=min_score,
|
|
243
|
+
offline=offline,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.command(name="diff")
|
|
248
|
+
def _diff_command(
|
|
249
|
+
revision: str = typer.Argument(
|
|
250
|
+
...,
|
|
251
|
+
help="Git revision or commit range to audit (e.g. 'HEAD~1', 'main..HEAD', 'origin/main').",
|
|
252
|
+
),
|
|
253
|
+
path: Path = typer.Option(
|
|
254
|
+
Path("."),
|
|
255
|
+
"--path",
|
|
256
|
+
"-p",
|
|
257
|
+
help="Target repository root directory.",
|
|
258
|
+
exists=True,
|
|
259
|
+
file_okay=False,
|
|
260
|
+
dir_okay=True,
|
|
261
|
+
resolve_path=True,
|
|
262
|
+
),
|
|
263
|
+
cure: bool = typer.Option(False, "--cure", "-c", help="Auto-sanitize detected debug statements."),
|
|
264
|
+
strict: bool = typer.Option(False, "--strict", "-s", help="Fail if sobriety threshold not met."),
|
|
265
|
+
min_score: float | None = typer.Option(None, "--min-score", help="Override minimum score threshold."),
|
|
266
|
+
offline: bool = typer.Option(False, "--offline", help="Perform offline manifest audit without external network calls."),
|
|
267
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output raw JSON."),
|
|
268
|
+
sarif_output: bool = typer.Option(False, "--sarif", help="Output SARIF 2.1.0."),
|
|
269
|
+
markdown_output: bool = typer.Option(False, "--markdown", "-m", help="Output Markdown report."),
|
|
270
|
+
):
|
|
271
|
+
"""Audit code changes introduced across a specific git commit range or branch."""
|
|
272
|
+
if not (json_output or sarif_output or markdown_output):
|
|
273
|
+
print_banner()
|
|
274
|
+
console.print(f"[bold cyan]Auditing changes in git range:[/] [yellow]{revision}[/]\n")
|
|
275
|
+
|
|
276
|
+
files = get_git_diff_range_files(path, revision)
|
|
277
|
+
_execute_audit(
|
|
278
|
+
files=files,
|
|
279
|
+
path=path,
|
|
280
|
+
json_output=json_output,
|
|
281
|
+
sarif_output=sarif_output,
|
|
282
|
+
markdown_output=markdown_output,
|
|
283
|
+
cure=cure,
|
|
284
|
+
strict=strict,
|
|
285
|
+
min_score_override=min_score,
|
|
286
|
+
offline=offline,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@app.command(name="watch")
|
|
291
|
+
def _watch_command(
|
|
292
|
+
path: Path = typer.Argument(
|
|
293
|
+
Path("."),
|
|
294
|
+
help="Target repository to monitor in real-time.",
|
|
295
|
+
exists=True,
|
|
296
|
+
file_okay=False,
|
|
297
|
+
dir_okay=True,
|
|
298
|
+
resolve_path=True,
|
|
299
|
+
),
|
|
300
|
+
interval: float = typer.Option(0.5, "--interval", "-i", help="Polling interval in seconds."),
|
|
301
|
+
cure: bool = typer.Option(False, "--cure", "-c", help="Auto-cure detected debug statements and chatter on save."),
|
|
302
|
+
):
|
|
303
|
+
"""Live continuous watcher daemon for real-time AI vibe-coding feedback."""
|
|
304
|
+
print_banner()
|
|
305
|
+
console.print(f"[bold cyan]👀 SOBER Live Watcher active on {path.resolve()}[/] [dim](Ctrl+C to exit)[/]\n")
|
|
306
|
+
|
|
307
|
+
def _on_change():
|
|
308
|
+
console.clear()
|
|
309
|
+
print_banner()
|
|
310
|
+
console.print("[bold yellow]⚡ Change detected! Re-auditing sobriety...[/]\n")
|
|
311
|
+
files = get_git_modified_files(path)
|
|
312
|
+
if not files:
|
|
313
|
+
files = get_all_code_files(path)
|
|
314
|
+
_execute_audit(files=files, path=path, cure=cure)
|
|
315
|
+
|
|
316
|
+
watcher = LiveSoberWatcher(path, poll_interval=interval)
|
|
317
|
+
try:
|
|
318
|
+
watcher.watch(_on_change)
|
|
319
|
+
except KeyboardInterrupt:
|
|
320
|
+
console.print("\n[bold cyan]SOBER Live Watcher stopped.[/]")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@app.command(name="explain")
|
|
324
|
+
def _explain_command(
|
|
325
|
+
path: Path = typer.Argument(
|
|
326
|
+
Path("."),
|
|
327
|
+
help="Target repository or folder path to audit.",
|
|
328
|
+
exists=True,
|
|
329
|
+
file_okay=False,
|
|
330
|
+
dir_okay=True,
|
|
331
|
+
resolve_path=True,
|
|
332
|
+
),
|
|
333
|
+
all_files: bool = typer.Option(False, "--all", "-a", help="Audit all files instead of only uncommitted changes."),
|
|
334
|
+
):
|
|
335
|
+
"""Generate high-density prompt context for autonomous coding agents (Claude, Cursor, Aider) to self-heal."""
|
|
336
|
+
files = get_all_code_files(path) if all_files else get_git_modified_files(path)
|
|
337
|
+
if not files and not all_files:
|
|
338
|
+
files = get_all_code_files(path)
|
|
339
|
+
|
|
340
|
+
config = load_config(path)
|
|
341
|
+
manifest = ManifestInspector(path)
|
|
342
|
+
|
|
343
|
+
all_issues = []
|
|
344
|
+
for f in files:
|
|
345
|
+
if config.is_detector_enabled("hallucinations"):
|
|
346
|
+
all_issues.extend(audit_hallucinations(f, path, manifest_inspector=manifest, config=config))
|
|
347
|
+
if config.is_detector_enabled("antipatterns"):
|
|
348
|
+
all_issues.extend(audit_antipatterns(f, path))
|
|
349
|
+
if config.is_detector_enabled("ghost_code"):
|
|
350
|
+
all_issues.extend(audit_ghost_code(f, path))
|
|
351
|
+
if config.is_detector_enabled("security"):
|
|
352
|
+
all_issues.extend(audit_security(f, path, custom_secret_patterns=config.custom_secret_patterns))
|
|
353
|
+
if config.is_detector_enabled("chatter"):
|
|
354
|
+
all_issues.extend(audit_chatter(f, path))
|
|
355
|
+
|
|
356
|
+
if config.is_detector_enabled("duplicates"):
|
|
357
|
+
all_issues.extend(audit_duplicates(files, path))
|
|
358
|
+
|
|
359
|
+
report = SoberAuditReport(all_issues, files_scanned=len(files), duration_seconds=0.0)
|
|
360
|
+
prompt = generate_agent_remediation_prompt(
|
|
361
|
+
issues=report.issues,
|
|
362
|
+
score=report.score,
|
|
363
|
+
grade=report.grade,
|
|
364
|
+
status_label=report.status_label,
|
|
365
|
+
files_scanned=len(files),
|
|
366
|
+
)
|
|
367
|
+
print(prompt)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@app.command(name="cure")
|
|
371
|
+
def _cure_command(
|
|
372
|
+
path: Path = typer.Argument(
|
|
373
|
+
Path("."),
|
|
374
|
+
help="Target folder to sanitize debug debris and AI chatter.",
|
|
375
|
+
exists=True,
|
|
376
|
+
file_okay=False,
|
|
377
|
+
dir_okay=True,
|
|
378
|
+
resolve_path=True,
|
|
379
|
+
),
|
|
380
|
+
all_files: bool = typer.Option(False, "--all", "-a", help="Cure all files instead of only uncommitted changes."),
|
|
381
|
+
):
|
|
382
|
+
"""Standalone sanitization tool: auto-strip leftover debug statements, debuggers, and AI chatter."""
|
|
383
|
+
print_banner()
|
|
384
|
+
files = get_all_code_files(path) if all_files else get_git_modified_files(path)
|
|
385
|
+
if not files and not all_files:
|
|
386
|
+
files = get_all_code_files(path)
|
|
387
|
+
|
|
388
|
+
all_issues = []
|
|
389
|
+
for f in files:
|
|
390
|
+
all_issues.extend(audit_ghost_code(f, path))
|
|
391
|
+
all_issues.extend(audit_chatter(f, path))
|
|
392
|
+
|
|
393
|
+
cured = apply_cure(all_issues, path)
|
|
394
|
+
render_cure_results(cured)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@hook_app.command(name="install")
|
|
398
|
+
def _hook_install(
|
|
399
|
+
path: Path = typer.Argument(Path("."), help="Target Git repository root path."),
|
|
400
|
+
):
|
|
401
|
+
"""Install a native Git pre-commit hook that runs sober scan before every commit."""
|
|
402
|
+
success, msg = install_git_hook(path)
|
|
403
|
+
if success:
|
|
404
|
+
console.print(f"[bold green]✔ Successfully installed sober pre-commit hook at {msg}[/]\n")
|
|
405
|
+
else:
|
|
406
|
+
console.print(f"[bold red]❌ {msg}[/]\n")
|
|
407
|
+
raise typer.Exit(1)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@hook_app.command(name="uninstall")
|
|
411
|
+
def _hook_uninstall(
|
|
412
|
+
path: Path = typer.Argument(Path("."), help="Target Git repository root path."),
|
|
413
|
+
):
|
|
414
|
+
"""Remove the sober Git pre-commit hook."""
|
|
415
|
+
success, msg = uninstall_git_hook(path)
|
|
416
|
+
if success:
|
|
417
|
+
console.print(f"[bold green]✔ {msg}[/]\n")
|
|
418
|
+
else:
|
|
419
|
+
console.print(f"[bold yellow]{msg}[/]\n")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@hook_app.command(name="status")
|
|
423
|
+
def _hook_status(
|
|
424
|
+
path: Path = typer.Argument(Path("."), help="Target Git repository root path."),
|
|
425
|
+
):
|
|
426
|
+
"""Check the status of the Git pre-commit hook."""
|
|
427
|
+
installed = get_git_hook_status(path)
|
|
428
|
+
hooks_dir = get_git_hooks_dir(path)
|
|
429
|
+
hook_path = str(hooks_dir / "pre-commit") if hooks_dir else ""
|
|
430
|
+
render_hook_status(installed, hook_path)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
@ci_app.command(name="init")
|
|
434
|
+
def _ci_init(
|
|
435
|
+
path: Path = typer.Argument(Path("."), help="Target repository root path."),
|
|
436
|
+
):
|
|
437
|
+
"""Initialize a GitHub Actions workflow for automated PR sobriety checks."""
|
|
438
|
+
wf_path = init_github_actions_workflow(path)
|
|
439
|
+
console.print(f"[bold green]✔ Generated GitHub Actions workflow at {wf_path}[/]\n")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@app.command(name="init")
|
|
443
|
+
def _init_command(
|
|
444
|
+
path: Path = typer.Argument(Path("."), help="Target directory to create sober.toml."),
|
|
445
|
+
):
|
|
446
|
+
"""Initialize a starter sober.toml configuration file."""
|
|
447
|
+
target_file = path / "sober.toml"
|
|
448
|
+
if target_file.exists():
|
|
449
|
+
console.print(f"[bold yellow]Configuration file {target_file} already exists.[/]")
|
|
450
|
+
raise typer.Exit(0)
|
|
451
|
+
|
|
452
|
+
starter_config = """# SOBER Configuration File (v2.0)
|
|
453
|
+
# Documentation: https://github.com/shenald-dev/sober
|
|
454
|
+
|
|
455
|
+
[sober]
|
|
456
|
+
min_score = 95.0
|
|
457
|
+
fail_on_critical = true
|
|
458
|
+
max_workers = 10
|
|
459
|
+
offline_mode = false
|
|
460
|
+
|
|
461
|
+
# Paths to ignore during audits
|
|
462
|
+
ignore_paths = [
|
|
463
|
+
"tests/*",
|
|
464
|
+
"docs/*",
|
|
465
|
+
"migrations/*",
|
|
466
|
+
]
|
|
467
|
+
|
|
468
|
+
# Allowed internal or proprietary packages that should never trigger hallucination alerts
|
|
469
|
+
allowed_packages = [
|
|
470
|
+
"@my-org/*",
|
|
471
|
+
# "internal_company_sdk",
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
# Disable specific detectors if needed
|
|
475
|
+
# disabled_detectors = ["duplicates"]
|
|
476
|
+
|
|
477
|
+
# Custom proprietary secret regex patterns
|
|
478
|
+
# [[sober.custom_secret_patterns]]
|
|
479
|
+
# name = "Company Internal API Token"
|
|
480
|
+
# pattern = "comp_[A-Za-z0-9]{32}"
|
|
481
|
+
"""
|
|
482
|
+
target_file.write_text(starter_config, encoding="utf-8")
|
|
483
|
+
console.print(f"[bold green]✔ Initialized starter configuration at {target_file}[/]")
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def main():
|
|
487
|
+
app()
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
if __name__ == "__main__":
|
|
491
|
+
main()
|
sober/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core audit, scanning, scoring, and sanitizing engines."""
|
sober/core/ci.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CI / GitHub Actions Workflow Generator for SOBER.
|
|
3
|
+
Bootstraps ready-to-run GitHub Actions workflows for automated PR sobriety checks.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
GITHUB_ACTIONS_WORKFLOW = """name: SOBER Code Sobriety Radar
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
pull_request:
|
|
12
|
+
branches: [main, master]
|
|
13
|
+
push:
|
|
14
|
+
branches: [main, master]
|
|
15
|
+
|
|
16
|
+
jobs:
|
|
17
|
+
sober-audit:
|
|
18
|
+
name: AI Hangover & Security Audit
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- name: Checkout Code
|
|
22
|
+
uses: actions/checkout@v4
|
|
23
|
+
with:
|
|
24
|
+
fetch-depth: 0
|
|
25
|
+
|
|
26
|
+
- name: Set up Python
|
|
27
|
+
uses: actions/setup-python@v5
|
|
28
|
+
with:
|
|
29
|
+
python-version: "3.11"
|
|
30
|
+
|
|
31
|
+
- name: Install SOBER
|
|
32
|
+
run: |
|
|
33
|
+
pip install git+https://github.com/shenald-dev/sober.git
|
|
34
|
+
|
|
35
|
+
- name: Run SOBER Audit (SARIF & Markdown)
|
|
36
|
+
run: |
|
|
37
|
+
sober scan --all --sarif > sober.sarif
|
|
38
|
+
sober scan --all --markdown > sober-summary.md
|
|
39
|
+
cat sober-summary.md >> $GITHUB_STEP_SUMMARY
|
|
40
|
+
continue-on-error: true
|
|
41
|
+
|
|
42
|
+
- name: Upload SARIF to GitHub Code Scanning
|
|
43
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
44
|
+
if: always()
|
|
45
|
+
with:
|
|
46
|
+
sarif_file: sober.sarif
|
|
47
|
+
category: sober-hangover-guard
|
|
48
|
+
|
|
49
|
+
- name: Strict Sobriety Gate
|
|
50
|
+
run: |
|
|
51
|
+
sober scan --all --strict --min-score 90
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def init_github_actions_workflow(repo_root: Path) -> Path:
|
|
56
|
+
"""Create .github/workflows/sober-guard.yml."""
|
|
57
|
+
workflows_dir = repo_root / ".github" / "workflows"
|
|
58
|
+
workflows_dir.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
workflow_file = workflows_dir / "sober-guard.yml"
|
|
60
|
+
workflow_file.write_text(GITHUB_ACTIONS_WORKFLOW, encoding="utf-8")
|
|
61
|
+
return workflow_file
|
sober/core/config.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration loader for SOBER - supports sober.toml and pyproject.toml [tool.sober].
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
import tomllib
|
|
13
|
+
except ModuleNotFoundError:
|
|
14
|
+
import tomli as tomllib # type: ignore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SoberConfig:
|
|
19
|
+
min_score: float = 95.0
|
|
20
|
+
fail_on_critical: bool = True
|
|
21
|
+
ignore_paths: list[str] = field(default_factory=list)
|
|
22
|
+
allowed_packages: list[str] = field(default_factory=list)
|
|
23
|
+
disabled_detectors: list[str] = field(default_factory=list)
|
|
24
|
+
custom_secret_patterns: list[dict[str, str]] = field(default_factory=list)
|
|
25
|
+
max_workers: int = 10
|
|
26
|
+
offline_mode: bool = False
|
|
27
|
+
|
|
28
|
+
def is_path_ignored(self, rel_path: str) -> bool:
|
|
29
|
+
"""Check if relative file path matches any ignore glob patterns."""
|
|
30
|
+
normalized = rel_path.replace("\\", "/")
|
|
31
|
+
for pattern in self.ignore_paths:
|
|
32
|
+
norm_pattern = pattern.replace("\\", "/")
|
|
33
|
+
if fnmatch.fnmatch(normalized, norm_pattern) or fnmatch.fnmatch(Path(normalized).name, norm_pattern):
|
|
34
|
+
return True
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
def is_package_whitelisted(self, package_name: str) -> bool:
|
|
38
|
+
"""Check if package name matches any allowed package names or wildcard scopes."""
|
|
39
|
+
clean_name = package_name.lower().strip()
|
|
40
|
+
for allowed in self.allowed_packages:
|
|
41
|
+
norm_allowed = allowed.lower().strip()
|
|
42
|
+
if norm_allowed == clean_name or fnmatch.fnmatch(clean_name, norm_allowed):
|
|
43
|
+
return True
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
def is_detector_enabled(self, detector_name: str) -> bool:
|
|
47
|
+
"""Check if detector is enabled."""
|
|
48
|
+
return detector_name.lower() not in [d.lower() for d in self.disabled_detectors]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_config(root_path: Path) -> SoberConfig:
|
|
52
|
+
"""Load configuration from sober.toml, .sober.toml, or pyproject.toml."""
|
|
53
|
+
candidates = [
|
|
54
|
+
root_path / "sober.toml",
|
|
55
|
+
root_path / ".sober.toml",
|
|
56
|
+
root_path / "pyproject.toml",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
for candidate in candidates:
|
|
60
|
+
if candidate.is_file():
|
|
61
|
+
try:
|
|
62
|
+
content = candidate.read_bytes()
|
|
63
|
+
data = tomllib.loads(content.decode("utf-8", errors="replace"))
|
|
64
|
+
|
|
65
|
+
section: dict[str, Any] = {}
|
|
66
|
+
if candidate.name == "pyproject.toml":
|
|
67
|
+
section = data.get("tool", {}).get("sober", {})
|
|
68
|
+
else:
|
|
69
|
+
section = data.get("sober", data)
|
|
70
|
+
|
|
71
|
+
if not section and candidate.name == "pyproject.toml":
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
return SoberConfig(
|
|
75
|
+
min_score=float(section.get("min_score", 95.0)),
|
|
76
|
+
fail_on_critical=bool(section.get("fail_on_critical", True)),
|
|
77
|
+
ignore_paths=list(section.get("ignore_paths", [])),
|
|
78
|
+
allowed_packages=list(section.get("allowed_packages", [])),
|
|
79
|
+
disabled_detectors=list(section.get("disabled_detectors", [])),
|
|
80
|
+
custom_secret_patterns=list(section.get("custom_secret_patterns", [])),
|
|
81
|
+
max_workers=int(section.get("max_workers", 10)),
|
|
82
|
+
offline_mode=bool(section.get("offline_mode", False)),
|
|
83
|
+
)
|
|
84
|
+
except Exception:
|
|
85
|
+
logging.debug(f"Failed to parse config candidate {candidate}", exc_info=True)
|
|
86
|
+
# If TOML has errors, skip to next candidate
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
return SoberConfig()
|