codeguard-pro 0.3.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.
cli.py ADDED
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env python3
2
+ """CodeGuard Pro CLI — Install, scan, and protect your code.
3
+
4
+ Usage:
5
+ codeguard init [--policy] Initialize CodeGuard in current repo
6
+ codeguard install Install pre-commit hook in current repo
7
+ codeguard scan <path> Scan a file or directory for secrets
8
+ codeguard scan-diff Scan staged git diff for secrets
9
+ codeguard learn-add Save a suspicious sample for later review
10
+ codeguard learn-report Generate an issue-ready markdown report
11
+ codeguard learn-summary Summarize the local learning corpus
12
+ codeguard uninstall Remove pre-commit hook
13
+ """
14
+
15
+ import os
16
+ import sys
17
+ import stat
18
+ import json
19
+ import shutil
20
+ import subprocess
21
+ import argparse
22
+
23
+ CODEGUARD_DIR = os.path.dirname(os.path.abspath(__file__))
24
+ sys.path.insert(0, CODEGUARD_DIR)
25
+
26
+ HOOK_SCRIPT = f'''#!/usr/bin/env python3
27
+ """CodeGuard Pro pre-commit hook — auto-installed."""
28
+ import sys
29
+ sys.path.insert(0, "{CODEGUARD_DIR}")
30
+ from hook import main
31
+ main()
32
+ '''
33
+
34
+ RED = "\033[91m"
35
+ GREEN = "\033[92m"
36
+ YELLOW = "\033[93m"
37
+ BOLD = "\033[1m"
38
+ RESET = "\033[0m"
39
+
40
+
41
+ def find_git_root() -> str:
42
+ """Find the .git directory from current working directory."""
43
+ cwd = os.getcwd()
44
+ while cwd != "/":
45
+ if os.path.isdir(os.path.join(cwd, ".git")):
46
+ return cwd
47
+ cwd = os.path.dirname(cwd)
48
+ return ""
49
+
50
+
51
+ def cmd_install():
52
+ """Install the pre-commit hook."""
53
+ git_root = find_git_root()
54
+ if not git_root:
55
+ print(f"{RED}Error: Not a git repository.{RESET}")
56
+ sys.exit(1)
57
+
58
+ hooks_dir = os.path.join(git_root, ".git", "hooks")
59
+ os.makedirs(hooks_dir, exist_ok=True)
60
+
61
+ hook_path = os.path.join(hooks_dir, "pre-commit")
62
+
63
+ # Back up existing hook
64
+ if os.path.exists(hook_path):
65
+ backup = hook_path + ".backup"
66
+ shutil.copy2(hook_path, backup)
67
+ print(f"{YELLOW}Existing hook backed up to {backup}{RESET}")
68
+
69
+ with open(hook_path, "w") as f:
70
+ f.write(HOOK_SCRIPT)
71
+
72
+ os.chmod(hook_path, os.stat(hook_path).st_mode | stat.S_IEXEC)
73
+
74
+ print(f"{GREEN}{BOLD}CodeGuard Pro installed.{RESET}")
75
+ print(f"Hook: {hook_path}")
76
+ print(f"Every commit will be scanned for secrets automatically.")
77
+
78
+
79
+ def cmd_uninstall():
80
+ """Remove the pre-commit hook."""
81
+ git_root = find_git_root()
82
+ if not git_root:
83
+ print(f"{RED}Error: Not a git repository.{RESET}")
84
+ sys.exit(1)
85
+
86
+ hook_path = os.path.join(git_root, ".git", "hooks", "pre-commit")
87
+ backup = hook_path + ".backup"
88
+
89
+ if os.path.exists(hook_path):
90
+ os.remove(hook_path)
91
+ print(f"{GREEN}CodeGuard Pro hook removed.{RESET}")
92
+
93
+ if os.path.exists(backup):
94
+ shutil.move(backup, hook_path)
95
+ print(f"Restored previous hook from backup.")
96
+ else:
97
+ print("No hook found.")
98
+
99
+
100
+ def cmd_scan(path: str):
101
+ """Scan a file or directory for secrets."""
102
+ from secret_scanner import scan_secrets, format_findings
103
+
104
+ if os.path.isfile(path):
105
+ with open(path, "r", errors="ignore") as f:
106
+ code = f.read()
107
+ findings = scan_secrets(code, path)
108
+ print(f"{BOLD}Scan: {path}{RESET}\n")
109
+ print(format_findings(findings, block_mode=False))
110
+ if findings:
111
+ sys.exit(1)
112
+ elif os.path.isdir(path):
113
+ total_findings = []
114
+ files_scanned = 0
115
+ skip_dirs = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'dist', 'build', '.next'}
116
+ skip_ext = {'.pyc', '.whl', '.so', '.dll', '.png', '.jpg', '.gif', '.ico', '.svg',
117
+ '.woff', '.ttf', '.mp3', '.mp4', '.zip', '.tar', '.gz', '.lock'}
118
+
119
+ for root, dirs, files in os.walk(path):
120
+ dirs[:] = [d for d in dirs if d not in skip_dirs]
121
+ for fname in files:
122
+ ext = os.path.splitext(fname)[1].lower()
123
+ if ext in skip_ext:
124
+ continue
125
+ fpath = os.path.join(root, fname)
126
+ try:
127
+ with open(fpath, "r", errors="ignore") as f:
128
+ code = f.read()
129
+ findings = scan_secrets(code, fpath)
130
+ files_scanned += 1
131
+ for finding in findings:
132
+ total_findings.append((fpath, finding))
133
+ except Exception:
134
+ continue
135
+
136
+ print(f"{BOLD}Directory Scan: {path}{RESET}")
137
+ print(f"Files scanned: {files_scanned}\n")
138
+
139
+ if not total_findings:
140
+ print(f"{GREEN}PASS — No secrets detected.{RESET}")
141
+ else:
142
+ critical = sum(1 for _, f in total_findings if f.severity == "CRITICAL")
143
+ high = sum(1 for _, f in total_findings if f.severity == "HIGH")
144
+ print(f"{RED}FOUND {len(total_findings)} secret(s){RESET}")
145
+ print(f" CRITICAL: {critical}")
146
+ print(f" HIGH: {high}\n")
147
+
148
+ for fpath, f in total_findings:
149
+ rel = os.path.relpath(fpath, path)
150
+ print(f" [{f.severity}] {f.secret_type} in {rel}:{f.line}")
151
+ print(f" {f.matched}")
152
+ print(f" Fix: {f.fix}\n")
153
+
154
+ sys.exit(1)
155
+ else:
156
+ print(f"{RED}Error: {path} not found.{RESET}")
157
+ sys.exit(1)
158
+
159
+
160
+ POLICIES = {
161
+ "minimal": {
162
+ "block_on_critical": True,
163
+ "block_on_high": False,
164
+ "scan_secrets": True,
165
+ "scan_owasp": False,
166
+ "scan_packages": False,
167
+ "autofix": False,
168
+ "autofix_model": None,
169
+ },
170
+ "standard": {
171
+ "block_on_critical": True,
172
+ "block_on_high": False,
173
+ "scan_secrets": True,
174
+ "scan_owasp": True,
175
+ "scan_packages": False,
176
+ "autofix": False,
177
+ "autofix_model": None,
178
+ },
179
+ "full": {
180
+ "block_on_critical": True,
181
+ "block_on_high": False,
182
+ "scan_secrets": True,
183
+ "scan_owasp": True,
184
+ "scan_packages": True,
185
+ "autofix": True,
186
+ "autofix_model": "MiniMax-M2.7",
187
+ },
188
+ }
189
+
190
+ SECRET_FILE_PATTERNS = [
191
+ ".env",
192
+ ".env.local",
193
+ ".env.production",
194
+ ".env.staging",
195
+ "*.pem",
196
+ "*.key",
197
+ "*.p12",
198
+ "*.pfx",
199
+ "*.jks",
200
+ ]
201
+
202
+
203
+ def _ensure_gitignore_patterns(git_root: str) -> list:
204
+ """Add common secret file patterns to .gitignore if missing. Returns list of added patterns."""
205
+ gitignore_path = os.path.join(git_root, ".gitignore")
206
+ existing_lines = set()
207
+
208
+ if os.path.exists(gitignore_path):
209
+ with open(gitignore_path, "r") as f:
210
+ existing_lines = {line.strip() for line in f.readlines()}
211
+
212
+ added = []
213
+ lines_to_add = []
214
+ for pattern in SECRET_FILE_PATTERNS:
215
+ if pattern not in existing_lines:
216
+ lines_to_add.append(pattern)
217
+ added.append(pattern)
218
+
219
+ if lines_to_add:
220
+ with open(gitignore_path, "a") as f:
221
+ if existing_lines and "" not in existing_lines:
222
+ f.write("\n")
223
+ f.write("# CodeGuard Pro — secret file patterns\n")
224
+ for line in lines_to_add:
225
+ f.write(f"{line}\n")
226
+
227
+ return added
228
+
229
+
230
+ def cmd_init(policy: str = "standard"):
231
+ """Initialize CodeGuard Pro in current repo: hook + config + gitignore."""
232
+ git_root = find_git_root()
233
+ if not git_root:
234
+ print(f"{RED}Error: Not a git repository. Run 'git init' first.{RESET}")
235
+ sys.exit(1)
236
+
237
+ print(f"{BOLD}CodeGuard Pro — Initializing ({policy} policy){RESET}\n")
238
+
239
+ steps_done = []
240
+
241
+ # 1. Install pre-commit hook (reuse existing logic)
242
+ cmd_install()
243
+ steps_done.append("Pre-commit hook installed")
244
+
245
+ # 2. Create .codeguard.json config
246
+ config = dict(POLICIES[policy])
247
+ config_path = os.path.join(git_root, ".codeguard.json")
248
+
249
+ if os.path.exists(config_path):
250
+ print(f"{YELLOW} .codeguard.json already exists — overwriting with {policy} policy{RESET}")
251
+
252
+ with open(config_path, "w") as f:
253
+ json.dump(config, f, indent=2)
254
+ f.write("\n")
255
+ steps_done.append(f".codeguard.json created ({policy} policy)")
256
+
257
+ # 3. Git-track .codeguard.json
258
+ try:
259
+ subprocess.run(
260
+ ["git", "add", ".codeguard.json"],
261
+ capture_output=True, text=True, timeout=10, cwd=git_root,
262
+ )
263
+ steps_done.append(".codeguard.json added to git tracking")
264
+ except Exception:
265
+ print(f"{YELLOW} Warning: Could not git add .codeguard.json{RESET}")
266
+
267
+ # 4. Add secret file patterns to .gitignore
268
+ added_patterns = _ensure_gitignore_patterns(git_root)
269
+ if added_patterns:
270
+ steps_done.append(f".gitignore updated (+{len(added_patterns)} patterns)")
271
+ # Also stage .gitignore
272
+ try:
273
+ subprocess.run(
274
+ ["git", "add", ".gitignore"],
275
+ capture_output=True, text=True, timeout=10, cwd=git_root,
276
+ )
277
+ except Exception:
278
+ pass
279
+ else:
280
+ steps_done.append(".gitignore already has secret patterns")
281
+
282
+ # 5. Print summary
283
+ print(f"\n{GREEN}{BOLD}Setup complete!{RESET}\n")
284
+ for step in steps_done:
285
+ print(f" {GREEN}+{RESET} {step}")
286
+
287
+ print(f"\n{BOLD}Policy: {policy}{RESET}")
288
+ print(f" Secrets scanning: {'ON' if config.get('scan_secrets') else 'OFF'}")
289
+ print(f" OWASP scanning: {'ON' if config.get('scan_owasp') else 'OFF'}")
290
+ print(f" Package scanning: {'ON' if config.get('scan_packages') else 'OFF'}")
291
+ print(f" Auto-fix: {'ON' if config.get('autofix') else 'OFF'}")
292
+ if config.get('autofix_model'):
293
+ print(f" Auto-fix model: {config['autofix_model']}")
294
+ print(f" Block on CRITICAL: {'YES' if config.get('block_on_critical') else 'NO'}")
295
+ print(f" Block on HIGH: {'YES' if config.get('block_on_high') else 'NO'}")
296
+ print(f"\nRun {BOLD}codeguard scan .{RESET} to scan your project now.")
297
+
298
+
299
+ def cmd_check(packages: list, registry: str = "pypi"):
300
+ """Scan packages BEFORE installing — catches typosquats + suspicious packages."""
301
+ from supply_chain import scan_pip_package, scan_npm_package
302
+
303
+ scanner = scan_pip_package if registry == "pypi" else scan_npm_package
304
+ blocked = []
305
+ warnings = []
306
+
307
+ print(f"{BOLD}CodeGuard Pre-Install Check ({registry}){RESET}\n")
308
+
309
+ for pkg in packages:
310
+ pkg = pkg.strip().split("==")[0].split(">=")[0].split("<=")[0].split("~=")[0]
311
+ if not pkg:
312
+ continue
313
+ result = scanner(pkg)
314
+ verdict = result.get("verdict", "ERROR")
315
+ reasons = result.get("reasons", [])
316
+
317
+ if verdict == "BLOCKED":
318
+ print(f" {RED}BLOCKED{RESET} {pkg}")
319
+ for r in reasons:
320
+ print(f" {r}")
321
+ blocked.append(pkg)
322
+ elif verdict == "WARNING":
323
+ print(f" {YELLOW}WARNING{RESET} {pkg}")
324
+ for r in reasons:
325
+ print(f" {r}")
326
+ warnings.append(pkg)
327
+ elif verdict == "SAFE":
328
+ print(f" {GREEN}SAFE{RESET} {pkg}")
329
+ else:
330
+ print(f" {YELLOW}?{RESET} {pkg} ({verdict})")
331
+
332
+ print()
333
+ if blocked:
334
+ print(f"{RED}{BOLD}BLOCKED: {len(blocked)} package(s) look dangerous.{RESET}")
335
+ print(f" {', '.join(blocked)}")
336
+ print(f"\n DO NOT install these. They may be typosquats or malicious.")
337
+ sys.exit(1)
338
+ elif warnings:
339
+ print(f"{YELLOW}WARNING: {len(warnings)} package(s) flagged.{RESET}")
340
+ print(f" Proceed with caution.")
341
+ else:
342
+ print(f"{GREEN}All {len(packages)} package(s) look safe to install.{RESET}")
343
+
344
+
345
+ def cmd_scan_diff():
346
+ """Scan staged diff for secrets."""
347
+ from hook import get_staged_diff
348
+ from secret_scanner import scan_diff, format_findings
349
+
350
+ diff = get_staged_diff()
351
+ if not diff:
352
+ print("No staged changes found.")
353
+ return
354
+
355
+ findings = scan_diff(diff)
356
+ print(f"{BOLD}Staged Diff Scan{RESET}\n")
357
+ print(format_findings(findings, block_mode=False))
358
+
359
+
360
+ def cmd_learn_add(sample_path: str, title: str, language: str = "python", source: str = "manual", expected_behavior: str = ""):
361
+ """Store a suspicious sample in the review corpus."""
362
+ from agent_analyzer import deep_analyze
363
+ from learning_loop import record_candidate
364
+
365
+ if not os.path.isfile(sample_path):
366
+ print(f"{RED}Error: {sample_path} not found.{RESET}")
367
+ sys.exit(1)
368
+
369
+ with open(sample_path, "r", errors="ignore") as f:
370
+ code = f.read()
371
+
372
+ ai_result = deep_analyze(code, language)
373
+ result = record_candidate(
374
+ title=title,
375
+ code=code,
376
+ language=language,
377
+ source=source,
378
+ expected_behavior=expected_behavior,
379
+ regex_findings=[f for f in ai_result.get("findings", []) if f.get("source") == "[REGEX]"],
380
+ ai_findings=[f for f in ai_result.get("findings", []) if f.get("source") == "[AI-BETA]"],
381
+ )
382
+ print(json.dumps(result, indent=2))
383
+
384
+
385
+ def cmd_learn_report(candidate_path: str):
386
+ """Generate issue-ready markdown for a stored learning sample."""
387
+ from learning_loop import generate_issue_markdown
388
+
389
+ if not os.path.isfile(candidate_path):
390
+ print(f"{RED}Error: {candidate_path} not found.{RESET}")
391
+ sys.exit(1)
392
+ print(json.dumps(generate_issue_markdown(candidate_path), indent=2))
393
+
394
+
395
+ def cmd_learn_summary(corpus_dir: str = "learning"):
396
+ """Summarize the local learning corpus."""
397
+ from learning_loop import corpus_summary
398
+ print(json.dumps(corpus_summary(corpus_dir), indent=2))
399
+
400
+
401
+ def main():
402
+ parser = argparse.ArgumentParser(
403
+ prog="codeguard",
404
+ description="CodeGuard Pro — Stop secrets from reaching git."
405
+ )
406
+ sub = parser.add_subparsers(dest="command")
407
+
408
+ init_p = sub.add_parser("init", help="Initialize CodeGuard in repo")
409
+ init_p.add_argument(
410
+ "--policy", choices=["minimal", "standard", "full"],
411
+ default="standard",
412
+ help="Security policy level (default: standard)"
413
+ )
414
+
415
+ sub.add_parser("install", help="Install pre-commit hook")
416
+ sub.add_parser("uninstall", help="Remove pre-commit hook")
417
+
418
+ scan_p = sub.add_parser("scan", help="Scan file or directory")
419
+ scan_p.add_argument("path", help="File or directory to scan")
420
+
421
+ sub.add_parser("scan-diff", help="Scan staged git diff")
422
+
423
+ check_p = sub.add_parser("check", help="Scan packages BEFORE installing")
424
+ check_p.add_argument("packages", nargs="+", help="Package names to check")
425
+ check_p.add_argument("--npm", action="store_true", help="Check npm instead of pip")
426
+
427
+ learn_add_p = sub.add_parser("learn-add", help="Add suspicious sample to learning corpus")
428
+ learn_add_p.add_argument("sample_path", help="Path to the suspicious sample file")
429
+ learn_add_p.add_argument("--title", required=True, help="Short title for the sample")
430
+ learn_add_p.add_argument("--language", default="python", help="Source language")
431
+ learn_add_p.add_argument("--source", default="manual", help="Where the sample came from")
432
+ learn_add_p.add_argument("--expected", default="", help="Expected detector behavior")
433
+
434
+ learn_report_p = sub.add_parser("learn-report", help="Generate issue-ready markdown from a candidate")
435
+ learn_report_p.add_argument("candidate_path", help="Path to learning candidate JSON")
436
+
437
+ learn_summary_p = sub.add_parser("learn-summary", help="Summarize the learning corpus")
438
+ learn_summary_p.add_argument("--corpus-dir", default="learning", help="Learning corpus directory")
439
+
440
+ args = parser.parse_args()
441
+
442
+ if args.command == "init":
443
+ cmd_init(args.policy)
444
+ elif args.command == "install":
445
+ cmd_install()
446
+ elif args.command == "uninstall":
447
+ cmd_uninstall()
448
+ elif args.command == "scan":
449
+ cmd_scan(args.path)
450
+ elif args.command == "scan-diff":
451
+ cmd_scan_diff()
452
+ elif args.command == "check":
453
+ cmd_check(args.packages, registry="npm" if args.npm else "pypi")
454
+ elif args.command == "learn-add":
455
+ cmd_learn_add(args.sample_path, args.title, args.language, args.source, args.expected)
456
+ elif args.command == "learn-report":
457
+ cmd_learn_report(args.candidate_path)
458
+ elif args.command == "learn-summary":
459
+ cmd_learn_summary(args.corpus_dir)
460
+ else:
461
+ parser.print_help()
462
+
463
+
464
+ if __name__ == "__main__":
465
+ main()