File size: 14,517 Bytes
de31cf7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
"""
Command-line interface for Shield Agents.

Provides a beautiful, interactive CLI using the Rich library for
progress bars, tables, and colored output.
"""

import argparse
import asyncio
import json
import logging
import sys
from pathlib import Path
from typing import Optional

from . import __version__
from .config import ShieldConfig
from .orchestrator import Orchestrator
from .shieldignore import ShieldIgnore
from .cache import ScanCache

logger = logging.getLogger("shield_agents.cli")


def setup_logging(verbose: bool = False, debug: bool = False):
    """Configure logging based on verbosity."""
    level = logging.DEBUG if debug else (logging.INFO if verbose else logging.WARNING)
    logging.basicConfig(
        level=level,
        format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
        datefmt="%H:%M:%S",
    )


def create_parser() -> argparse.ArgumentParser:
    """Create the CLI argument parser."""
    parser = argparse.ArgumentParser(
        prog="shield-agents",
        description="Shield Agents - AI-Powered Multi-Agent Cybersecurity Scanner",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  shield-agents scan ./my-project              # Scan a project
  shield-agents scan ./my-project --full        # Full scan (ignore cache)
  shield-agents scan ./my-project --fix         # Scan with auto-fix suggestions
  shield-agents scan ./my-project --sarif-only  # Output only SARIF for GitHub
  shield-agents init                            # Create .shieldignore template
  shield-agents cache --clear                   # Clear scan cache
  shield-agents cache --stats                   # Show cache statistics
  shield-agents version                         # Show version info
        """,
    )

    subparsers = parser.add_subparsers(dest="command", help="Available commands")

    # Scan command
    scan_parser = subparsers.add_parser("scan", help="Run security scan")
    scan_parser.add_argument("target", help="Path to scan")
    scan_parser.add_argument("--config", "-c", help="Path to config YAML file")
    scan_parser.add_argument("--full", action="store_true", help="Full scan (ignore cache)")
    scan_parser.add_argument("--fix", action="store_true", help="Generate auto-fix suggestions")
    scan_parser.add_argument("--no-report", action="store_true", help="Skip report generation")
    scan_parser.add_argument("--sarif-only", action="store_true", help="Output only SARIF format")
    scan_parser.add_argument("--output", "-o", help="Output directory for reports")
    scan_parser.add_argument("--provider", choices=["mock", "openai", "anthropic", "ollama"], help="LLM provider")
    scan_parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
    scan_parser.add_argument("--debug", action="store_true", help="Debug output")
    scan_parser.add_argument("--no-cache", action="store_true", help="Disable caching")
    scan_parser.add_argument("--no-dedup", action="store_true", help="Disable deduplication")
    scan_parser.add_argument("--no-ignore", action="store_true", help="Ignore .shieldignore rules")
    scan_parser.add_argument("--ci", action="store_true", help="CI/CD mode: SARIF output, JSON to stdout, silent except errors")
    scan_parser.add_argument("--format", "-f", choices=["rich", "json", "sarif", "plain"], default="rich", help="Output format (default: rich)")
    scan_parser.add_argument("--fail-threshold", type=int, default=75, help="Risk score threshold for CI failure (default: 75)")

    # Init command
    init_parser = subparsers.add_parser("init", help="Initialize Shield Agents configuration")
    init_parser.add_argument("--path", default=".", help="Directory to initialize")

    # Cache command
    cache_parser = subparsers.add_parser("cache", help="Manage scan cache")
    cache_parser.add_argument("--clear", action="store_true", help="Clear the cache")
    cache_parser.add_argument("--stats", action="store_true", help="Show cache statistics")

    # Version command
    subparsers.add_parser("version", help="Show version information")

    return parser


async def run_scan_ci(args) -> int:
    """CI/CD optimized scan - silent except for JSON output on stdout."""
    config = ShieldConfig(config_path=args.config) if args.config else ShieldConfig()
    config.target_path = args.target

    # Apply CLI overrides
    if args.provider:
        config.llm.provider = args.provider
    if args.output:
        config.report.output_dir = args.output
    if args.no_cache:
        config.cache.enabled = False
    if args.no_dedup:
        config.deduplication.enabled = False

    # CI mode: always generate SARIF + JSON (or SARIF only if --sarif-only)
    config.report.formats = ["sarif", "json"] if not getattr(args, 'sarif_only', False) else ["sarif"]

    # Minimal logging for CI
    setup_logging(verbose=False, debug=False)

    orchestrator = Orchestrator(config)
    result = await orchestrator.scan(
        target_path=args.target,
        full_scan=args.full,
        generate_report=True,
        generate_fixes=args.fix,
    )

    # Output JSON to stdout for CI pipeline consumption
    output = result.to_dict()
    print(json.dumps(output, indent=2))

    # Exit code based on risk threshold
    threshold = getattr(args, 'fail_threshold', 75) or 75
    return 1 if result.risk_score >= threshold else 0


async def run_scan(args) -> int:
    """Execute the scan command."""
    # If format is json, use CI-style output
    if getattr(args, 'format', 'rich') == 'json':
        return await run_scan_ci(args)

    try:
        from rich.console import Console
        from rich.progress import Progress, SpinnerColumn, TextColumn
        from rich.table import Table
        from rich.panel import Panel
        from rich import box
    except ImportError:
        # Fallback without Rich
        return await _run_scan_simple(args)

    console = Console()

    # Load configuration
    config = ShieldConfig(config_path=args.config) if args.config else ShieldConfig()
    config.target_path = args.target

    # Apply CLI overrides
    if args.provider:
        config.llm.provider = args.provider
    if args.output:
        config.report.output_dir = args.output
    if args.no_cache:
        config.cache.enabled = False
    if args.no_dedup:
        config.deduplication.enabled = False
    if args.no_ignore:
        config.shieldignore.enabled = False
    if args.sarif_only:
        config.report.formats = ["sarif"]
    if args.ci:
        config.report.formats = ["sarif", "json"]

    config.verbose = args.verbose
    config.debug = args.debug
    setup_logging(args.verbose, args.debug)

    # Display header
    console.print(Panel.fit(
        "[bold blue]Shield Agents[/bold blue] - AI-Powered Security Scanner\n"
        f"[dim]Version {__version__} | Provider: {config.llm.provider}[/dim]",
        border_style="blue",
    ))

    # Run scan
    orchestrator = Orchestrator(config)

    with Progress(
        SpinnerColumn(),
        TextColumn("[progress.description]{task.description}"),
        console=console,
    ) as progress:
        task = progress.add_task("Scanning...", total=None)
        result = await orchestrator.scan(
            target_path=args.target,
            full_scan=args.full,
            generate_report=not args.no_report,
            generate_fixes=args.fix,
        )
        progress.update(task, completed=True)

    # Display results
    severity_colors = {
        "CRITICAL": "bold red",
        "HIGH": "red",
        "MEDIUM": "yellow",
        "LOW": "cyan",
        "INFO": "dim",
    }

    # Risk score panel
    risk_color = "red" if result.risk_score >= 75 else "yellow" if result.risk_score >= 50 else "green"
    console.print(Panel.fit(
        f"[{risk_color}]Risk Score: {result.risk_score}/100[/{risk_color}]\n"
        f"Files scanned: {result.files_scanned}\n"
        f"Total findings: {len(result.filtered_findings)}\n"
        f"Scan duration: {result.scan_duration:.2f}s",
        title="Scan Summary",
    ))

    # Severity breakdown table
    sev_table = Table(title="Severity Breakdown", box=box.SIMPLE)
    sev_table.add_column("Severity", style="bold")
    sev_table.add_column("Count", justify="right")

    severity_counts = {}
    for f in result.filtered_findings:
        sev = f.get("severity", "MEDIUM").upper()
        severity_counts[sev] = severity_counts.get(sev, 0) + 1

    for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]:
        count = severity_counts.get(sev, 0)
        if count > 0:
            sev_table.add_row(f"[{severity_colors.get(sev, 'white')}]{sev}[/{severity_colors.get(sev, 'white')}]", str(count))

    console.print(sev_table)

    # Findings table
    if result.filtered_findings:
        findings_table = Table(title="Findings", box=box.SIMPLE, show_lines=True)
        findings_table.add_column("#", style="dim", width=4)
        findings_table.add_column("Severity", width=10)
        findings_table.add_column("Title", width=40)
        findings_table.add_column("File", width=30)
        findings_table.add_column("Line", width=6)
        findings_table.add_column("Source", width=15)

        for i, f in enumerate(result.filtered_findings[:50], 1):
            sev = f.get("severity", "MEDIUM").upper()
            sev_style = severity_colors.get(sev, "white")
            findings_table.add_row(
                str(i),
                f"[{sev_style}]{sev}[/{sev_style}]",
                f.get("title", "Unknown")[:40],
                f.get("file", "N/A")[-30:],
                str(f.get("line", "N/A")),
                f.get("source", f.get("agent", "unknown"))[:15],
            )

        console.print(findings_table)

    # Auto-fix suggestions
    if result.fixes:
        console.print(f"\n[bold green]Auto-fix Suggestions ({len(result.fixes)}):[/bold green]")
        for i, fix in enumerate(result.fixes[:20], 1):
            console.print(f"  {i}. [{severity_colors.get(fix.get('severity', 'INFO'), 'dim')}]{fix.get('title', 'Unknown')}[/{severity_colors.get(fix.get('severity', 'INFO'), 'dim')}]")
            if fix.get("fix"):
                console.print(f"     [dim]Fix: {fix['fix'][:100]}[/dim]")

    # Report locations
    if result.report_files:
        console.print("\n[bold]Reports generated:[/bold]")
        for fmt, path in result.report_files.items():
            console.print(f"  [{fmt}] {path}")

    # Stats
    if result.stats:
        console.print(f"\n[dim]Stats: {result.stats}[/dim]")

    return 0 if result.risk_score < 75 else 1


async def _run_scan_simple(args) -> int:
    """Simple scan output without Rich library."""
    config = ShieldConfig(config_path=args.config) if args.config else ShieldConfig()
    config.target_path = args.target

    if args.provider:
        config.llm.provider = args.provider
    if args.output:
        config.report.output_dir = args.output
    if args.no_cache:
        config.cache.enabled = False
    if args.no_dedup:
        config.deduplication.enabled = False
    if args.no_ignore:
        config.shieldignore.enabled = False
    if args.sarif_only:
        config.report.formats = ["sarif"]
    if args.ci:
        config.report.formats = ["sarif", "json"]

    setup_logging(args.verbose, args.debug)

    print(f"Shield Agents v{__version__} - Scanning {args.target}...")
    orchestrator = Orchestrator(config)
    result = await orchestrator.scan(
        target_path=args.target,
        full_scan=args.full,
        generate_report=not args.no_report,
        generate_fixes=args.fix,
    )

    # JSON format: output to stdout and exit
    if args.format == "json":
        output = result.to_dict()
        print(json.dumps(output, indent=2))
        threshold = getattr(args, 'fail_threshold', 75) or 75
        return 1 if result.risk_score >= threshold else 0

    print(f"\nRisk Score: {result.risk_score}/100")
    print(f"Files scanned: {result.files_scanned}")
    print(f"Findings: {len(result.filtered_findings)}")
    print(f"Duration: {result.scan_duration:.2f}s")

    if result.report_files:
        print("\nReports:")
        for fmt, path in result.report_files.items():
            print(f"  [{fmt}] {path}")

    return 0 if result.risk_score < 75 else 1


def run_init(args) -> int:
    """Initialize Shield Agents configuration."""
    path = args.path
    template_path = ShieldIgnore.create_template(path)
    print(f"Created .shieldignore template at: {template_path}")

    # Also create a default config
    config_path = Path(path) / "config.yaml"
    if not config_path.exists():
        config_content = """# Shield Agents Configuration
llm:
  provider: mock  # mock, openai, anthropic, ollama
  # api_key: YOUR_API_KEY  # Or set SHIELD_LLM_API_KEY env var
  model: gpt-4
  temperature: 0.1

scanner:
  sast_enabled: true
  secrets_enabled: true

cache:
  enabled: true
  incremental: true

deduplication:
  enabled: true
  merge_sources: true

report:
  output_dir: ./shield-reports
  formats:
    - html
    - sarif
    - json
"""
        Path(path).mkdir(parents=True, exist_ok=True)
        config_path.write_text(config_content)
        print(f"Created config template at: {config_path}")

    return 0


def run_cache(args) -> int:
    """Manage scan cache."""
    cache = ScanCache()
    cache._ensure_loaded()

    if args.clear:
        cache.clear()
        cache.save()
        print("Cache cleared.")
    elif args.stats:
        stats = cache.get_stats()
        print(f"Cached files: {stats['cached_files']}")
        print(f"Total cached findings: {stats['total_cached_findings']}")
        print(f"Cache size: {stats['cache_size_bytes']} bytes")
    else:
        print("Use --clear or --stats")

    return 0


def main():
    """Main entry point."""
    parser = create_parser()
    args = parser.parse_args()

    if args.command is None:
        parser.print_help()
        return 0

    if args.command == "version":
        print(f"Shield Agents v{__version__}")
        return 0

    if args.command == "init":
        return run_init(args)

    if args.command == "cache":
        return run_cache(args)

    if args.command == "scan":
        try:
            if args.ci:
                return asyncio.run(run_scan_ci(args))
            return asyncio.run(run_scan(args))
        except KeyboardInterrupt:
            print("\nScan interrupted.")
            return 130

    return 0


if __name__ == "__main__":
    sys.exit(main())