aina-scan 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aina_scan/__init__.py
ADDED
aina_scan/cli.py
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
"""aina-scan CLI — scan Python files against the AINA Scan API."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import sys
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
import zipfile
|
|
13
|
+
import io
|
|
14
|
+
|
|
15
|
+
from . import API_BASE, __version__
|
|
16
|
+
|
|
17
|
+
CONFIG_FILE = pathlib.Path.home() / ".aina_scan" / "config.json"
|
|
18
|
+
_LEGACY_CONFIG = pathlib.Path.home() / ".aina_vibeguard" / "config.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── config helpers ──────────────────────────────────────────────
|
|
22
|
+
def _load_config() -> dict:
|
|
23
|
+
for cfg in (CONFIG_FILE, _LEGACY_CONFIG):
|
|
24
|
+
if cfg.exists():
|
|
25
|
+
try:
|
|
26
|
+
return json.loads(cfg.read_text(encoding="utf-8"))
|
|
27
|
+
except Exception:
|
|
28
|
+
pass
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _save_config(data: dict) -> None:
|
|
33
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
CONFIG_FILE.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _api_key() -> str:
|
|
38
|
+
# Support both env var names for backward compat
|
|
39
|
+
key = (
|
|
40
|
+
os.environ.get("AINA_SCAN_API_KEY")
|
|
41
|
+
or os.environ.get("VIBEGUARD_API_KEY")
|
|
42
|
+
or _load_config().get("api_key", "")
|
|
43
|
+
)
|
|
44
|
+
if not key:
|
|
45
|
+
print("❌ API key not set.\n"
|
|
46
|
+
" Run: aina-scan config --key YOUR_KEY\n"
|
|
47
|
+
" Or: export AINA_SCAN_API_KEY=YOUR_KEY", file=sys.stderr)
|
|
48
|
+
sys.exit(1)
|
|
49
|
+
return key
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── HTTP helpers ─────────────────────────────────────────────────
|
|
53
|
+
def _get(path: str, api_key: str) -> dict:
|
|
54
|
+
req = urllib.request.Request(
|
|
55
|
+
API_BASE + path,
|
|
56
|
+
headers={"X-API-Key": api_key},
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
60
|
+
return json.loads(r.read())
|
|
61
|
+
except urllib.error.HTTPError as e:
|
|
62
|
+
return json.loads(e.read())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _post_json(path: str, body: dict, api_key: str) -> dict:
|
|
66
|
+
req = urllib.request.Request(
|
|
67
|
+
API_BASE + path,
|
|
68
|
+
data=json.dumps(body).encode(),
|
|
69
|
+
headers={"Content-Type": "application/json", "X-API-Key": api_key},
|
|
70
|
+
method="POST",
|
|
71
|
+
)
|
|
72
|
+
try:
|
|
73
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
74
|
+
return json.loads(r.read())
|
|
75
|
+
except urllib.error.HTTPError as e:
|
|
76
|
+
return json.loads(e.read())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _scan_file_bytes(filename: str, data: bytes, api_key: str) -> dict:
|
|
80
|
+
boundary = b"----AINAScanBoundary"
|
|
81
|
+
body = (
|
|
82
|
+
b"--" + boundary +
|
|
83
|
+
b"\r\nContent-Disposition: form-data; name=\"file\"; filename=\"" +
|
|
84
|
+
filename.encode() + b"\"\r\n"
|
|
85
|
+
b"Content-Type: text/plain\r\n\r\n" +
|
|
86
|
+
data +
|
|
87
|
+
b"\r\n--" + boundary + b"--\r\n"
|
|
88
|
+
)
|
|
89
|
+
req = urllib.request.Request(
|
|
90
|
+
API_BASE + "/v1/scan", data=body,
|
|
91
|
+
headers={
|
|
92
|
+
"X-API-Key": api_key,
|
|
93
|
+
"Content-Type": "multipart/form-data; boundary=----AINAScanBoundary",
|
|
94
|
+
},
|
|
95
|
+
method="POST",
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
with urllib.request.urlopen(req, timeout=30) as r:
|
|
99
|
+
return json.loads(r.read())
|
|
100
|
+
except urllib.error.HTTPError as e:
|
|
101
|
+
return json.loads(e.read())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── result printer ───────────────────────────────────────────────
|
|
105
|
+
def _print_result(d: dict, verbose: bool = False) -> int:
|
|
106
|
+
tier = d.get("tier", "?")
|
|
107
|
+
fname = d.get("filename", "?")
|
|
108
|
+
blk = d.get("blocks", 0)
|
|
109
|
+
wrn = d.get("warns", 0)
|
|
110
|
+
sid = d.get("scan_id", "")
|
|
111
|
+
|
|
112
|
+
tier_badge = {"free": "🆓", "pro": "💎", "premium": "👑"}.get(tier, "❓")
|
|
113
|
+
status_icon = "🔴 BLOCKED" if blk > 0 else ("🟡 WARN" if wrn > 0 else "🟢 CLEAN")
|
|
114
|
+
|
|
115
|
+
print(f"\n{tier_badge} [{tier.upper()}] {fname}")
|
|
116
|
+
print(f" {status_icon} blocks={blk} warns={wrn}")
|
|
117
|
+
|
|
118
|
+
if sid:
|
|
119
|
+
print(f" scan_id: {sid}")
|
|
120
|
+
|
|
121
|
+
# hash proof
|
|
122
|
+
rh = d.get("result_hash")
|
|
123
|
+
if rh:
|
|
124
|
+
bt = d.get("block_types", {})
|
|
125
|
+
payload = f"{sid}|{blk}|{json.dumps(bt, sort_keys=True)}"
|
|
126
|
+
local_hash = hashlib.sha256(payload.encode()).hexdigest()[:32].upper()
|
|
127
|
+
match = "✅" if local_hash == rh else "❌"
|
|
128
|
+
print(f" hash: {rh} {match}")
|
|
129
|
+
|
|
130
|
+
# free tier limits
|
|
131
|
+
if tier == "free":
|
|
132
|
+
rem = d.get("remaining", "?")
|
|
133
|
+
limit = d.get("daily_limit", 50)
|
|
134
|
+
print(f" quota: {rem}/{limit} remaining today")
|
|
135
|
+
|
|
136
|
+
# issues (pro/premium)
|
|
137
|
+
issues = d.get("issues", [])
|
|
138
|
+
if issues:
|
|
139
|
+
print(f"\n {'─'*50}")
|
|
140
|
+
print(f" {'KIND':<22} {'SEVERITY':<8} {'LINE':<6} DETAIL")
|
|
141
|
+
print(f" {'─'*50}")
|
|
142
|
+
for i in issues[:30]:
|
|
143
|
+
kind = i.get("kind", "?")[:22]
|
|
144
|
+
sev = i.get("severity", "?")
|
|
145
|
+
line = str(i.get("line", "?"))
|
|
146
|
+
det = str(i.get("detail", ""))[:60]
|
|
147
|
+
fp_marker = " [FP suppressed]" if i.get("fp_suppressed") else ""
|
|
148
|
+
icon = "🔴" if sev == "BLOCK" else "🟡"
|
|
149
|
+
print(f" {icon} {kind:<22} {sev:<8} {line:<6} {det}{fp_marker}")
|
|
150
|
+
if len(issues) > 30:
|
|
151
|
+
print(f" ... (+{len(issues)-30} more)")
|
|
152
|
+
|
|
153
|
+
# advisory (pro/premium)
|
|
154
|
+
adv = d.get("advisory", {})
|
|
155
|
+
if adv and verbose:
|
|
156
|
+
chains = adv.get("l3_causal_chains", [])
|
|
157
|
+
l1 = adv.get("l1_knowledge", [])
|
|
158
|
+
if chains:
|
|
159
|
+
print(f"\n 🧠 AINA L3 Causal Chains ({len(chains)}):")
|
|
160
|
+
for c in chains:
|
|
161
|
+
prob = int(c.get("probability", 0) * 100)
|
|
162
|
+
depth = c.get("depth", 1)
|
|
163
|
+
indent = " " * depth
|
|
164
|
+
print(f" {indent}[L{depth} p={prob}%] "
|
|
165
|
+
f"{c.get('cause','?')} → {c.get('effect','?')}")
|
|
166
|
+
evid = c.get("evidence", "")
|
|
167
|
+
if evid:
|
|
168
|
+
print(f" {indent} ↳ {evid}")
|
|
169
|
+
if l1:
|
|
170
|
+
print(f"\n 💡 Mitigations:")
|
|
171
|
+
for m in l1:
|
|
172
|
+
print(f" • {m.get('subject','?')}: {m.get('object','?')}")
|
|
173
|
+
|
|
174
|
+
# locked (free)
|
|
175
|
+
locked = d.get("_locked", [])
|
|
176
|
+
if locked:
|
|
177
|
+
print(f"\n 🔒 Locked: {', '.join(locked[:4])}")
|
|
178
|
+
print(f" Upgrade at: https://github.com/Moonsehwan/aina-scan")
|
|
179
|
+
|
|
180
|
+
if blk > 0:
|
|
181
|
+
return 1
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ── commands ─────────────────────────────────────────────────────
|
|
186
|
+
def cmd_config(args: argparse.Namespace) -> int:
|
|
187
|
+
cfg = _load_config()
|
|
188
|
+
if args.key:
|
|
189
|
+
cfg["api_key"] = args.key
|
|
190
|
+
_save_config(cfg)
|
|
191
|
+
print(f"✅ API key saved to {CONFIG_FILE}")
|
|
192
|
+
elif args.show:
|
|
193
|
+
key = cfg.get("api_key", "")
|
|
194
|
+
print(f"API key: {key[:8]}...{key[-4:]}" if len(key) > 12 else f"API key: {key or '(not set)'}")
|
|
195
|
+
elif args.clear:
|
|
196
|
+
cfg.pop("api_key", None)
|
|
197
|
+
_save_config(cfg)
|
|
198
|
+
print("✅ API key cleared")
|
|
199
|
+
return 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _agent_friendly_report(d: dict, path: str) -> int:
|
|
203
|
+
"""--agent-friendly: JSON + Markdown 에이전트 최적화 리포트."""
|
|
204
|
+
blocks = [i for i in d.get("issues", []) if i.get("severity") == "BLOCK"]
|
|
205
|
+
warns = [i for i in d.get("issues", []) if i.get("severity") == "WARN"]
|
|
206
|
+
adv = d.get("advisory", {})
|
|
207
|
+
chains = adv.get("l3_causal_chains", [])
|
|
208
|
+
|
|
209
|
+
chain_by_vuln: dict[str, str] = {}
|
|
210
|
+
for c in chains:
|
|
211
|
+
key_c = c.get("cause", "")
|
|
212
|
+
effect = c.get("effect", "")
|
|
213
|
+
prob = int(c.get("probability", 0) * 100)
|
|
214
|
+
if key_c not in chain_by_vuln:
|
|
215
|
+
chain_by_vuln[key_c] = f"{key_c} → {effect} (p={prob}%)"
|
|
216
|
+
|
|
217
|
+
FIX_HINTS: dict[str, tuple[str, str]] = {
|
|
218
|
+
"COMMAND_INJECTION": ("subprocess.run(cmd, shell=True)", "subprocess.run(cmd.split(), shell=False)"),
|
|
219
|
+
"PATH_TRAVERSAL": ("open(user_path)", "pathlib.Path(user_path).resolve() then open()"),
|
|
220
|
+
"INSECURE_RANDOM": ("random.choices(chars, k=n)", "secrets.choice(chars) for _ in range(n)"),
|
|
221
|
+
"WEAK_CRYPTO": ("hashlib.md5()", "hashlib.sha256()"),
|
|
222
|
+
"HARDCODED_SECRET": ("api_key = 'sk-...'", "api_key = os.environ['API_KEY']"),
|
|
223
|
+
"SQL_INJECTION_RISK": ("f\"SELECT ... WHERE x='{val}'\"", "cursor.execute('SELECT ... WHERE x=?', (val,))"),
|
|
224
|
+
"EVAL_EXEC_RISK": ("eval(code_string)", "ast.literal_eval(code_string) or safe sandbox"),
|
|
225
|
+
"GOD_OBJECT": ("class X: # 50+ methods", "split into focused sub-classes (SRP)"),
|
|
226
|
+
"STUB_SKELETON": ("def fn(): pass", "implement actual logic"),
|
|
227
|
+
"UNIFORM_RETURN": ("all paths return same constant", "return meaningful computed value"),
|
|
228
|
+
"DEEP_NESTING": ("if: if: if: if: ...", "extract helper functions, use early return"),
|
|
229
|
+
"TRIVIAL_IF_CHAIN": ("if x=='a': ... elif x=='b': ...", "use a dict dispatch table or DB lookup"),
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
block_json = []
|
|
233
|
+
for issue in blocks:
|
|
234
|
+
kind = issue.get("kind", "?")
|
|
235
|
+
hint = FIX_HINTS.get(kind, ("?", "see AINA Scan docs"))
|
|
236
|
+
chain_str = next((v for k, v in chain_by_vuln.items() if kind.lower().replace("_", "") in k.lower().replace("_", "")), "")
|
|
237
|
+
entry = {
|
|
238
|
+
"type": kind,
|
|
239
|
+
"severity": "CRITICAL" if kind in ("COMMAND_INJECTION", "PATH_TRAVERSAL", "INSECURE_RANDOM", "SQL_INJECTION_RISK") else "HIGH",
|
|
240
|
+
"file": str(path),
|
|
241
|
+
"line": issue.get("line", "?"),
|
|
242
|
+
"detail": issue.get("detail", ""),
|
|
243
|
+
"before_code": hint[0],
|
|
244
|
+
"after_code": hint[1],
|
|
245
|
+
"verify": f"aina-scan scan {pathlib.Path(path).name} → 0 {kind}",
|
|
246
|
+
}
|
|
247
|
+
if chain_str:
|
|
248
|
+
entry["l3_chain"] = chain_str
|
|
249
|
+
block_json.append(entry)
|
|
250
|
+
|
|
251
|
+
report = {
|
|
252
|
+
"scan_id": d.get("scan_id", ""),
|
|
253
|
+
"file": str(path),
|
|
254
|
+
"tier": d.get("tier", "?"),
|
|
255
|
+
"blocks": d.get("blocks", 0),
|
|
256
|
+
"warns": d.get("warns", 0),
|
|
257
|
+
"result_hash": d.get("result_hash", ""),
|
|
258
|
+
"block_items": block_json,
|
|
259
|
+
"warn_count": len(warns),
|
|
260
|
+
"agent_instruction": (
|
|
261
|
+
"Fix all BLOCK items below in order. "
|
|
262
|
+
"After fixing each file, run: aina-scan scan <file>. "
|
|
263
|
+
"If a BLOCK is intentional, add: # aina-scan: ignore (reason). "
|
|
264
|
+
"Report when all blocks are 0."
|
|
265
|
+
),
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
269
|
+
|
|
270
|
+
print()
|
|
271
|
+
print("---")
|
|
272
|
+
print(f"## AINA Scan Agent Report — {pathlib.Path(path).name}")
|
|
273
|
+
print(f"**Blocks: {len(blocks)}** | Warns: {len(warns)} | Hash: `{d.get('result_hash','')}`")
|
|
274
|
+
print()
|
|
275
|
+
for i, b in enumerate(block_json, 1):
|
|
276
|
+
sev = b["severity"]
|
|
277
|
+
print(f"### [{i}] 🔴 {b['type']} ({sev}) — L{b['line']}")
|
|
278
|
+
print(f"**Detail**: {b['detail']}")
|
|
279
|
+
print()
|
|
280
|
+
print("**Before:**")
|
|
281
|
+
print(f"```python\n{b['before_code']}\n```")
|
|
282
|
+
print("**After:**")
|
|
283
|
+
print(f"```python\n{b['after_code']}\n```")
|
|
284
|
+
if b.get("l3_chain"):
|
|
285
|
+
print(f"**Attack path**: {b['l3_chain']}")
|
|
286
|
+
print(f"**Verify**: `{b['verify']}`")
|
|
287
|
+
print()
|
|
288
|
+
|
|
289
|
+
return 1 if blocks else 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cmd_scan(args: argparse.Namespace) -> int:
|
|
293
|
+
key = _api_key()
|
|
294
|
+
path = pathlib.Path(args.file)
|
|
295
|
+
if not path.exists():
|
|
296
|
+
print(f"❌ File not found: {path}", file=sys.stderr)
|
|
297
|
+
return 1
|
|
298
|
+
data = path.read_bytes()
|
|
299
|
+
print(f"🔍 Scanning {path.name} ({len(data):,} bytes) …")
|
|
300
|
+
d = _scan_file_bytes(path.name, data, key)
|
|
301
|
+
if "error" in d or d.get("detail"):
|
|
302
|
+
print(f"❌ {d.get('error') or d.get('detail')}", file=sys.stderr)
|
|
303
|
+
return 1
|
|
304
|
+
if getattr(args, "report", None):
|
|
305
|
+
pathlib.Path(args.report).write_text(
|
|
306
|
+
json.dumps(d, indent=2, ensure_ascii=False), encoding="utf-8"
|
|
307
|
+
)
|
|
308
|
+
print(f"📄 Report saved → {args.report}")
|
|
309
|
+
if getattr(args, "agent_friendly", False):
|
|
310
|
+
return _agent_friendly_report(d, str(path))
|
|
311
|
+
return _print_result(d, verbose=args.verbose)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def cmd_scan_project(args: argparse.Namespace) -> int:
|
|
315
|
+
key = _api_key()
|
|
316
|
+
project = pathlib.Path(args.directory)
|
|
317
|
+
if not project.is_dir():
|
|
318
|
+
print(f"❌ Directory not found: {project}", file=sys.stderr)
|
|
319
|
+
return 1
|
|
320
|
+
|
|
321
|
+
py_files = list(project.rglob("*.py"))
|
|
322
|
+
if not py_files:
|
|
323
|
+
print("⚠️ No .py files found.")
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
print(f"📦 Zipping {len(py_files)} Python files …")
|
|
327
|
+
buf = io.BytesIO()
|
|
328
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
329
|
+
for f in py_files:
|
|
330
|
+
zf.write(f, f.relative_to(project.parent))
|
|
331
|
+
zip_bytes = buf.getvalue()
|
|
332
|
+
print(f" ZIP size: {len(zip_bytes):,} bytes")
|
|
333
|
+
|
|
334
|
+
boundary = b"----AINAScanBoundary"
|
|
335
|
+
body = (
|
|
336
|
+
b"--" + boundary +
|
|
337
|
+
b"\r\nContent-Disposition: form-data; name=\"file\"; filename=\"project.zip\"\r\n"
|
|
338
|
+
b"Content-Type: application/zip\r\n\r\n" +
|
|
339
|
+
zip_bytes +
|
|
340
|
+
b"\r\n--" + boundary + b"--\r\n"
|
|
341
|
+
)
|
|
342
|
+
req = urllib.request.Request(
|
|
343
|
+
API_BASE + "/v1/scan/project", data=body,
|
|
344
|
+
headers={
|
|
345
|
+
"X-API-Key": key,
|
|
346
|
+
"Content-Type": "multipart/form-data; boundary=----AINAScanBoundary",
|
|
347
|
+
},
|
|
348
|
+
method="POST",
|
|
349
|
+
)
|
|
350
|
+
print("🔬 Running Premium project scan …")
|
|
351
|
+
try:
|
|
352
|
+
with urllib.request.urlopen(req, timeout=60) as r:
|
|
353
|
+
d = json.loads(r.read())
|
|
354
|
+
except urllib.error.HTTPError as e:
|
|
355
|
+
d = json.loads(e.read())
|
|
356
|
+
|
|
357
|
+
if "error" in d or d.get("detail"):
|
|
358
|
+
print(f"❌ {d.get('error') or d.get('detail')}", file=sys.stderr)
|
|
359
|
+
return 1
|
|
360
|
+
|
|
361
|
+
print(f"\n👑 [PREMIUM] Project scan: {project.name}")
|
|
362
|
+
print(f" files={d.get('files_scanned',0)} blocks={d.get('blocks',0)} warns={d.get('warns',0)}")
|
|
363
|
+
taint_flows = d.get("taint_flows", [])
|
|
364
|
+
if taint_flows:
|
|
365
|
+
print(f"\n 🔗 Taint Flows ({len(taint_flows)}):")
|
|
366
|
+
for t in taint_flows[:5]:
|
|
367
|
+
print(f" • {t}")
|
|
368
|
+
return 1 if d.get("blocks", 0) > 0 else 0
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
372
|
+
key = _api_key()
|
|
373
|
+
d = _get("/v1/status", key)
|
|
374
|
+
ok = "✅" if d.get("ok") or d.get("status") == "ok" else "❌"
|
|
375
|
+
print(f"🔍 AINA Scan API {ok}")
|
|
376
|
+
chains = d.get("chains", {})
|
|
377
|
+
print(f" Expert chains : {chains.get('expert', '?')}")
|
|
378
|
+
print(f" Learned chains: {chains.get('learned', '?')}")
|
|
379
|
+
learning = d.get("learning", {})
|
|
380
|
+
print(f" Total scans : {learning.get('total_scans', '?')}")
|
|
381
|
+
print(f" FP reports : {learning.get('fp_reports', '?')}")
|
|
382
|
+
top = d.get("top_vuln_types", learning.get("top_vuln_types", []))
|
|
383
|
+
if top:
|
|
384
|
+
print(f" Top findings:")
|
|
385
|
+
for t in top[:5]:
|
|
386
|
+
print(f" • {t.get('type','?'):<30} {t.get('count',0):>3}x")
|
|
387
|
+
return 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def cmd_slots(args: argparse.Namespace) -> int:
|
|
391
|
+
d = urllib.request.urlopen(urllib.request.Request(API_BASE + "/v1/slots"), timeout=10)
|
|
392
|
+
d = json.loads(d.read())
|
|
393
|
+
eb = d.get("earlybird", {})
|
|
394
|
+
if eb:
|
|
395
|
+
print("🎫 Early Bird Slots")
|
|
396
|
+
for plan, info in eb.items():
|
|
397
|
+
sold = info.get("sold", 0)
|
|
398
|
+
total = info.get("total", 0)
|
|
399
|
+
pct = int(sold / total * 100) if total else 0
|
|
400
|
+
bar = "█" * (pct // 10) + "░" * (10 - pct // 10)
|
|
401
|
+
print(f" {plan.upper():8} [{bar}] {sold}/{total} sold ({pct}%)")
|
|
402
|
+
else:
|
|
403
|
+
print(f" Tier: {d.get('tier','?')} Remaining: {d.get('remaining','?')}")
|
|
404
|
+
return 0
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def cmd_history(args: argparse.Namespace) -> int:
|
|
408
|
+
key = _api_key()
|
|
409
|
+
d = _get(f"/v1/report/history?limit={args.limit}", key)
|
|
410
|
+
history = d.get("history", [])
|
|
411
|
+
if not history:
|
|
412
|
+
print("No scan history.")
|
|
413
|
+
return 0
|
|
414
|
+
print(f"📋 Recent scans ({len(history)}):")
|
|
415
|
+
print(f" {'SCAN ID':<36} {'FILE':<30} {'BLKS':>4} {'WRNS':>4} DATE")
|
|
416
|
+
for h in history:
|
|
417
|
+
print(f" {h.get('scan_id','?'):<36} {h.get('filename','?')[:30]:<30} "
|
|
418
|
+
f"{h.get('blocks',0):>4} {h.get('warns',0):>4} {h.get('scanned_at','?')[:19]}")
|
|
419
|
+
return 0
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def cmd_feedback(args: argparse.Namespace) -> int:
|
|
423
|
+
key = _api_key()
|
|
424
|
+
is_fp = getattr(args, "verdict", None) == "fp" or not getattr(args, "confirm", True)
|
|
425
|
+
scan_id = args.scan_id
|
|
426
|
+
if not scan_id and getattr(args, "file", None):
|
|
427
|
+
hist = _get("/v1/report/history?limit=20", key).get("history", [])
|
|
428
|
+
fname = pathlib.Path(args.file).name
|
|
429
|
+
match = next((h for h in hist if h.get("filename", "").endswith(fname)), None)
|
|
430
|
+
if match:
|
|
431
|
+
scan_id = match.get("scan_id", "")
|
|
432
|
+
print(f" scan_id auto-matched: {scan_id[:16]}… ({fname})")
|
|
433
|
+
body = {
|
|
434
|
+
"scan_id": scan_id or "",
|
|
435
|
+
"vuln_type": args.kind,
|
|
436
|
+
"is_false_positive": is_fp,
|
|
437
|
+
"note": getattr(args, "note", "") or "",
|
|
438
|
+
}
|
|
439
|
+
d = _post_json("/v1/feedback", body, key)
|
|
440
|
+
verdict_str = "FALSE POSITIVE" if is_fp else "TRUE POSITIVE"
|
|
441
|
+
status = d.get("status", "?")
|
|
442
|
+
fp_reg = d.get("fp_registered", False)
|
|
443
|
+
print(f"✅ Feedback recorded: {verdict_str} pattern={args.kind}")
|
|
444
|
+
print(f" status={status} fp_registered={fp_reg}")
|
|
445
|
+
if is_fp and fp_reg:
|
|
446
|
+
fname_matched = d.get("filename", "")
|
|
447
|
+
print(f" → {fname_matched} × {args.kind} → next scan will downgrade BLOCK → WARN")
|
|
448
|
+
elif is_fp and not fp_reg:
|
|
449
|
+
print(f" ⚠️ FP not registered (scan_id not found in history).")
|
|
450
|
+
print(f" Re-scan the file first, then run feedback again.")
|
|
451
|
+
return 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def cmd_stats(args: argparse.Namespace) -> int:
|
|
455
|
+
key = _api_key()
|
|
456
|
+
d = _get("/v1/stats", key)
|
|
457
|
+
print("📊 AINA Scan Pattern Statistics")
|
|
458
|
+
learning = d.get("learning", {})
|
|
459
|
+
print(f" Total scans : {learning.get('total_scans', d.get('total_scans', '?'))}")
|
|
460
|
+
print(f" Total issues: {learning.get('total_findings', d.get('total_issues', '?'))}")
|
|
461
|
+
print(f" FP reports : {learning.get('fp_reports', '?')}")
|
|
462
|
+
print()
|
|
463
|
+
rows_raw = d.get("top_vuln_types", d.get("patterns", d.get("by_pattern", [])))
|
|
464
|
+
if isinstance(rows_raw, dict):
|
|
465
|
+
rows = [(k, v.get("found", v) if isinstance(v, dict) else v, 0) for k, v in rows_raw.items()]
|
|
466
|
+
elif isinstance(rows_raw, list):
|
|
467
|
+
rows = [(p.get("type", p.get("pattern", "?")), p.get("count", 0), p.get("fp_reports", 0)) for p in rows_raw]
|
|
468
|
+
else:
|
|
469
|
+
rows = []
|
|
470
|
+
if rows:
|
|
471
|
+
rows.sort(key=lambda x: x[1], reverse=True)
|
|
472
|
+
max_cnt = max((r[1] for r in rows), default=1)
|
|
473
|
+
print(f" {'PATTERN':<30} {'FOUND':>6} {'FP REPORTS':>10} {'FP%':>5}")
|
|
474
|
+
print(f" {'─'*58}")
|
|
475
|
+
for name, cnt, fp_cnt in rows[:15]:
|
|
476
|
+
fp_pct = f"{fp_cnt/cnt*100:.0f}%" if cnt else "—"
|
|
477
|
+
bar = "█" * min(20, int(cnt / max(1, max_cnt) * 20))
|
|
478
|
+
print(f" {name:<30} {cnt:>6} {fp_cnt:>10} {fp_pct:>5} {bar}")
|
|
479
|
+
|
|
480
|
+
fp_store = d.get("fp_store", {})
|
|
481
|
+
if fp_store:
|
|
482
|
+
print(f"\n 🗂️ Your FP store ({len(fp_store)} entries):")
|
|
483
|
+
for key_str, count in list(fp_store.items())[:10]:
|
|
484
|
+
print(f" • {key_str} (×{count})")
|
|
485
|
+
return 0
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def cmd_docs(args: argparse.Namespace) -> int:
|
|
489
|
+
key = _api_key()
|
|
490
|
+
d = _get("/v1/export/learned", key)
|
|
491
|
+
out = json.dumps(d, indent=2, ensure_ascii=False)
|
|
492
|
+
if args.output:
|
|
493
|
+
pathlib.Path(args.output).write_text(out, encoding="utf-8")
|
|
494
|
+
print(f"✅ Saved to {args.output}")
|
|
495
|
+
else:
|
|
496
|
+
print(out)
|
|
497
|
+
return 0
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ── entry point ──────────────────────────────────────────────────
|
|
501
|
+
def main() -> None:
|
|
502
|
+
p = argparse.ArgumentParser(
|
|
503
|
+
prog="aina-scan",
|
|
504
|
+
description=f"AINA Scan v{__version__} — AI-powered Python security scanner",
|
|
505
|
+
)
|
|
506
|
+
p.add_argument("--version", action="version", version=f"aina-scan {__version__}")
|
|
507
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
508
|
+
|
|
509
|
+
# config
|
|
510
|
+
c = sub.add_parser("config", help="Set or show API key")
|
|
511
|
+
c.add_argument("--key", help="Your AINA Scan API key")
|
|
512
|
+
c.add_argument("--show", action="store_true", help="Show current key")
|
|
513
|
+
c.add_argument("--clear", action="store_true", help="Remove stored key")
|
|
514
|
+
|
|
515
|
+
# scan
|
|
516
|
+
s = sub.add_parser("scan", help="Scan a single Python file")
|
|
517
|
+
s.add_argument("file", help="Path to .py file")
|
|
518
|
+
s.add_argument("-v", "--verbose", action="store_true", help="Show L3 causal chains")
|
|
519
|
+
s.add_argument("--report", metavar="FILE", help="Save full JSON result to file")
|
|
520
|
+
s.add_argument("--agent-friendly", action="store_true",
|
|
521
|
+
help="Output JSON+Markdown with before/after code and fix instructions (for AI agents)")
|
|
522
|
+
|
|
523
|
+
# scan-project (Premium)
|
|
524
|
+
sp = sub.add_parser("scan-project", help="[Premium] Scan entire project directory")
|
|
525
|
+
sp.add_argument("directory", help="Project root directory")
|
|
526
|
+
|
|
527
|
+
# status
|
|
528
|
+
sub.add_parser("status", help="Show API status and chain info")
|
|
529
|
+
|
|
530
|
+
# slots
|
|
531
|
+
sub.add_parser("slots", help="Show early bird slot availability")
|
|
532
|
+
|
|
533
|
+
# history
|
|
534
|
+
h = sub.add_parser("history", help="View your scan history")
|
|
535
|
+
h.add_argument("--limit", type=int, default=20)
|
|
536
|
+
|
|
537
|
+
# feedback
|
|
538
|
+
fb = sub.add_parser("feedback", help="Report false positive / confirm finding")
|
|
539
|
+
fb.add_argument("kind", help="Pattern kind, e.g. STUB_SKELETON")
|
|
540
|
+
fb.add_argument("--scan-id", dest="scan_id", default="", help="Scan ID (optional, auto-looked up from history)")
|
|
541
|
+
fb.add_argument("--file", help="Scanned file path (used for auto scan_id lookup)")
|
|
542
|
+
fb.add_argument("--verdict", choices=["fp", "tp"], default="fp",
|
|
543
|
+
help="fp = false positive (default), tp = true positive")
|
|
544
|
+
fb.add_argument("--confirm", action="store_true", help="Confirm as real TRUE positive")
|
|
545
|
+
fb.add_argument("--note", help="Optional note")
|
|
546
|
+
|
|
547
|
+
# stats
|
|
548
|
+
sub.add_parser("stats", help="[Pro] Show per-pattern FP statistics")
|
|
549
|
+
|
|
550
|
+
# docs / export
|
|
551
|
+
d = sub.add_parser("docs", help="Export learned patterns as JSON")
|
|
552
|
+
d.add_argument("-o", "--output", help="Output file path")
|
|
553
|
+
|
|
554
|
+
args = p.parse_args()
|
|
555
|
+
dispatch = {
|
|
556
|
+
"config": cmd_config,
|
|
557
|
+
"scan": cmd_scan,
|
|
558
|
+
"scan-project": cmd_scan_project,
|
|
559
|
+
"status": cmd_status,
|
|
560
|
+
"slots": cmd_slots,
|
|
561
|
+
"history": cmd_history,
|
|
562
|
+
"feedback": cmd_feedback,
|
|
563
|
+
"stats": cmd_stats,
|
|
564
|
+
"docs": cmd_docs,
|
|
565
|
+
}
|
|
566
|
+
sys.exit(dispatch[args.cmd](args))
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
if __name__ == "__main__":
|
|
570
|
+
main()
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aina-scan
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: AI-powered Python security scanner — 13 vuln types, AINA L3 causal chains, 100% recall
|
|
5
|
+
Project-URL: Homepage, https://github.com/Moonsehwan/aina-scan
|
|
6
|
+
Project-URL: Repository, https://github.com/Moonsehwan/aina-scan
|
|
7
|
+
Project-URL: Issues, https://github.com/Moonsehwan/aina-scan/issues
|
|
8
|
+
Author-email: AINA Sovereign <shanyshany3528@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: linter,python,sast,security,static-analysis,vulnerability
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: Security
|
|
22
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: requests>=2.28.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# aina-scan
|
|
28
|
+
|
|
29
|
+
[](https://pypi.org/project/aina-scan/)
|
|
30
|
+
[](https://pypi.org/project/aina-scan/)
|
|
31
|
+
[](LICENSE)
|
|
32
|
+
|
|
33
|
+
**AST-based security scanner for AI-generated Python code.**
|
|
34
|
+
|
|
35
|
+
> May flag false positives. Never misses a real one.
|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
[한국어](README.ko.md) · [日本語](README.ja.md) · [中文](README.zh.md) · [Español](README.es.md) · [Deutsch](README.de.md)
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Real Findings
|
|
44
|
+
|
|
45
|
+
Scanned top open-source AI coding tools. Found what others missed.
|
|
46
|
+
|
|
47
|
+
**serena** (25K ⭐) — AI coding assistant:
|
|
48
|
+
```
|
|
49
|
+
CRITICAL COMMAND_INJECTION agent.py:1222
|
|
50
|
+
subprocess.Popen(cmd, shell=True)
|
|
51
|
+
Attack path: config_tamper → shell_injection → server_compromise (p=97%)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**aider** (27K ⭐) — AI pair programmer:
|
|
55
|
+
```
|
|
56
|
+
CRITICAL COMMAND_INJECTION commands.py:974
|
|
57
|
+
subprocess.run("git " + user_input, shell=True)
|
|
58
|
+
Attack path: user_input → shell_injection → repo_compromise (p=94%)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**35 true positives. 0 false positives.**
|
|
62
|
+
Missed by Semgrep. Missed by the maintainers.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Install
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pip install aina-scan
|
|
70
|
+
aina-scan config --key YOUR_KEY
|
|
71
|
+
aina-scan scan agent.py
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Get a free API key → **[github.com/Moonsehwan/aina-scan](https://github.com/Moonsehwan/aina-scan)**
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Usage
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Scan a file
|
|
82
|
+
aina-scan scan agent.py
|
|
83
|
+
|
|
84
|
+
# Agent-friendly output — paste into Claude Code to auto-fix
|
|
85
|
+
aina-scan scan agent.py --agent-friendly
|
|
86
|
+
|
|
87
|
+
# Save full JSON report
|
|
88
|
+
aina-scan scan agent.py --report report.json
|
|
89
|
+
|
|
90
|
+
# Verify a fix worked
|
|
91
|
+
aina-scan scan agent.py
|
|
92
|
+
# ✅ 0 blocks found
|
|
93
|
+
|
|
94
|
+
# View scan history
|
|
95
|
+
aina-scan history
|
|
96
|
+
|
|
97
|
+
# Report false positive (auto-suppressed in next scan)
|
|
98
|
+
aina-scan feedback STUB_SKELETON --verdict fp --file agent.py
|
|
99
|
+
|
|
100
|
+
# Pattern statistics
|
|
101
|
+
aina-scan stats
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### `--agent-friendly` output
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"blocks": [{
|
|
109
|
+
"type": "COMMAND_INJECTION",
|
|
110
|
+
"severity": "CRITICAL",
|
|
111
|
+
"file": "agent.py",
|
|
112
|
+
"line": 1222,
|
|
113
|
+
"before_code": "subprocess.Popen(cmd, shell=True)",
|
|
114
|
+
"after_code": "subprocess.Popen(cmd.split(), shell=False)",
|
|
115
|
+
"verify": "aina-scan scan agent.py → 0 COMMAND_INJECTION",
|
|
116
|
+
"l3_chain": "config_tamper → shell_injection → server_compromise (p=97%)"
|
|
117
|
+
}],
|
|
118
|
+
"agent_instruction": "Fix all BLOCK items above. After each fix, verify. Report when all blocks are 0."
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Paste into Claude Code → automated fix loop. No manual steps.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## FP Feedback Loop
|
|
127
|
+
|
|
128
|
+
Report a false positive once → suppressed in all future scans for that file:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
# 1. Scan → BLOCK found
|
|
132
|
+
aina-scan scan token_usage.py
|
|
133
|
+
# 🔴 BLOCKED HARDCODED_SECRET L47
|
|
134
|
+
|
|
135
|
+
# 2. Report as FP (e.g. it's a test fixture, not a real secret)
|
|
136
|
+
aina-scan feedback HARDCODED_SECRET --verdict fp --file token_usage.py
|
|
137
|
+
# ✅ Feedback recorded: FALSE POSITIVE
|
|
138
|
+
# → token_usage.py × HARDCODED_SECRET → next scan will downgrade BLOCK → WARN
|
|
139
|
+
|
|
140
|
+
# 3. Re-scan → BLOCK gone
|
|
141
|
+
aina-scan scan token_usage.py
|
|
142
|
+
# 🟡 WARN HARDCODED_SECRET [FP suppressed]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Per-user learning. Your FP profile stays with your API key.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## What It Detects
|
|
150
|
+
|
|
151
|
+
### Security (13 patterns)
|
|
152
|
+
|
|
153
|
+
| Pattern | Severity |
|
|
154
|
+
|---------|----------|
|
|
155
|
+
| `COMMAND_INJECTION` | CRITICAL |
|
|
156
|
+
| `PATH_TRAVERSAL` | CRITICAL |
|
|
157
|
+
| `SQL_INJECTION_RISK` | CRITICAL |
|
|
158
|
+
| `INSECURE_RANDOM` | CRITICAL |
|
|
159
|
+
| `WEAK_CRYPTO` | HIGH |
|
|
160
|
+
| `HARDCODED_SECRET` | HIGH |
|
|
161
|
+
| `EVAL_EXEC_RISK` | HIGH |
|
|
162
|
+
| `GOD_OBJECT` | HIGH |
|
|
163
|
+
| `BOUNDARY_MISSING` | MEDIUM |
|
|
164
|
+
| `STUB_SKELETON` | MEDIUM |
|
|
165
|
+
| `UNIFORM_RETURN` | MEDIUM |
|
|
166
|
+
| `DEEP_NESTING` | MEDIUM |
|
|
167
|
+
| `TRIVIAL_IF_CHAIN` | MEDIUM |
|
|
168
|
+
|
|
169
|
+
### Code Quality (7 patterns)
|
|
170
|
+
|
|
171
|
+
`DUPLICATE_FUNCTION` · `CIRCULAR_DEPENDENCY` · `N_PLUS_ONE_QUERY` · `MAGIC_NUMBER` · `MUTABLE_DEFAULT` · `EMPTY_EXCEPT` · `SHORT_PASSTHROUGH`
|
|
172
|
+
|
|
173
|
+
### Architecture (6 patterns — Pro)
|
|
174
|
+
|
|
175
|
+
`TAINT_FLOW` · `CROSS_FILE_INJECTION` · `UNSAFE_DESERIALIZATION` · `MISSING_ERROR_HANDLING` · `LOGIC_BOMB` · `RACE_CONDITION`
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
## vs Semgrep vs Claude
|
|
180
|
+
|
|
181
|
+
| | aina-scan | Semgrep (free) | Claude (inline) |
|
|
182
|
+
|--|:---:|:---:|:---:|
|
|
183
|
+
| serena COMMAND_INJECTION | ✅ | ❌ | ❌ |
|
|
184
|
+
| aider COMMAND_INJECTION | ✅ | ❌ | ❌ |
|
|
185
|
+
| gpt-engineer PATH_TRAVERSAL | ✅ | ⚠️ partial | ❌ |
|
|
186
|
+
| Zero dependencies | ✅ | ❌ | ❌ |
|
|
187
|
+
| CI exit code | ✅ | ✅ | ❌ |
|
|
188
|
+
| Causal attack chain | ✅ | ❌ | ❌ |
|
|
189
|
+
| Agent-friendly output | ✅ | ❌ | ❌ |
|
|
190
|
+
| FP feedback loop | ✅ | ❌ | ❌ |
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## GitHub Actions
|
|
195
|
+
|
|
196
|
+
```yaml
|
|
197
|
+
# .github/workflows/aina-scan.yml
|
|
198
|
+
name: AINA Scan Security Check
|
|
199
|
+
|
|
200
|
+
on: [pull_request]
|
|
201
|
+
|
|
202
|
+
jobs:
|
|
203
|
+
scan:
|
|
204
|
+
runs-on: ubuntu-latest
|
|
205
|
+
steps:
|
|
206
|
+
- uses: actions/checkout@v4
|
|
207
|
+
- name: Install aina-scan
|
|
208
|
+
run: pip install aina-scan
|
|
209
|
+
- name: Scan Python files
|
|
210
|
+
env:
|
|
211
|
+
AINA_SCAN_API_KEY: ${{ secrets.AINA_SCAN_KEY }}
|
|
212
|
+
run: |
|
|
213
|
+
find . -name "*.py" | head -20 | while read f; do
|
|
214
|
+
aina-scan scan "$f" || exit 1
|
|
215
|
+
done
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Add `AINA_SCAN_KEY` to **Settings → Secrets → Actions**.
|
|
219
|
+
PR fails automatically if security blocks are found.
|
|
220
|
+
|
|
221
|
+
---
|
|
222
|
+
|
|
223
|
+
## Pricing
|
|
224
|
+
|
|
225
|
+
| | Free | Pro | Premium |
|
|
226
|
+
|--|:---:|:---:|:---:|
|
|
227
|
+
| Price | $0 | $19/mo Early Bird | $99/mo Early Bird |
|
|
228
|
+
| Files/day | 50 | Unlimited | Unlimited |
|
|
229
|
+
| Security patterns | 13 | 13 | 13 |
|
|
230
|
+
| Causal attack chains | ❌ | ✅ | ✅ |
|
|
231
|
+
| Scan history | ❌ | ✅ | ✅ |
|
|
232
|
+
| FP feedback | ❌ | ✅ | ✅ |
|
|
233
|
+
| Project scan | ❌ | ❌ | ✅ |
|
|
234
|
+
| Taint flow analysis | ❌ | ❌ | ✅ |
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## FAQ
|
|
239
|
+
|
|
240
|
+
**Q: Does it send my code to a server?**
|
|
241
|
+
A: Only the scanned file is sent. No code is stored permanently.
|
|
242
|
+
|
|
243
|
+
**Q: False positive rate?**
|
|
244
|
+
A: ~3% on abstract base class patterns. The FP feedback loop (`--verdict fp`) suppresses them per-user immediately.
|
|
245
|
+
|
|
246
|
+
**Q: How is it different from `bandit`?**
|
|
247
|
+
A: bandit uses regex patterns. aina-scan uses AST analysis with causal chain tracing. Bandit missed both serena and aider findings.
|
|
248
|
+
|
|
249
|
+
**Q: Works offline?**
|
|
250
|
+
A: Requires API call. Free tier: 50 files/day.
|
|
251
|
+
|
|
252
|
+
**Q: How does detection work?**
|
|
253
|
+
A: Black-box API. Core logic runs server-side.
|
|
254
|
+
|
|
255
|
+
**Q: Migrating from aina-vibeguard?**
|
|
256
|
+
A: `pip install aina-scan`. Your old `VIBEGUARD_API_KEY` env var still works. Config key is auto-migrated.
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Contact
|
|
261
|
+
|
|
262
|
+
- Issues: [github.com/Moonsehwan/aina-scan/issues](https://github.com/Moonsehwan/aina-scan/issues)
|
|
263
|
+
- Email: shanyshany3528@gmail.com
|
|
264
|
+
|
|
265
|
+
---
|
|
266
|
+
|
|
267
|
+
MIT License · CLI source only · Core engine proprietary (server-side)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
aina_scan/__init__.py,sha256=j9fbsORSj8l66lV2-F9w83LFMl1LEvkzjJlPekYLluI,193
|
|
2
|
+
aina_scan/cli.py,sha256=S_AgGYN67pbWfVEKOg2Tk5Yh5OzROt65wq1tUXBF9F4,22712
|
|
3
|
+
aina_scan-2.0.0.dist-info/METADATA,sha256=TNrNsQxLyknQ_3-f9OKgrqDMMAq71lUvuAbbUEM4gAI,7610
|
|
4
|
+
aina_scan-2.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
aina_scan-2.0.0.dist-info/entry_points.txt,sha256=NFYn5yYQHSSfSwnXkR8gdyUZU01vxeLJf7AJW3YZKUo,49
|
|
6
|
+
aina_scan-2.0.0.dist-info/licenses/LICENSE,sha256=xmTuwXUr3LNKNz0ctTLq9SrJsJfMcbqNUNZ0E_oxmB0,1071
|
|
7
|
+
aina_scan-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AINA Sovereign
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|