kryptorious-draftguard 1.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.
draftguard/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Kryptorious DraftGuard — Environment Configuration Drift Detection.
2
+
3
+ Detect environment configuration drift across dev/staging/prod before it
4
+ causes production incidents.
5
+ """
6
+
7
+ __version__ = "1.0.0"
8
+ __author__ = "Kryptorious"
draftguard/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m draftguard`."""
2
+
3
+ from draftguard.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
draftguard/cli.py ADDED
@@ -0,0 +1,482 @@
1
+ """Command-line interface for DraftGuard.
2
+
3
+ Usage:
4
+ draftguard scan [DIR] Scan directory tree for config files and compare envs
5
+ draftguard compare DIR1 DIR2 Compare two specific config sets
6
+ draftguard audit DIR [ENV] Audit a single environment for issues
7
+ draftguard diff DIR1 DIR2 Show detailed diff between environments
8
+
9
+ Options:
10
+ --envs TEXT Environments to check [default: dev,prod]
11
+ --format FORMAT Output format (table, json, markdown, summary)
12
+ --fail-on SEVERITY Exit with error if findings at or above severity
13
+ -o, --output FILE Write report to file
14
+ -r, --recursive Recurse into subdirectories [default: true]
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+ from pathlib import Path
21
+ from typing import List, Optional, Tuple
22
+
23
+ import click
24
+
25
+ from . import __version__
26
+ from .differ import Differ
27
+ from .parser import ConfigParser
28
+ from .reporters import get_reporter
29
+ from .rules import Severity
30
+ from .scanner import ConfigScanner
31
+
32
+
33
+ def _parse_envs(ctx, param, value: Optional[str]) -> List[str]:
34
+ """Parse --envs comma-separated list."""
35
+ if not value:
36
+ return ["dev", "prod"]
37
+ return [e.strip() for e in value.split(",")]
38
+
39
+
40
+ def _parse_severity(value: str) -> Severity:
41
+ """Parse a severity string."""
42
+ mapping = {
43
+ "critical": Severity.CRITICAL,
44
+ "crit": Severity.CRITICAL,
45
+ "high": Severity.HIGH,
46
+ "medium": Severity.MEDIUM,
47
+ "med": Severity.MEDIUM,
48
+ "warning": Severity.WARNING,
49
+ "warn": Severity.WARNING,
50
+ "info": Severity.INFO,
51
+ }
52
+ v = value.lower()
53
+ if v not in mapping:
54
+ raise click.BadParameter(f"Invalid severity: {value}. "
55
+ f"Choose from: critical, high, medium, warning, info")
56
+ return mapping[v]
57
+
58
+
59
+ def _print_banner() -> None:
60
+ """Print the DraftGuard banner."""
61
+ try:
62
+ from rich.console import Console
63
+ from rich.text import Text
64
+ console = Console()
65
+ banner = Text("""╔══════════════════════════════════════════════════╗
66
+ ║ 🔍 KRYPTORIOUS DRAFTGUARD v{} ║
67
+ ║ Detect config drift before it causes outages ║
68
+ ╚══════════════════════════════════════════════════╝""".format(__version__),
69
+ style="bold cyan")
70
+ console.print(banner)
71
+ except ImportError:
72
+ click.echo(f"Kryptorious DraftGuard v{__version__}")
73
+
74
+
75
+ @click.group(invoke_without_command=True)
76
+ @click.version_option(__version__, prog_name="draftguard")
77
+ @click.pass_context
78
+ def main(ctx: click.Context) -> None:
79
+ """Kryptorious DraftGuard — Environment Configuration Drift Detection.
80
+
81
+ Detect missing keys, value mismatches, type drift, default leakage,
82
+ and secret exposure across dev/staging/prod environments.
83
+ """
84
+ if ctx.invoked_subcommand is None:
85
+ _print_banner()
86
+ click.echo()
87
+ click.echo(ctx.get_help())
88
+ click.echo()
89
+
90
+
91
+ @main.command("scan")
92
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path),
93
+ default=Path.cwd())
94
+ @click.option("--envs", "-e", callback=_parse_envs, default="dev,prod",
95
+ help="Comma-separated environments to compare (default: dev,prod)")
96
+ @click.option("--format", "-f", "output_format", default="table",
97
+ type=click.Choice(["table", "json", "markdown", "md", "summary"]),
98
+ help="Output format")
99
+ @click.option("--fail-on", default=None, help="Exit code 1 if findings at or above this severity")
100
+ @click.option("--output", "-o", "output_file", type=click.Path(path_type=Path),
101
+ help="Write report to file")
102
+ @click.option("--recursive/--no-recursive", "-r", default=True,
103
+ help="Recurse into subdirectories")
104
+ def scan_command(
105
+ directory: Path,
106
+ envs: List[str],
107
+ output_format: str,
108
+ fail_on: Optional[str],
109
+ output_file: Optional[Path],
110
+ recursive: bool,
111
+ ) -> None:
112
+ """Scan a directory tree for config files and compare environments.
113
+
114
+ DIR is the root directory to scan (defaults to current directory).
115
+
116
+ Examples:
117
+ draftguard scan .
118
+ draftguard scan /app --envs dev,staging,prod
119
+ draftguard scan . --format json -o report.json
120
+ """
121
+ is_json = output_format == "json"
122
+ if not is_json:
123
+ _print_banner()
124
+ click.echo()
125
+
126
+ parser = ConfigParser()
127
+ scanner = ConfigScanner(parser=parser)
128
+ differ = Differ(parser=parser, scanner=scanner)
129
+
130
+ if not is_json:
131
+ click.echo(f"📂 Scanning: {directory}")
132
+ configs = scanner.scan(directory, recursive=recursive)
133
+
134
+ if not configs:
135
+ if not is_json:
136
+ click.echo("⚠️ No config files found.")
137
+ sys.exit(0)
138
+
139
+ # Group by environment
140
+ groups = scanner.group_by_environment(configs)
141
+
142
+ if not is_json:
143
+ click.echo(f" Found {len(configs)} config files across {len(groups)} environments:")
144
+ for env_name, cfgs in sorted(groups.items()):
145
+ click.echo(f" • {env_name}: {len(cfgs)} file(s) "
146
+ f"({', '.join(c.filepath.name for c in cfgs)})")
147
+ click.echo()
148
+
149
+ if len(envs) < 2:
150
+ click.echo("❌ Need at least 2 environments to compare. Use --envs or audit command.")
151
+ sys.exit(1)
152
+
153
+ # Use first two envs as source/target
154
+ source_env = envs[0]
155
+ target_env = envs[1]
156
+
157
+ source_configs = groups.get(source_env, [])
158
+ target_configs = groups.get(target_env, [])
159
+
160
+ if not source_configs:
161
+ click.echo(f"⚠️ No config files found for environment '{source_env}'")
162
+ click.echo(f" Available environments: {', '.join(sorted(groups.keys()))}")
163
+ sys.exit(1)
164
+
165
+ if not target_configs:
166
+ click.echo(f"⚠️ No config files found for environment '{target_env}'")
167
+ click.echo(f" Available environments: {', '.join(sorted(groups.keys()))}")
168
+ sys.exit(1)
169
+
170
+ # Run comparison
171
+ result = differ.compare_environments(source_env, target_env, source_configs, target_configs)
172
+
173
+ # Generate report
174
+ reporter = get_reporter(output_format)
175
+ report = reporter.report(result)
176
+
177
+ # Rich reporter prints itself; others return a string to print
178
+ if output_format in ("table",) and report == "":
179
+ pass # RichReporter printed directly
180
+ else:
181
+ click.echo(report)
182
+
183
+ if output_file:
184
+ output_file.write_text(report, encoding="utf-8")
185
+ click.echo(f"\n✅ Report written to: {output_file}")
186
+
187
+ # Determine exit code
188
+ exit_code = 0
189
+ if fail_on:
190
+ threshold = _parse_severity(fail_on)
191
+ has_failures = any(
192
+ Severity(sev).level >= threshold.level
193
+ for sev in result.summary
194
+ if result.summary.get(sev, 0) > 0
195
+ )
196
+ if has_failures:
197
+ exit_code = 1
198
+
199
+ if exit_code != 0:
200
+ sys.exit(exit_code)
201
+
202
+
203
+ @main.command("compare")
204
+ @click.argument("dir1", type=click.Path(exists=True, file_okay=False, path_type=Path))
205
+ @click.argument("dir2", type=click.Path(exists=True, file_okay=False, path_type=Path))
206
+ @click.option("--envs", "-e", callback=_parse_envs, default=None,
207
+ help="Environment labels for the two directories (default: inferred from paths)")
208
+ @click.option("--format", "-f", "output_format", default="table",
209
+ type=click.Choice(["table", "json", "markdown", "md", "summary"]),
210
+ help="Output format")
211
+ @click.option("--fail-on", default=None, help="Exit code 1 if findings at or above this severity")
212
+ @click.option("--output", "-o", "output_file", type=click.Path(path_type=Path),
213
+ help="Write report to file")
214
+ def compare_command(
215
+ dir1: Path,
216
+ dir2: Path,
217
+ envs: Optional[List[str]],
218
+ output_format: str,
219
+ fail_on: Optional[str],
220
+ output_file: Optional[Path],
221
+ ) -> None:
222
+ """Compare config files from two directories.
223
+
224
+ DIR1 and DIR2 are paths to environment config directories.
225
+
226
+ Examples:
227
+ draftguard compare ./envs/dev ./envs/prod
228
+ draftguard compare ./dev ./staging --envs development,staging
229
+ """
230
+ is_json = output_format == "json"
231
+ if not is_json:
232
+ _print_banner()
233
+ click.echo()
234
+
235
+ if envs and len(envs) >= 2:
236
+ source_env, target_env = envs[0], envs[1]
237
+ else:
238
+ source_env = dir1.name
239
+ target_env = dir2.name
240
+
241
+ parser = ConfigParser()
242
+ scanner = ConfigScanner(parser=parser)
243
+ differ = Differ(parser=parser, scanner=scanner)
244
+
245
+ if not is_json:
246
+ click.echo(f"📂 Scanning source: {dir1}")
247
+ source_configs = scanner.scan(dir1, recursive=True)
248
+ if not is_json:
249
+ click.echo(f" Found {len(source_configs)} config file(s)")
250
+
251
+ if not is_json:
252
+ click.echo(f"📂 Scanning target: {dir2}")
253
+ target_configs = scanner.scan(dir2, recursive=True)
254
+ if not is_json:
255
+ click.echo(f" Found {len(target_configs)} config file(s)")
256
+ click.echo()
257
+
258
+ if not source_configs or not target_configs:
259
+ click.echo("❌ Both directories must contain config files.")
260
+ sys.exit(1)
261
+
262
+ result = differ.compare_environments(source_env, target_env, source_configs, target_configs)
263
+
264
+ reporter = get_reporter(output_format)
265
+ report = reporter.report(result)
266
+
267
+ if output_format in ("table",) and report == "":
268
+ pass
269
+ else:
270
+ click.echo(report)
271
+
272
+ if output_file:
273
+ output_file.write_text(report, encoding="utf-8")
274
+ click.echo(f"\n✅ Report written to: {output_file}")
275
+
276
+ exit_code = 0
277
+ if fail_on:
278
+ threshold = _parse_severity(fail_on)
279
+ has_failures = any(
280
+ Severity(sev).level >= threshold.level
281
+ for sev in result.summary
282
+ if result.summary.get(sev, 0) > 0
283
+ )
284
+ if has_failures:
285
+ exit_code = 1
286
+
287
+ if exit_code != 0:
288
+ sys.exit(exit_code)
289
+
290
+
291
+ @main.command("audit")
292
+ @click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path),
293
+ default=Path.cwd())
294
+ @click.option("--env", "-e", default=None, help="Environment to audit "
295
+ "(default: auto-detect from directory)")
296
+ @click.option("--format", "-f", "output_format", default="table",
297
+ type=click.Choice(["table", "json", "markdown", "md", "summary"]),
298
+ help="Output format")
299
+ @click.option("--fail-on", default=None, help="Exit code 1 if findings at or above this severity")
300
+ @click.option("--output", "-o", "output_file", type=click.Path(path_type=Path),
301
+ help="Write report to file")
302
+ def audit_command(
303
+ directory: Path,
304
+ env: Optional[str],
305
+ output_format: str,
306
+ fail_on: Optional[str],
307
+ output_file: Optional[Path],
308
+ ) -> None:
309
+ """Audit a single environment for configuration issues.
310
+
311
+ Checks for empty values, default placeholders, and weak secrets.
312
+
313
+ Examples:
314
+ draftguard audit .
315
+ draftguard audit ./prod --env production
316
+ """
317
+ is_json = output_format == "json"
318
+ if not is_json:
319
+ _print_banner()
320
+ click.echo()
321
+
322
+ parser = ConfigParser()
323
+ scanner = ConfigScanner(parser=parser)
324
+ differ = Differ(parser=parser, scanner=scanner)
325
+
326
+ if not is_json:
327
+ click.echo(f"📂 Auditing: {directory}")
328
+ configs = scanner.scan(directory, recursive=True)
329
+
330
+ if not configs:
331
+ if not is_json:
332
+ click.echo("⚠️ No config files found.")
333
+ sys.exit(0)
334
+
335
+ env_name = env or configs[0].env_name
336
+ if not is_json:
337
+ click.echo(f" Environment: {env_name}")
338
+ click.echo(f" Config files: {len(configs)}")
339
+ click.echo()
340
+
341
+ findings = differ.audit_environment(env_name, configs)
342
+
343
+ reporter = get_reporter(output_format)
344
+ report = reporter.report_audit(findings, env_name)
345
+
346
+ if output_format in ("table",) and report == "":
347
+ pass
348
+ else:
349
+ click.echo(report)
350
+
351
+ if output_file:
352
+ output_file.write_text(report, encoding="utf-8")
353
+ click.echo(f"\n✅ Report written to: {output_file}")
354
+
355
+ exit_code = 0
356
+ if fail_on:
357
+ threshold = _parse_severity(fail_on)
358
+ has_failures = any(
359
+ f.severity.level >= threshold.level for f in findings
360
+ )
361
+ if has_failures:
362
+ exit_code = 1
363
+
364
+ if exit_code != 0:
365
+ sys.exit(exit_code)
366
+
367
+
368
+ @main.command("diff")
369
+ @click.argument("dir1", type=click.Path(exists=True, file_okay=False, path_type=Path))
370
+ @click.argument("dir2", type=click.Path(exists=True, file_okay=False, path_type=Path))
371
+ @click.option("--format", "-f", "output_format", default="table",
372
+ type=click.Choice(["table", "json", "markdown", "md"]),
373
+ help="Output format")
374
+ @click.option("--output", "-o", "output_file", type=click.Path(path_type=Path),
375
+ help="Write report to file")
376
+ def diff_command(
377
+ dir1: Path,
378
+ dir2: Path,
379
+ output_format: str,
380
+ output_file: Optional[Path],
381
+ ) -> None:
382
+ """Show detailed key-by-key diff between two config directories.
383
+
384
+ Examples:
385
+ draftguard diff ./dev ./prod
386
+ draftguard diff ./staging ./prod --format markdown
387
+ """
388
+ is_json = output_format == "json"
389
+ if not is_json:
390
+ _print_banner()
391
+ click.echo()
392
+
393
+ parser = ConfigParser()
394
+ scanner = ConfigScanner(parser=parser)
395
+ differ = Differ(parser=parser, scanner=scanner)
396
+
397
+ if not is_json:
398
+ click.echo(f"📂 Scanning: {dir1}")
399
+ configs1 = scanner.scan(dir1, recursive=True)
400
+
401
+ if not is_json:
402
+ click.echo(f"📂 Scanning: {dir2}")
403
+ configs2 = scanner.scan(dir2, recursive=True)
404
+
405
+ if not configs1 or not configs2:
406
+ click.echo("❌ Both directories must contain config files.")
407
+ sys.exit(1)
408
+
409
+ # Merge configs
410
+ merged1 = scanner.merge_env_configs(configs1)
411
+ merged2 = scanner.merge_env_configs(configs2)
412
+
413
+ diffs = differ.diff_values(
414
+ merged1.values, merged2.values,
415
+ env_source=dir1.name, env_target=dir2.name,
416
+ )
417
+
418
+ if not is_json:
419
+ click.echo(f"🔍 Diff: {dir1.name} ↔ {dir2.name}")
420
+ click.echo(f" Keys in {dir1.name}: {len(merged1.values)}")
421
+ click.echo(f" Keys in {dir2.name}: {len(merged2.values)}")
422
+ click.echo(f" Differences: {len(diffs)}")
423
+ click.echo()
424
+
425
+ # Display diff
426
+ if output_format == "json":
427
+ import json
428
+
429
+ diff_data = {
430
+ "source": str(dir1),
431
+ "target": str(dir2),
432
+ "differences": [
433
+ {"status": status, "key": key, "source_value": sv, "target_value": tv}
434
+ for status, key, sv, tv in diffs
435
+ ],
436
+ }
437
+ output = json.dumps(diff_data, indent=2, default=str)
438
+ click.echo(output)
439
+ elif output_format in ("markdown", "md"):
440
+ lines = [
441
+ f"# Diff: `{dir1.name}` ↔ `{dir2.name}`",
442
+ "",
443
+ "| Status | Key | Source | Target |",
444
+ "|--------|-----|--------|--------|",
445
+ ]
446
+ for status, key, sv, tv in diffs:
447
+ sv_str = f"`{sv}`" if sv else "—"
448
+ tv_str = f"`{tv}`" if tv else "—"
449
+ icon = {"added": "➕", "removed": "➖", "changed": "🔄"}.get(status, "•")
450
+ lines.append(f"| {icon} {status} | `{key}` | {sv_str} | {tv_str} |")
451
+ output = "\n".join(lines) + "\n"
452
+ click.echo(output)
453
+ else:
454
+ # Table format
455
+ try:
456
+ from rich.console import Console
457
+ from rich.table import Table
458
+ console = Console()
459
+ table = Table(title=f"Diff: {dir1.name} ↔ {dir2.name}",
460
+ show_header=True, header_style="bold")
461
+ table.add_column("Status", style="bold", width=10)
462
+ table.add_column("Key", style="cyan", width=35)
463
+ table.add_column(str(dir1.name), width=30)
464
+ table.add_column(str(dir2.name), width=30)
465
+
466
+ for status, key, sv, tv in diffs:
467
+ color = {"added": "green", "removed": "red", "changed": "yellow"}.get(status, "")
468
+ sv_str = str(sv)[:28] if sv else "—"
469
+ tv_str = str(tv)[:28] if tv else "—"
470
+ table.add_row(
471
+ f"[{color}]{status}[/{color}]",
472
+ key,
473
+ sv_str,
474
+ tv_str,
475
+ )
476
+ console.print(table)
477
+ except ImportError:
478
+ for status, key, sv, tv in diffs:
479
+ click.echo(f" [{status}] {key}: {sv} → {tv}")
480
+
481
+ if output_file:
482
+ output_file.write_text(output if 'output' in dir() else "", encoding="utf-8")