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