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 +0 -0
- aiscan/api/__init__.py +0 -0
- aiscan/api/routes.py +177 -0
- aiscan/cli.py +387 -0
- aiscan/config.py +23 -0
- aiscan/models.py +54 -0
- aiscan/reporters/__init__.py +0 -0
- aiscan/reporters/console_reporter.py +49 -0
- aiscan/reporters/json_reporter.py +40 -0
- aiscan/reporters/pdf_reporter.py +288 -0
- aiscan/rules/__init__.py +0 -0
- aiscan/rules/registry.py +427 -0
- aiscan/scanners/__init__.py +0 -0
- aiscan/scanners/agent_scanner.py +7 -0
- aiscan/scanners/api_scanner.py +13 -0
- aiscan/scanners/base_scanner.py +78 -0
- aiscan/scanners/integration_scanner.py +7 -0
- aiscan/scanners/mcp_scanner.py +7 -0
- aiscan/scanners/prompt_scanner.py +7 -0
- aiscan/scanners/tm_coding_standards_scanner.py +51 -0
- aiscan/sdk/__init__.py +13 -0
- aiscan/sdk/decorators.py +178 -0
- aiscan/sdk/http_client.py +125 -0
- aiscan/sdk/middleware.py +207 -0
- aiscan/sdk/scanner.py +236 -0
- aiscan/tm_coding_standards/__init__.py +0 -0
- aiscan/tm_coding_standards/models.py +62 -0
- aiscan/tm_coding_standards/registry.py +11 -0
- aiscan/utils/__init__.py +0 -0
- aiscan/utils/aggregator.py +16 -0
- aiscan/utils/ai_checker.py +163 -0
- aiscan_security-0.0.2.dist-info/METADATA +748 -0
- aiscan_security-0.0.2.dist-info/RECORD +36 -0
- aiscan_security-0.0.2.dist-info/WHEEL +5 -0
- aiscan_security-0.0.2.dist-info/entry_points.txt +2 -0
- aiscan_security-0.0.2.dist-info/top_level.txt +1 -0
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
|