aiscan-security 0.0.2__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.
aiscan/__init__.py ADDED
File without changes
aiscan/api/__init__.py ADDED
File without changes
aiscan/api/routes.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ REST API — all 5 scan type endpoints.
3
+ POST /scan/prompt
4
+ POST /scan/agent
5
+ POST /scan/api
6
+ POST /scan/mcp
7
+ POST /scan/integration
8
+ POST /scan/all — runs all 5 scanners, returns combined results
9
+ GET /health
10
+ """
11
+ import os
12
+ import tempfile
13
+
14
+ from fastapi import FastAPI, UploadFile, File
15
+ from pydantic import BaseModel
16
+
17
+ from aiscan.models import ScanType, Severity
18
+ from aiscan.scanners.agent_scanner import scan_agent
19
+ from aiscan.scanners.api_scanner import scan_api
20
+ from aiscan.scanners.integration_scanner import scan_integration
21
+ from aiscan.scanners.mcp_scanner import scan_mcp
22
+ from aiscan.scanners.prompt_scanner import scan_prompt
23
+ from aiscan.scanners.tm_coding_standards_scanner import tm_coding_standards_scanner
24
+ from aiscan.utils.aggregator import aggregate
25
+
26
+ app = FastAPI(
27
+ title="AI Security Scanner",
28
+ description="Scan prompts, agents, APIs, MCP tools and integrations for security vulnerabilities",
29
+ version="0.0.2",
30
+ )
31
+
32
+
33
+ class ScanRequest(BaseModel):
34
+ content: str
35
+ filename: str = "input.txt"
36
+
37
+
38
+ class FindingOut(BaseModel):
39
+ rule_id: str
40
+ severity: str
41
+ threat: str
42
+ title: str
43
+ detail: str
44
+ file: str
45
+ line: int
46
+ fix: str
47
+
48
+
49
+ class ScanResponse(BaseModel):
50
+ scan_type: str
51
+ passed: bool
52
+ critical: int
53
+ high: int
54
+ medium: int
55
+ info: int
56
+ total: int
57
+ findings: list[FindingOut]
58
+
59
+
60
+ def _run_scan(req: ScanRequest, scan_fn, scan_type: ScanType, suffix: str = ".txt") -> ScanResponse:
61
+ with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False, encoding="utf-8") as f:
62
+ f.write(req.content)
63
+ tmp = f.name
64
+ try:
65
+ result = scan_fn(tmp)
66
+ result.findings = aggregate(result.findings)
67
+ return _shape(result, scan_type)
68
+ finally:
69
+ os.unlink(tmp)
70
+
71
+
72
+ def _shape(result, scan_type: ScanType) -> ScanResponse:
73
+ findings = [{
74
+ "rule_id": f.rule_id, "severity": f.severity.value, "threat": f.threat_type.value,
75
+ "title": f.title, "detail": f.detail, "file": f.file, "line": f.line, "fix": f.fix,
76
+ } for f in result.findings]
77
+ return ScanResponse(
78
+ scan_type=scan_type.value, passed=result.passed,
79
+ critical=result.critical_count, high=result.high_count,
80
+ medium=sum(1 for f in result.findings if f.severity == Severity.MEDIUM),
81
+ info=sum(1 for f in result.findings if f.severity == Severity.INFO),
82
+ total=len(result.findings), findings=findings,
83
+ )
84
+
85
+
86
+ @app.get("/health")
87
+ async def health():
88
+ return {"status": "ok", "service": "ai-security-scanner", "version": "0.0.2",
89
+ "scan_types": ["prompt", "agent", "api", "mcp", "integration"]}
90
+
91
+
92
+ @app.post("/scan/prompt", response_model=ScanResponse)
93
+ async def scan_prompt_ep(req: ScanRequest):
94
+ return _run_scan(req, scan_prompt, ScanType.PROMPT, ".txt")
95
+
96
+
97
+ @app.post("/scan/agent", response_model=ScanResponse)
98
+ async def scan_agent_ep(req: ScanRequest):
99
+ return _run_scan(req, scan_agent, ScanType.AGENT, ".yaml")
100
+
101
+
102
+ @app.post("/scan/api", response_model=ScanResponse)
103
+ async def scan_api_ep(req: ScanRequest):
104
+ suffix = "." + req.filename.rsplit(".", 1)[-1] if "." in req.filename else ".yaml"
105
+ return _run_scan(req, scan_api, ScanType.API, suffix)
106
+
107
+
108
+ @app.post("/scan/mcp", response_model=ScanResponse)
109
+ async def scan_mcp_ep(req: ScanRequest):
110
+ return _run_scan(req, scan_mcp, ScanType.MCP, ".json")
111
+
112
+
113
+ @app.post("/scan/integration", response_model=ScanResponse)
114
+ async def scan_integration_ep(req: ScanRequest):
115
+ return _run_scan(req, scan_integration, ScanType.INTEGRATION, ".yaml")
116
+
117
+
118
+ @app.post("/scan/all")
119
+ async def scan_all(req: ScanRequest):
120
+ """Run all 5 scanners against the same content. Useful for mixed config files."""
121
+ results = []
122
+ for fn, st, sfx in [
123
+ (scan_prompt, ScanType.PROMPT, ".txt"),
124
+ (scan_agent, ScanType.AGENT, ".yaml"),
125
+ (scan_api, ScanType.API, ".yaml"),
126
+ (scan_mcp, ScanType.MCP, ".json"),
127
+ (scan_integration, ScanType.INTEGRATION, ".yaml"),
128
+ ]:
129
+ results.append(_run_scan(req, fn, st, sfx).model_dump())
130
+ total_findings = sum(r["total"] for r in results)
131
+ total_critical = sum(r["critical"] for r in results)
132
+ return {
133
+ "passed": total_critical == 0 and sum(r["high"] for r in results) == 0,
134
+ "total_findings": total_findings,
135
+ "total_critical": total_critical,
136
+ "results": results,
137
+ }
138
+
139
+
140
+ # coding standards
141
+
142
+ @app.post("/tm-coding-standards")
143
+ async def tm_coding_standards(file: UploadFile = File(...)):
144
+ suffix = os.path.splitext(file.filename)[1]
145
+
146
+ with tempfile.NamedTemporaryFile(
147
+ delete=False,
148
+ suffix=suffix
149
+ ) as tmp:
150
+
151
+ content = await file.read()
152
+ tmp.write(content)
153
+
154
+ temp_path = tmp.name
155
+
156
+ try:
157
+ result = tm_coding_standards_scanner(temp_path)
158
+
159
+ return {
160
+ "target": result.target,
161
+ "total_findings": len(result.findings),
162
+ "findings": [
163
+ {
164
+ "rule_id": f.rule_id,
165
+ "severity": f.severity.value if hasattr(f.severity, "value") else str(f.severity),
166
+ "title": f.title,
167
+ "detail": f.detail,
168
+ "file": f.file,
169
+ "line": f.line,
170
+ "fix": f.fix,
171
+ }
172
+ for f in result.findings
173
+ ]
174
+ }
175
+
176
+ finally:
177
+ os.unlink(temp_path)
aiscan/cli.py ADDED
@@ -0,0 +1,387 @@
1
+ """
2
+ aiscan — AI Security Scanner CLI
3
+
4
+ A generic, installable command-line tool to scan AI system components
5
+ for security vulnerabilities.
6
+
7
+ Usage after install:
8
+ aiscan scan prompt.txt
9
+ aiscan scan --type api openapi.yaml
10
+ aiscan scan --type all config.yaml --output json
11
+ aiscan init
12
+ aiscan serve
13
+ """
14
+ import importlib
15
+ from pathlib import Path
16
+ from typing import Optional
17
+
18
+ import typer
19
+ from rich.console import Console
20
+ from rich.panel import Panel
21
+
22
+ from aiscan.rules.registry import PYTHON_RULES
23
+
24
+ app = typer.Typer(
25
+ name="aiscan",
26
+ help="AI Security Scanner — scan prompts, agents, APIs, MCP tools and integrations.",
27
+ add_completion=True,
28
+ no_args_is_help=True,
29
+ )
30
+ console = Console()
31
+
32
+ # ── scan type → (module, function, default suffix) ────────────────────────────
33
+ SCAN_REGISTRY = {
34
+ "prompt": ("aiscan.scanners.prompt_scanner", "scan_prompt", ".txt"),
35
+ "agent": ("aiscan.scanners.agent_scanner", "scan_agent", ".yaml"),
36
+ "api": ("aiscan.scanners.api_scanner", "scan_api", ".yaml"),
37
+ "mcp": ("aiscan.scanners.mcp_scanner", "scan_mcp", ".json"),
38
+ "integration": ("aiscan.scanners.integration_scanner", "scan_integration", ".yaml"),
39
+ "code": ("aiscan.scanners.api_scanner", "scan_code_api", ".py"),
40
+ }
41
+
42
+ # Auto-detect scan type from file extension
43
+ AUTO_TYPE = {
44
+ # Prompt files
45
+ ".py": "prompt", ".txt": "prompt", ".md": "prompt", ".j2": "prompt", ".jinja": "prompt",
46
+ # Code files — scanned with full rule set
47
+ ".js": "prompt", ".ts": "prompt", ".jsx": "prompt",
48
+ ".tsx": "prompt", ".go": "prompt", ".java": "prompt", ".rb": "prompt",
49
+ ".php": "prompt", ".cs": "prompt", ".cpp": "prompt", ".c": "prompt",
50
+ ".rs": "prompt", ".swift": "prompt", ".kt": "prompt",
51
+ # API / config files
52
+ ".yaml": "api", ".yml": "api", ".json": "mcp",
53
+ # Integration / environment files
54
+ ".env": "integration", ".toml": "integration",
55
+ ".ini": "integration", ".cfg": "integration", ".conf": "integration",
56
+ ".properties": "integration",
57
+ # code file
58
+ ".py": "code",
59
+ }
60
+
61
+ # All file extensions aiscan will pick up when scanning a directory
62
+ SCANNABLE_EXTENSIONS = set(AUTO_TYPE.keys())
63
+
64
+
65
+ def _resolve_type(file: Path, declared: str) -> list[str]:
66
+ if declared == "all":
67
+ return list(SCAN_REGISTRY.keys())
68
+
69
+ if declared == "auto":
70
+ ext = file.suffix.lower()
71
+
72
+ # Python files → run BOTH scanners
73
+ if ext == ".py":
74
+ return ["prompt", "code"]
75
+
76
+ return [AUTO_TYPE.get(ext, "prompt")]
77
+
78
+ return [declared]
79
+
80
+
81
+ def _run_one(file: Path, scan_type: str):
82
+ """Run a single scanner and return ScanResult."""
83
+ mod_name, fn_name, _ = SCAN_REGISTRY[scan_type]
84
+ mod = importlib.import_module(mod_name)
85
+ return getattr(mod, fn_name)(str(file))
86
+
87
+
88
+ # ── scan command ─────────────────────────────────────────────────────────────
89
+ @app.command()
90
+ def scan(
91
+ files: list[Path] = typer.Argument(
92
+ ..., help="One or more files to scan. Globs are expanded by the shell.",
93
+ exists=True, readable=True,
94
+ ),
95
+ type: str = typer.Option(
96
+ "auto",
97
+ "--type", "-t",
98
+ help="Scan type: prompt | agent | api | mcp | integration |code | all | auto",
99
+ ),
100
+ output: str = typer.Option(
101
+ "console",
102
+ "--output", "-o",
103
+ help="Output format: console | json | sarif | pdf",
104
+ ),
105
+ out: Optional[Path] = typer.Option(
106
+ None,
107
+ "--out", "-O",
108
+ help="Write output to file instead of stdout",
109
+ ),
110
+ fail_on: str = typer.Option(
111
+ "high",
112
+ "--fail-on", "-f",
113
+ help="Exit code 1 if any finding reaches this severity: critical | high | medium | none",
114
+ ),
115
+ no_ai: bool = typer.Option(
116
+ False,
117
+ "--no-ai",
118
+ help="Skip AI deep scan — run static rules only (faster, no API key needed)",
119
+ ),
120
+ quiet: bool = typer.Option(
121
+ False,
122
+ "--quiet", "-q",
123
+ help="Suppress all output except errors. Useful in CI.",
124
+ ),
125
+ ):
126
+ """
127
+ Analyze one or more files for AI security threats, code quality issues, and compliance with TMotions Python coding standards.
128
+
129
+ Examples:
130
+
131
+ aiscan scan system_prompt.txt
132
+
133
+ aiscan scan --type api openapi.yaml
134
+
135
+ aiscan scan --type all config.yaml --output json
136
+
137
+ aiscan scan --type prompt prompts/ --output sarif --out results.sarif
138
+
139
+ aiscan scan --no-ai --fail-on critical agent.yaml
140
+ """
141
+ from aiscan.utils.aggregator import aggregate
142
+ from aiscan.reporters.console_reporter import print_results
143
+ from aiscan.reporters.json_reporter import to_json, to_sarif
144
+
145
+ if no_ai:
146
+ import os
147
+ os.environ["AI_PROVIDER"] = ""
148
+
149
+ results = []
150
+ for file in files:
151
+ if file.is_dir():
152
+ # Expand directory — scan all recognised file types inside
153
+ all_files = [
154
+ f for f in file.rglob("*")
155
+ if f.is_file() and f.suffix.lower() in SCANNABLE_EXTENSIONS
156
+ ]
157
+ if not all_files and not quiet:
158
+ console.print(f"[yellow]No scannable files found in {file}[/yellow]")
159
+ for f in all_files:
160
+ for st in _resolve_type(f, type):
161
+ r = _run_one(f, st)
162
+ r.findings = aggregate(r.findings)
163
+ results.append(r)
164
+ else:
165
+ for st in _resolve_type(file, type):
166
+ r = _run_one(file, st)
167
+ r.findings = aggregate(r.findings)
168
+ results.append(r)
169
+
170
+ if not results:
171
+ if not quiet:
172
+ console.print("[yellow]No files scanned.[/yellow]")
173
+ raise typer.Exit(0)
174
+
175
+ # Output
176
+ if output == "json":
177
+ rendered = to_json(results)
178
+ if out:
179
+ out.write_text(rendered)
180
+ if not quiet:
181
+ console.print(f"[green]JSON written to {out}[/green]")
182
+ else:
183
+ print(rendered)
184
+
185
+ elif output == "sarif":
186
+ rendered = to_sarif(results)
187
+ if out:
188
+ out.write_text(rendered)
189
+ if not quiet:
190
+ console.print(f"[green]SARIF written to {out}[/green]")
191
+ else:
192
+ print(rendered)
193
+
194
+ elif output == "pdf":
195
+ from aiscan.reporters.pdf_reporter import generate_pdf
196
+ pdf_path = str(out) if out else "aiscan-report.pdf"
197
+ generate_pdf(results, pdf_path)
198
+ if not quiet:
199
+ console.print(f"[green]PDF report saved to {pdf_path}[/green]")
200
+
201
+ else:
202
+ if not quiet:
203
+ print_results(results)
204
+
205
+ # CI exit code
206
+ FAIL_RANK = {"critical": 4, "high": 3, "medium": 2, "none": 0}
207
+ from aiscan.models import Severity
208
+ SEV_RANK = {Severity.CRITICAL: 4, Severity.HIGH: 3, Severity.MEDIUM: 2, Severity.INFO: 1}
209
+ threshold = FAIL_RANK.get(fail_on, 3)
210
+ max_sev = max((SEV_RANK.get(f.severity, 0) for r in results for f in r.findings), default=0)
211
+ if max_sev >= threshold:
212
+ raise typer.Exit(code=1)
213
+
214
+
215
+ # ── serve command ─────────────────────────────────────────────────────────────
216
+ @app.command()
217
+ def serve(
218
+ host: str = typer.Option("0.0.0.0", "--host", help="Host to bind"),
219
+ port: int = typer.Option(8000, "--port", help="Port to listen on"),
220
+ reload: bool = typer.Option(False, "--reload", help="Auto-reload on code changes (dev mode)"),
221
+ ):
222
+ """
223
+ Start the AI Security Scanner REST API server.
224
+
225
+ Endpoints:
226
+ GET /health
227
+ POST /scan/prompt
228
+ POST /scan/agent
229
+ POST /scan/api
230
+ POST /scan/mcp
231
+ POST /scan/integration
232
+ POST /scan/all
233
+ GET /docs (Swagger UI)
234
+
235
+ Example:
236
+ aiscan serve --port 8000 --reload
237
+ """
238
+ try:
239
+ import uvicorn
240
+ except ImportError:
241
+ console.print("[red]Install the api extra to use serve: pip install aiscan[api][/red]")
242
+ raise typer.Exit(1)
243
+
244
+ from aiscan.api import routes # noqa: F401 — triggers app creation
245
+ console.print(Panel(
246
+ f"[bold]AI Security Scanner API[/bold]\n"
247
+ f"http://{host}:{port}\n"
248
+ f"Swagger UI: http://{host}:{port}/docs",
249
+ title="aiscan serve",
250
+ ))
251
+ uvicorn.run("aiscan.api.routes:app", host=host, port=port, reload=reload)
252
+
253
+
254
+ # ── rules command ─────────────────────────────────────────────────────────────
255
+ @app.command()
256
+ def rules(
257
+ type: Optional[str] = typer.Argument(None,
258
+ help="Filter by scan type: prompt | agent | api | mcp | integration"),
259
+ severity: Optional[str] = typer.Option(None, "--severity", "-s",
260
+ help="Filter by severity: critical | high | medium | info"),
261
+ ):
262
+ """
263
+ List all security rules in the registry.
264
+
265
+ Examples:
266
+ aiscan rules
267
+ aiscan rules prompt
268
+ aiscan rules api --severity critical
269
+ """
270
+ from rich.table import Table
271
+ from rich import box
272
+ from aiscan.rules.registry import RULES
273
+ from aiscan.models import ScanType
274
+
275
+ SEV_STYLE = {
276
+ "critical": "bold red",
277
+ "high": "red",
278
+ "medium": "yellow",
279
+ "info": "blue",
280
+ "low": "dim",
281
+ }
282
+
283
+ count = 0
284
+
285
+ table = Table(box=box.SIMPLE, show_header=True, header_style="bold")
286
+ table.add_column("ID", width=9)
287
+ table.add_column("Target", width=12)
288
+ table.add_column("Threat", width=20)
289
+ table.add_column("Severity", width=10)
290
+ table.add_column("Title", width=48)
291
+
292
+ filtered = RULES
293
+ if type:
294
+ try:
295
+ st = ScanType(type)
296
+ filtered = [r for r in filtered if st in r["scan_types"]]
297
+ except ValueError:
298
+ console.print(f"[red]Unknown type '{type}'. Use: {', '.join(SCAN_REGISTRY)}[/red]")
299
+ raise typer.Exit(1)
300
+
301
+ if severity:
302
+ filtered = [r for r in filtered if r["severity"].value == severity]
303
+
304
+ for r in filtered:
305
+ sev = r["severity"].value
306
+ targets = ", ".join(s.value for s in r["scan_types"])
307
+ table.add_row(
308
+ r["id"], targets, r["threat"].value, sev, r["title"],
309
+ style=SEV_STYLE.get(sev, ""),
310
+ )
311
+
312
+ if not type or type.lower() == "code":
313
+
314
+ for r in PYTHON_RULES:
315
+ sev = r["severity"].value.lower()
316
+
317
+ if severity and sev != severity.lower():
318
+ continue
319
+
320
+ table.add_row(
321
+ f"[{SEV_STYLE.get(sev, '')}]{r['id']}[/]",
322
+ f"[{SEV_STYLE.get(sev, '')}]code[/]",
323
+ f"[{SEV_STYLE.get(sev, '')}]TM_Coding_Standards[/]",
324
+ f"[{SEV_STYLE.get(sev, '')}]{sev.upper()}[/]",
325
+ f"[{SEV_STYLE.get(sev, '')}]{r['title']}[/]",
326
+ )
327
+ count += 1
328
+
329
+ console.print(table)
330
+ console.print(f"\n[dim]{len(filtered)} rule(s) shown[/dim]")
331
+
332
+
333
+ # ── init command ──────────────────────────────────────────────────────────────
334
+ @app.command()
335
+ def init(
336
+ provider: str = typer.Option("claude", "--provider", "-p", help="AI provider: claude | openai | vertex"),
337
+ force: bool = typer.Option(False, "--force", help="Overwrite existing .env file"),
338
+ ):
339
+ """
340
+ Initialise a new .env config file in the current directory.
341
+
342
+ Example:
343
+ aiscan init
344
+ aiscan init --provider openai
345
+ """
346
+ env_path = Path(".env")
347
+ if env_path.exists() and not force:
348
+ console.print("[yellow].env already exists. Use --force to overwrite.[/yellow]")
349
+ raise typer.Exit(0)
350
+
351
+ content = f"""# aiscan configuration
352
+ # Generated by: aiscan init --provider {provider}
353
+
354
+ # Active provider: claude | openai | vertex
355
+ AI_PROVIDER={provider}
356
+
357
+ # Anthropic (Claude)
358
+ ANTHROPIC_API_KEY=sk-ant-...
359
+ SCANNER_MODEL=claude-sonnet-4-20250514
360
+
361
+ # OpenAI (GPT-4o)
362
+ OPENAI_API_KEY=sk-...
363
+ OPENAI_MODEL=gpt-4o
364
+
365
+ # Vertex AI (Gemini)
366
+ VERTEX_PROJECT=your-gcp-project-id
367
+ VERTEX_LOCATION=us-central1
368
+ VERTEX_MODEL=gemini-2.5-flash-001
369
+
370
+ # Limits
371
+ MAX_TOKENS=2000
372
+ LOG_LEVEL=INFO
373
+ """
374
+ env_path.write_text(content)
375
+ console.print(f"[green]Created .env — set your {provider.upper()}_API_KEY and run:[/green]")
376
+ console.print(f" aiscan scan --type prompt your_prompt.txt")
377
+
378
+
379
+ # ── version command ───────────────────────────────────────────────────────────
380
+ @app.command()
381
+ def version():
382
+ """Show aiscan version."""
383
+ console.print("aiscan 0.0.2")
384
+
385
+
386
+ if __name__ == "__main__":
387
+ app()
aiscan/config.py ADDED
@@ -0,0 +1,23 @@
1
+ from typing import Literal
2
+
3
+ from pydantic_settings import BaseSettings
4
+
5
+
6
+ class Settings(BaseSettings):
7
+ log_level: str = "INFO"
8
+ ai_provider: Literal["claude", "openai", "vertex", ""] = "claude"
9
+ anthropic_api_key: str = ""
10
+ scanner_model: str = "claude-sonnet-4-20250514"
11
+ openai_api_key: str = ""
12
+ openai_model: str = "gpt-4o"
13
+ vertex_project: str = ""
14
+ vertex_location: str = "us-central1"
15
+ vertex_model: str = "gemini-2.5-flash-001"
16
+ max_tokens: int = 2000
17
+
18
+ class Config:
19
+ env_file = ".env"
20
+ extra = "ignore"
21
+
22
+
23
+ settings = Settings()
aiscan/models.py ADDED
@@ -0,0 +1,54 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
3
+
4
+
5
+ class Severity(str, Enum):
6
+ CRITICAL = "critical"
7
+ HIGH = "high"
8
+ MEDIUM = "medium"
9
+ INFO = "info"
10
+
11
+
12
+ class ScanType(str, Enum):
13
+ PROMPT = "prompt"
14
+ AGENT = "agent"
15
+ API = "api"
16
+ MCP = "mcp"
17
+ INTEGRATION = "integration"
18
+
19
+
20
+ class ThreatType(str, Enum):
21
+ PROMPT_INJECTION = "prompt_injection"
22
+ DATA_LEAKAGE = "data_leakage"
23
+ TOOL_ABUSE = "tool_abuse"
24
+ SECURITY_RISK = "security_risk"
25
+
26
+
27
+ @dataclass
28
+ class Finding:
29
+ scan_type: ScanType
30
+ threat_type: ThreatType
31
+ severity: Severity
32
+ rule_id: str
33
+ title: str
34
+ detail: str
35
+ file: str
36
+ line: int
37
+ fix: str
38
+
39
+
40
+ @dataclass
41
+ class ScanResult:
42
+ target: str
43
+ scan_type: ScanType
44
+ findings: list[Finding] = field(default_factory=list)
45
+ error: str = ""
46
+
47
+ @property
48
+ def critical_count(self): return sum(1 for f in self.findings if f.severity == Severity.CRITICAL)
49
+
50
+ @property
51
+ def high_count(self): return sum(1 for f in self.findings if f.severity == Severity.HIGH)
52
+
53
+ @property
54
+ def passed(self): return self.critical_count == 0 and self.high_count == 0
File without changes
@@ -0,0 +1,49 @@
1
+ """Rich console reporter for all 5 scan types."""
2
+ from rich import box
3
+ from rich.console import Console
4
+ from rich.panel import Panel
5
+ from rich.table import Table
6
+
7
+ from aiscan.models import ScanResult, Severity
8
+
9
+ console = Console()
10
+
11
+ ICON = {Severity.CRITICAL: "🔴", Severity.HIGH: "🟠", Severity.MEDIUM: "🟡", Severity.INFO: "🔵"}
12
+ STYLE = {Severity.CRITICAL: "bold red", Severity.HIGH: "bold yellow", Severity.MEDIUM: "yellow", Severity.INFO: "dim"}
13
+
14
+
15
+ def print_results(results: list[ScanResult]) -> int:
16
+ total = 0
17
+ for r in results:
18
+ if r.error:
19
+ console.print(f"[red]ERROR {r.target}: {r.error}[/red]")
20
+ continue
21
+ if not r.findings:
22
+ console.print(f"[green]✅ {r.target} ({r.scan_type.value}) — no issues[/green]")
23
+ continue
24
+ t = Table(box=box.SIMPLE, show_header=True, header_style="bold")
25
+ t.add_column("Sev", width=10)
26
+ t.add_column("Rule", width=8)
27
+ t.add_column("Threat", width=20)
28
+ t.add_column("Title", width=44)
29
+ t.add_column("Line", width=5, justify="right")
30
+ for f in r.findings:
31
+ t.add_row(
32
+ f"{ICON.get(f.severity, '')} {f.severity.value}",
33
+ f.rule_id, f.threat_type.value, f.title,
34
+ str(f.line) if f.line else "—",
35
+ style=STYLE.get(f.severity, ""),
36
+ )
37
+ console.print(Panel(t, title=f"[bold]{r.target}[/bold] [{r.scan_type.value}]",
38
+ subtitle=f"{len(r.findings)} finding(s)"))
39
+ for f in r.findings:
40
+ if f.severity in (Severity.CRITICAL, Severity.HIGH):
41
+ console.print(f" [bold]{f.title}[/bold]")
42
+ console.print(f" [dim]{f.detail}[/dim]")
43
+ console.print(f" [green]Fix:[/green] {f.fix}\n")
44
+ total += len(r.findings)
45
+ if total == 0:
46
+ console.print("\n[bold green]✅ All scans passed.[/bold green]")
47
+ else:
48
+ console.print(f"\n[bold]Total findings: {total}[/bold]")
49
+ return total