aisec-suite 0.2.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.
aisec/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """aisec-suite: unified runner and SARIF exporter for mcpaudit, memsentry and ragsentry."""
2
+ __version__ = "0.2.0"
aisec/adapters.py ADDED
@@ -0,0 +1,108 @@
1
+ """Adapters that run each scanner and normalise its findings. Scanners are imported lazily; a missing
2
+ scanner is reported and skipped rather than crashing the run."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import pathlib
8
+
9
+ from aisec.extract import extract_tools
10
+ from aisec.model import Finding
11
+
12
+ CONTEXT_NAMES = {"CLAUDE.md", "AGENTS.md", "GEMINI.md", ".cursorrules", ".windsurfrules", ".clinerules", "copilot-instructions.md"}
13
+ _SKIP = {"node_modules", ".git", "venv", ".venv"}
14
+ _CHUNK = 50 # large manifests scale super-linearly in mcpaudit's cross-tool check
15
+
16
+
17
+ def _rel(p: pathlib.Path, root: pathlib.Path) -> str:
18
+ try:
19
+ return str(p.relative_to(root))
20
+ except ValueError:
21
+ return str(p)
22
+
23
+
24
+ def _walk(root: pathlib.Path):
25
+ """Yield regular files under root, pruning vendored dirs *before* descending and never following symlinks
26
+ (a repo under scan is untrusted input: a symlink must not make us read files outside it)."""
27
+ for dirpath, dirs, files in os.walk(root, followlinks=False):
28
+ dirs[:] = [d for d in dirs if d not in _SKIP]
29
+ for name in files:
30
+ p = pathlib.Path(dirpath, name)
31
+ if not p.is_symlink():
32
+ yield p
33
+
34
+
35
+ def scan_mcp_source(root: pathlib.Path, include_tests: bool = False, warnings: list[str] | None = None) -> tuple[list[Finding], int]:
36
+ """Statically extract tools from source, run mcpaudit's static checks. Returns (findings, tools_found)."""
37
+ try:
38
+ from mcpaudit.checks.description_scan import scan_descriptions
39
+ from mcpaudit.checks.permission_scope import check_scope
40
+ from mcpaudit.manifest import ServerManifest, ToolManifest
41
+ except ImportError:
42
+ if warnings is not None:
43
+ warnings.append("mcpaudit not installed: skipped MCP tool checks")
44
+ return [], 0
45
+ tools = extract_tools(root, include_tests)
46
+ findings: list[Finding] = []
47
+ for i in range(0, len(tools), _CHUNK):
48
+ part = tools[i:i + _CHUNK]
49
+ manifest = ServerManifest(server_name=root.name, tools=[ToolManifest(t["name"], t["description"], t["input_schema"]) for t in part])
50
+ by_name = {t["name"]: t for t in part}
51
+ for f in scan_descriptions(manifest) + check_scope(manifest):
52
+ src = by_name.get(f.tool, {})
53
+ findings.append(Finding("mcpaudit", f"{f.check}: {f.title}", f.severity.value, f.detail, src.get("file", "."), src.get("line", 1)))
54
+ return findings, len(tools)
55
+
56
+
57
+ def scan_manifest_files(root: pathlib.Path, warnings: list[str] | None = None) -> list[Finding]:
58
+ """Scan *.json files shaped like {"server_name":..., "tools":[...]} (e.g. an mcpaudit baseline)."""
59
+ try:
60
+ from mcpaudit.checks.description_scan import scan_descriptions
61
+ from mcpaudit.checks.permission_scope import check_scope
62
+ from mcpaudit.manifest import ServerManifest
63
+ except ImportError:
64
+ return []
65
+ out: list[Finding] = []
66
+ for p in _walk(root):
67
+ if p.suffix != ".json" or p.stat().st_size > 5_000_000:
68
+ continue
69
+ try:
70
+ d = json.loads(p.read_text(encoding="utf-8", errors="replace"))
71
+ if not (isinstance(d, dict) and "server_name" in d and isinstance(d.get("tools"), list)):
72
+ continue
73
+ m = ServerManifest.from_dict(d)
74
+ except Exception:
75
+ continue
76
+ for f in scan_descriptions(m) + check_scope(m):
77
+ out.append(Finding("mcpaudit", f"{f.check}: {f.title}", f.severity.value, f.detail, _rel(p, root), 1))
78
+ return out
79
+
80
+
81
+ def scan_context_files(root: pathlib.Path, warnings: list[str] | None = None) -> list[Finding]:
82
+ try:
83
+ from memsentry.memfile import MemoryFile
84
+ from memsentry.scanner import scan_file
85
+ except ImportError:
86
+ if warnings is not None:
87
+ warnings.append("memsentry not installed: skipped agent-context file checks")
88
+ return []
89
+ out: list[Finding] = []
90
+ for p in _walk(root):
91
+ if p.name in CONTEXT_NAMES or (p.parent.name == "rules" and p.parent.parent.name == ".cursor"):
92
+ for f in scan_file(MemoryFile.load(p)):
93
+ out.append(Finding("memsentry", f"{f.check}: {f.title}", f.severity.value, f.detail, _rel(p, root), f.line))
94
+ return out
95
+
96
+
97
+ def scan_rag_dir(rag_dir: pathlib.Path, root: pathlib.Path, warnings: list[str] | None = None) -> list[Finding]:
98
+ try:
99
+ from ragsentry.scanner import scan_path
100
+ except ImportError:
101
+ if warnings is not None:
102
+ warnings.append("ragsentry not installed: skipped RAG document checks")
103
+ return []
104
+ out: list[Finding] = []
105
+ for fname, fs in scan_path(str(rag_dir)).items():
106
+ for f in fs:
107
+ out.append(Finding("ragsentry", f"{f.check}: {f.title}", f.severity.value, f.detail, _rel(pathlib.Path(fname), root), f.line))
108
+ return out
aisec/cli.py ADDED
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ import collections
4
+ import json
5
+ import os
6
+ import pathlib
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from aisec import __version__
12
+ from aisec.adapters import scan_context_files, scan_manifest_files, scan_mcp_source, scan_rag_dir
13
+ from aisec.model import SEVERITY_ORDER, at_least
14
+ from aisec.sarif import to_sarif
15
+ from aisec.summary import to_markdown
16
+
17
+ app = typer.Typer(name="aisec", help="Run mcpaudit, memsentry and ragsentry on a repository in one command.", no_args_is_help=True)
18
+
19
+
20
+ @app.callback()
21
+ def _main(version: bool = typer.Option(False, "--version", is_eager=True)) -> None:
22
+ if version:
23
+ typer.echo(__version__)
24
+ raise typer.Exit()
25
+
26
+
27
+ @app.command()
28
+ def scan(
29
+ path: pathlib.Path = typer.Argument(..., exists=True, file_okay=False, help="Repository root to scan."),
30
+ rag: Optional[pathlib.Path] = typer.Option(None, "--rag", help="Directory of RAG source documents to scan with ragsentry."),
31
+ sarif: Optional[pathlib.Path] = typer.Option(None, "--sarif", help="Write SARIF 2.1.0 here (for GitHub code scanning)."),
32
+ json_out: Optional[pathlib.Path] = typer.Option(None, "--json", help="Write findings as JSON here."),
33
+ fail_on: Optional[str] = typer.Option(None, "--fail-on", help="Exit 1 if any finding is at or above: low|medium|high|critical."),
34
+ include_tests: bool = typer.Option(False, "--include-tests", help="Also extract tools from tests/examples/fixtures."),
35
+ baseline: Optional[pathlib.Path] = typer.Option(None, "--baseline", help="JSON file of accepted findings; they are hidden and never fail the run."),
36
+ write_baseline: Optional[pathlib.Path] = typer.Option(None, "--write-baseline", help="Write current findings as a baseline (accept everything seen today)."),
37
+ summary: Optional[pathlib.Path] = typer.Option(None, "--summary", help="Write a Markdown summary here (defaults to $GITHUB_STEP_SUMMARY when set)."),
38
+ ) -> None:
39
+ if fail_on and fail_on not in SEVERITY_ORDER:
40
+ raise typer.BadParameter("must be one of low|medium|high|critical")
41
+ root = path.resolve()
42
+ warnings: list[str] = []
43
+ crashed: list[str] = []
44
+
45
+ def guarded(label, fn, default, *args):
46
+ """A scanner bug must not masquerade as 'findings exist' (exit 1) nor as a clean pass."""
47
+ try:
48
+ return fn(*args)
49
+ except Exception as e: # noqa: BLE001 - third-party scanners; report, don't die
50
+ crashed.append(label)
51
+ warnings.append(f"{label} crashed ({type(e).__name__}: {e}); its results are missing")
52
+ return default
53
+
54
+ findings, n_tools = guarded("mcpaudit (source)", scan_mcp_source, ([], 0), root, include_tests, warnings)
55
+ findings = list(findings)
56
+ findings += guarded("mcpaudit (manifests)", scan_manifest_files, [], root, warnings)
57
+ findings += guarded("memsentry", scan_context_files, [], root, warnings)
58
+ if rag:
59
+ findings += guarded("ragsentry", scan_rag_dir, [], rag.resolve(), root, warnings)
60
+ findings.sort(key=lambda f: (-SEVERITY_ORDER[f.severity], f.file, f.line))
61
+
62
+ if write_baseline:
63
+ write_baseline.write_text(json.dumps(sorted({f.fingerprint for f in findings}), indent=2))
64
+ typer.echo(f"baseline written: {len(findings)} findings accepted -> {write_baseline}")
65
+ suppressed = 0
66
+ if baseline:
67
+ try:
68
+ accepted = set(json.loads(baseline.read_text()))
69
+ except (OSError, ValueError) as e:
70
+ raise typer.BadParameter(f"cannot read baseline: {e}")
71
+ kept = [f for f in findings if f.fingerprint not in accepted]
72
+ suppressed, findings = len(findings) - len(kept), kept
73
+
74
+ if sarif:
75
+ sarif.write_text(json.dumps(to_sarif(findings), indent=2), encoding="utf-8")
76
+ if json_out:
77
+ json_out.write_text(json.dumps([f.to_dict() for f in findings], indent=2), encoding="utf-8")
78
+
79
+ summary = summary or (pathlib.Path(os.environ["GITHUB_STEP_SUMMARY"]) if os.environ.get("GITHUB_STEP_SUMMARY") else None)
80
+ if summary:
81
+ with summary.open("a", encoding="utf-8") as fh:
82
+ fh.write(to_markdown(findings, n_tools, warnings, suppressed))
83
+
84
+ for w in warnings:
85
+ typer.echo(f"warning: {w}", err=True)
86
+ by_sev = collections.Counter(f.severity for f in findings)
87
+ typer.echo(f"tools extracted: {n_tools} | findings: {len(findings)} "
88
+ f"(critical {by_sev['critical']}, high {by_sev['high']}, medium {by_sev['medium']}, low {by_sev['low']})"
89
+ + (f" | baseline-suppressed: {suppressed}" if suppressed else ""))
90
+ if n_tools == 0:
91
+ typer.echo("note: no MCP tools were found statically; that means 'unknown', not 'safe'.")
92
+ for f in findings[:25]:
93
+ typer.echo(f" [{f.severity:8}] {f.file}:{f.line} {f.scanner} {f.rule}")
94
+ if len(findings) > 25:
95
+ typer.echo(f" ... and {len(findings) - 25} more (use --json or --sarif for all)")
96
+ if fail_on and crashed and not any(at_least(f.severity, fail_on) for f in findings):
97
+ typer.echo(f"error: {len(crashed)} scanner(s) crashed, so a clean result can't be trusted (exit 2)", err=True)
98
+ raise typer.Exit(2)
99
+ if fail_on and any(at_least(f.severity, fail_on) for f in findings):
100
+ raise typer.Exit(1)
aisec/extract.py ADDED
@@ -0,0 +1,180 @@
1
+ """Statically extract MCP tool definitions (name, description, input schema) from source code.
2
+
3
+ No code is executed. Supports Python FastMCP-style decorators (via ast) and TypeScript/JavaScript
4
+ registerTool()/addTool()/server.tool() calls with zod or JSON schemas, plus generic
5
+ {name, description, inputSchema} object literals. Best-effort: a 90-repo study found about 60% of
6
+ repos yielded tools, so treat "no tools found" as "unknown", not "safe".
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import pathlib
12
+ import re
13
+
14
+ _ALWAYS_SKIP = {"node_modules", ".git", "dist", "build", "venv", ".venv", "__pycache__", "docs"}
15
+ _TEST_DIRS = {"tests", "test", "__tests__", "examples", "example", "e2e", "bench", "fixtures", "scripts"}
16
+ _TEST_FILE = re.compile(r"\.(spec|test)\.[jt]sx?$")
17
+ _STR = r'(?:"((?:[^"\\]|\\.)*)"|\'((?:[^\'\\]|\\.)*)\'|`((?:[^`\\]|\\.)*)`)'
18
+ _ANCHOR = re.compile(r"(?:registerTool|addTool|defineTool|definePageTool|\.tool)\s*\(\s*")
19
+
20
+
21
+ def _lit(m):
22
+ return next(g for g in m.groups() if g is not None)
23
+
24
+
25
+ def _files(root: pathlib.Path, exts, include_tests: bool):
26
+ skip = set(_ALWAYS_SKIP) | (set() if include_tests else _TEST_DIRS)
27
+ for p in root.rglob("*"):
28
+ if p.suffix in exts and p.is_file() and p.stat().st_size < 400_000:
29
+ rel = p.relative_to(root)
30
+ if set(rel.parts[:-1]) & skip:
31
+ continue
32
+ if not include_tests and _TEST_FILE.search(p.name):
33
+ continue
34
+ yield p
35
+
36
+
37
+ def _py_tools(path: pathlib.Path) -> list[dict]:
38
+ try:
39
+ tree = ast.parse(path.read_text(errors="ignore"))
40
+ except Exception:
41
+ return []
42
+ out = []
43
+ for fn in ast.walk(tree):
44
+ if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
45
+ continue
46
+ dec = None
47
+ for d in fn.decorator_list:
48
+ base = d.func if isinstance(d, ast.Call) else d
49
+ if (isinstance(base, ast.Attribute) and base.attr == "tool") or (isinstance(base, ast.Name) and base.id == "tool"):
50
+ dec = d
51
+ break
52
+ if dec is None:
53
+ continue
54
+ name, desc = fn.name, ast.get_docstring(fn) or ""
55
+ if isinstance(dec, ast.Call):
56
+ for kw in dec.keywords:
57
+ if kw.arg == "name" and isinstance(kw.value, ast.Constant):
58
+ name = str(kw.value.value)
59
+ if kw.arg == "description" and isinstance(kw.value, ast.Constant):
60
+ desc = str(kw.value.value)
61
+ if dec.args and isinstance(dec.args[0], ast.Constant) and isinstance(dec.args[0].value, str):
62
+ name = dec.args[0].value
63
+ props = {}
64
+ for a in fn.args.args + fn.args.kwonlyargs:
65
+ if a.arg in ("self", "cls", "ctx", "context"):
66
+ continue
67
+ ann = ast.unparse(a.annotation) if a.annotation else ""
68
+ if "Context" in ann:
69
+ continue
70
+ s: dict = {"type": "string"}
71
+ if ann.startswith("Literal["):
72
+ s["enum"] = ["?"]
73
+ elif re.search(r"\bint\b", ann):
74
+ s = {"type": "integer"}
75
+ elif re.search(r"\bfloat\b", ann):
76
+ s = {"type": "number"}
77
+ elif re.search(r"\bbool\b", ann):
78
+ s = {"type": "boolean"}
79
+ elif re.search(r"\b(list|List)\b", ann):
80
+ s = {"type": "array"}
81
+ elif re.search(r"\b(dict|Dict)\b", ann) or (ann and not re.search(r"\bstr\b", ann)):
82
+ s = {"type": "object"}
83
+ props[a.arg] = s
84
+ out.append(dict(name=name, description=desc, input_schema={"type": "object", "properties": props}, line=fn.lineno))
85
+ return out
86
+
87
+
88
+ def _zod_props(block: str) -> dict:
89
+ props = {}
90
+ for m in re.finditer(r"(\w+)\s*:\s*(?:z|zod)\s*\.\s*(string|number|boolean|enum|array|object|any|union|record)\s*\(([^)]*)\)([^,\n]*(?:\n\s*\.[^,\n]*)*)", block):
91
+ key, kind, _arg, chain = m.groups()
92
+ s = {"type": {"string": "string", "number": "number", "boolean": "boolean", "array": "array", "enum": "string"}.get(kind, "object")}
93
+ if kind == "enum":
94
+ s["enum"] = ["?"]
95
+ if re.search(r"\.regex\(|\.startsWith\(|\.endsWith\(|\.includes\(", chain):
96
+ s["pattern"] = "?"
97
+ if re.search(r"\.max\(|\.length\(", chain) and kind == "string":
98
+ s["maxLength"] = 1
99
+ if re.search(r"\.(url|email|uuid|datetime)\(", chain):
100
+ s["format"] = "?"
101
+ props[key] = s
102
+ return props
103
+
104
+
105
+ def _json_props(block: str) -> dict:
106
+ props = {}
107
+ for pm in re.finditer(r"(\w+)\s*:\s*\{\s*type\s*:\s*['\"](\w+)['\"]([^{}]*)\}", block):
108
+ k, ty, tail = pm.groups()
109
+ sc = {"type": ty}
110
+ for f in ("enum", "pattern", "format", "maxLength"):
111
+ if re.search(r"\b%s\s*:" % f, tail):
112
+ sc[f] = "?"
113
+ props[k] = sc
114
+ return props
115
+
116
+
117
+ def _collect_consts(text: str, consts: dict) -> None:
118
+ for m in re.finditer(r"(?:const|let|var)\s+([A-Za-z_]\w*)\s*=\s*" + _STR + r"\s*(?:as const)?\s*[;\n]", text):
119
+ consts[m.group(1)] = next(g for g in m.groups()[1:] if g is not None)
120
+
121
+
122
+ def _ts_tools(path: pathlib.Path, consts: dict) -> list[dict]:
123
+ t = path.read_text(errors="ignore")
124
+ out = []
125
+ anchors = list(_ANCHOR.finditer(t))
126
+ for i, m in enumerate(anchors):
127
+ end = anchors[i + 1].start() if i + 1 < len(anchors) else len(t)
128
+ win = t[m.end(): min(end, m.end() + 4500)]
129
+ name = None
130
+ if win.startswith("{"):
131
+ nm = re.search(r"\bname\s*:\s*(?:" + _STR + r"|([A-Za-z_]\w*))", win[:600])
132
+ else:
133
+ nm = re.match(r"(?:" + _STR + r"|([A-Za-z_][\w.]*))\s*,", win)
134
+ if nm:
135
+ name = next((g for g in nm.groups()[:3] if g is not None), None) or consts.get(nm.group(4))
136
+ if not name:
137
+ continue
138
+ desc = None
139
+ dm = None if win.startswith("{") else re.match(r"(?:" + _STR + r")\s*,\s*" + _STR, win)
140
+ if dm:
141
+ desc = next(g for g in dm.groups()[3:] if g is not None)
142
+ else:
143
+ dd = re.search(r"description\s*:\s*" + _STR, win)
144
+ if dd:
145
+ desc = _lit(dd)
146
+ if desc is None:
147
+ continue
148
+ props = _zod_props(win)
149
+ props.update({k: v for k, v in _json_props(win).items() if k not in props})
150
+ out.append(dict(name=name, description=desc, input_schema={"type": "object", "properties": props}, line=t.count("\n", 0, m.start()) + 1))
151
+ return out
152
+
153
+
154
+ def extract_tools(root: str | pathlib.Path, include_tests: bool = False) -> list[dict]:
155
+ """Return de-duplicated tool dicts: name, description, input_schema, file (relative), line."""
156
+ root = pathlib.Path(root)
157
+ tools: list[dict] = []
158
+ for p in _files(root, {".py"}, include_tests):
159
+ for t in _py_tools(p):
160
+ tools.append({**t, "file": str(p.relative_to(root))})
161
+ ts = list(_files(root, {".ts", ".js", ".mjs", ".tsx"}, include_tests))
162
+ consts: dict = {}
163
+ for p in ts:
164
+ try:
165
+ _collect_consts(p.read_text(errors="ignore"), consts)
166
+ except Exception:
167
+ pass
168
+ for p in ts:
169
+ try:
170
+ for t in _ts_tools(p, consts):
171
+ tools.append({**t, "file": str(p.relative_to(root))})
172
+ except Exception:
173
+ pass
174
+ seen, uniq = set(), []
175
+ for t in tools:
176
+ k = (t["name"], t["description"][:60])
177
+ if k not in seen:
178
+ seen.add(k)
179
+ uniq.append(t)
180
+ return uniq
aisec/model.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ from dataclasses import dataclass, asdict
5
+
6
+ SEVERITY_ORDER = {"low": 0, "medium": 1, "high": 2, "critical": 3}
7
+
8
+
9
+ @dataclass
10
+ class Finding:
11
+ scanner: str # mcpaudit | memsentry | ragsentry
12
+ rule: str # e.g. "permission_scope: Unscoped filesystem access"
13
+ severity: str # low | medium | high | critical
14
+ message: str
15
+ file: str # path relative to the scan root
16
+ line: int = 1
17
+
18
+ @property
19
+ def fingerprint(self) -> str:
20
+ """Stable across line shifts, so baselines survive unrelated edits."""
21
+ raw = "\x1f".join((self.scanner, self.rule, self.file.replace("\\", "/"), self.message))
22
+ return hashlib.sha256(raw.encode()).hexdigest()[:32]
23
+
24
+ def to_dict(self) -> dict:
25
+ return {**asdict(self), "fingerprint": self.fingerprint}
26
+
27
+
28
+ def at_least(severity: str, threshold: str) -> bool:
29
+ return SEVERITY_ORDER[severity] >= SEVERITY_ORDER[threshold]
aisec/sarif.py ADDED
@@ -0,0 +1,44 @@
1
+ """Minimal SARIF 2.1.0 writer so findings show up in GitHub code scanning."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ from aisec import __version__
7
+ from aisec.model import Finding
8
+
9
+ _LEVEL = {"critical": "error", "high": "error", "medium": "warning", "low": "note"}
10
+ # GitHub ranks code-scanning alerts by this CVSS-like number (>=9 critical, 7-8.9 high, 4-6.9 medium, <4 low).
11
+ _SECURITY_SEVERITY = {"critical": "9.5", "high": "8.0", "medium": "5.5", "low": "2.0"}
12
+ _HELP = {"mcpaudit": "https://github.com/Pyhroff/mcpaudit", "memsentry": "https://github.com/Pyhroff/memsentry",
13
+ "ragsentry": "https://github.com/Pyhroff/ragsentry"}
14
+
15
+
16
+ def _rule_id(f: Finding) -> str:
17
+ return f"{f.scanner}/" + re.sub(r"[^A-Za-z0-9_.-]+", "-", f.rule).strip("-").lower()
18
+
19
+
20
+ def to_sarif(findings: list[Finding]) -> dict:
21
+ rules: dict[str, dict] = {}
22
+ results = []
23
+ for f in findings:
24
+ rid = _rule_id(f)
25
+ rules.setdefault(rid, {"id": rid, "name": rid, "shortDescription": {"text": f.rule},
26
+ "helpUri": _HELP.get(f.scanner, "https://github.com/Pyhroff/aisec-suite"),
27
+ "properties": {"scanner": f.scanner, "security-severity": _SECURITY_SEVERITY[f.severity],
28
+ "tags": ["security", "ai-security", f.scanner]}})
29
+ results.append({
30
+ "ruleId": rid,
31
+ "level": _LEVEL[f.severity],
32
+ "message": {"text": f.message},
33
+ "locations": [{"physicalLocation": {"artifactLocation": {"uri": f.file.replace("\\", "/")},
34
+ "region": {"startLine": max(1, f.line)}}}],
35
+ "partialFingerprints": {"aisec/v1": f.fingerprint},
36
+ "properties": {"severity": f.severity, "scanner": f.scanner},
37
+ })
38
+ return {
39
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
40
+ "version": "2.1.0",
41
+ "runs": [{"tool": {"driver": {"name": "aisec-suite", "version": __version__,
42
+ "informationUri": "https://github.com/Pyhroff", "rules": list(rules.values())}},
43
+ "results": results}],
44
+ }
aisec/summary.py ADDED
@@ -0,0 +1,31 @@
1
+ """Markdown job summary (GitHub renders $GITHUB_STEP_SUMMARY on the run page)."""
2
+ from __future__ import annotations
3
+
4
+ import collections
5
+
6
+ from aisec.model import Finding
7
+
8
+ _ICON = {"critical": "🟥", "high": "🟧", "medium": "🟨", "low": "⬜"}
9
+
10
+
11
+ def _cell(text: str) -> str:
12
+ return text.replace("|", "\\|").replace("\n", " ")[:160]
13
+
14
+
15
+ def to_markdown(findings: list[Finding], n_tools: int, warnings: list[str], suppressed: int = 0) -> str:
16
+ by = collections.Counter(f.severity for f in findings)
17
+ out = ["## aisec-suite scan\n",
18
+ f"**{len(findings)} finding(s)** · critical {by['critical']} · high {by['high']} · "
19
+ f"medium {by['medium']} · low {by['low']} · MCP tools extracted: {n_tools}"
20
+ + (f" · {suppressed} accepted via baseline" if suppressed else "") + "\n"]
21
+ if n_tools == 0:
22
+ out.append("> No MCP tools were found statically. That means *unknown*, not *safe*.\n")
23
+ for w in warnings:
24
+ out.append(f"> ⚠️ {_cell(w)}\n")
25
+ if findings:
26
+ out.append("| Severity | Scanner | Rule | Location |\n|---|---|---|---|")
27
+ for f in findings[:50]:
28
+ out.append(f"| {_ICON[f.severity]} {f.severity} | {f.scanner} | {_cell(f.rule)} | `{_cell(f.file)}:{f.line}` |")
29
+ if len(findings) > 50:
30
+ out.append(f"\n…and {len(findings) - 50} more in the SARIF/JSON output.")
31
+ return "\n".join(out) + "\n"
@@ -0,0 +1,86 @@
1
+ Metadata-Version: 2.4
2
+ Name: aisec-suite
3
+ Version: 0.2.0
4
+ Summary: One command that runs mcpaudit, memsentry and ragsentry on a repo and emits SARIF for GitHub code scanning.
5
+ Author: Pyhroff
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Pyhroff/aisec-suite
8
+ Project-URL: Issues, https://github.com/Pyhroff/aisec-suite/issues
9
+ Project-URL: Changelog, https://github.com/Pyhroff/aisec-suite/blob/main/CHANGELOG.md
10
+ Keywords: MCP,prompt injection,SARIF,AI security,LLM agents,code scanning
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: typer>=0.12
23
+ Provides-Extra: scanners
24
+ Requires-Dist: pyhroff-mcpaudit>=0.7; extra == "scanners"
25
+ Requires-Dist: memsentry>=1.2; extra == "scanners"
26
+ Requires-Dist: pyhroff-ragsentry>=1.1; extra == "scanners"
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8; extra == "dev"
29
+ Requires-Dist: ruff>=0.5; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # aisec-suite
33
+
34
+ [![tests](https://github.com/Pyhroff/aisec-suite/actions/workflows/tests.yml/badge.svg)](https://github.com/Pyhroff/aisec-suite/actions/workflows/tests.yml) ![python](https://img.shields.io/badge/python-3.10%E2%80%933.12-blue) ![license](https://img.shields.io/badge/license-MIT-green)
35
+
36
+ One command that runs three AI-security scanners on a repository and writes SARIF for GitHub code scanning.
37
+
38
+ | Scanner | What it checks | Input found automatically |
39
+ |---|---|---|
40
+ | [mcpaudit](https://github.com/Pyhroff/mcpaudit) | MCP tool poisoning and over-broad tool scope (static checks) | Tool definitions **extracted statically from source** (Python FastMCP decorators, TS/JS `registerTool`/`addTool`/`server.tool`), plus manifest `*.json` files |
41
+ | [memsentry](https://github.com/Pyhroff/memsentry) | Injected instructions and hidden payloads in agent context files | `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`, `.cursorrules`, `.windsurfrules`, `.clinerules`, `.cursor/rules/*`, `copilot-instructions.md` |
42
+ | [ragsentry](https://github.com/Pyhroff/ragsentry) | Injection and retrieval manipulation in RAG source documents | A directory you pass with `--rag` |
43
+
44
+ No third-party code is executed: tool definitions are read from source, not by launching the server.
45
+
46
+ ```bash
47
+ pip install "aisec-suite[scanners]" # also installs pyhroff-mcpaudit, memsentry, pyhroff-ragsentry from PyPI
48
+ aisec scan . --sarif aisec.sarif --fail-on high
49
+ aisec scan . --rag ./docs --json findings.json
50
+ ```
51
+
52
+ Exit codes: `0` clean, `1` a finding at or above `--fail-on`, `2` a scanner crashed (so a clean result can't be trusted; only with `--fail-on`). Use `--include-tests` to also extract tools from tests/examples/fixtures.
53
+
54
+ ### Adopting it on an existing repo without a wall of red
55
+ ```bash
56
+ aisec scan . --write-baseline .aisec-baseline.json # accept what exists today
57
+ aisec scan . --baseline .aisec-baseline.json --fail-on high # CI now fails only on NEW findings
58
+ ```
59
+ Fingerprints ignore line numbers, so moving code around does not resurface accepted findings. Every run also writes a Markdown table to the GitHub job summary (`--summary`).
60
+
61
+ ## GitHub Action
62
+ One line, no install step; the scanners come from PyPI:
63
+
64
+ ```yaml
65
+ permissions:
66
+ contents: read
67
+ security-events: write
68
+ steps:
69
+ - uses: actions/checkout@v4
70
+ - uses: Pyhroff/aisec-suite@v0.2.0
71
+ with:
72
+ fail-on: high
73
+ ```
74
+
75
+ Inputs: `path`, `fail-on`, `rag-dir`, `baseline`, `install`, `upload-sarif`. Needs `security-events: write` for the upload step. Inputs reach the shell only through quoted env vars, never interpolated into script text.
76
+
77
+ (The action itself has not yet been run on GitHub Actions; the CLI and adapters are tested, including with fake scanners in CI.)
78
+
79
+ ## Honest scope
80
+ - Static extraction is best-effort. In a study of 90 public MCP repos it found tools in 53 (about 59%); "no tools found" prints a note and means *unknown*, not *safe*.
81
+ - Findings are heuristics for human review. In the same study only 37.5% (95% CI 24-53%) of mcpaudit v0.6's HIGH `permission_scope` findings were accurate, and none of the flagged repos had genuine tool poisoning. Use the patched scanners (mcpaudit 0.7 / memsentry 1.2) for fewer false positives; see `mcp-scan-study/REPORT.md`.
82
+ - mcpaudit's dynamic (live LLM) and rug-pull checks are not part of this suite; use mcpaudit directly for those.
83
+ - Line numbers for extracted tools point at the registration call, not the description text.
84
+
85
+ ## Development
86
+ `pip install -e ".[dev]" && pytest -q` (adapter tests use in-memory fake scanners and always run; a few end-to-end tests skip unless the real scanners are installed).
@@ -0,0 +1,13 @@
1
+ aisec/__init__.py,sha256=1bTDEiRxMMrBBaR-p0h_ZlWVcSTcBtsZskz9xa-3GvI,114
2
+ aisec/adapters.py,sha256=GV_H5GY_QTEBSsdR65sXkArjFUixSu0WzT4shY52Y1g,4851
3
+ aisec/cli.py,sha256=F8saZv7V9_JQtNQHY4KBTa-kGIMHcq3v8XFoaRhE-VE,5216
4
+ aisec/extract.py,sha256=M-vj6QhoGSAAed2b6gMsFWv6ueAK7DGnEYWqk_zXzK4,7492
5
+ aisec/model.py,sha256=RaIfZi8ahPVvAcaq7vWEZa-3zq8AT5Mi6H2l7fkYYGM,970
6
+ aisec/sarif.py,sha256=I7jrcTvou5JGJZBWZVmoLaPGhXoC5kZ-_s95GzRVOQc,2173
7
+ aisec/summary.py,sha256=dCFWGRuvE3gM3f0-c_OjCxbnxEbrGu4JQPr3HdTmwTM,1410
8
+ aisec_suite-0.2.0.dist-info/licenses/LICENSE,sha256=Skd0_F-tLTX1HDTM9NyFSIbQaDW0dM1_ydtr6-3zztQ,1064
9
+ aisec_suite-0.2.0.dist-info/METADATA,sha256=zMEap44PLn-_0a6EDMdtAXas2u6rTpLzdonLj-c-zcE,4921
10
+ aisec_suite-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ aisec_suite-0.2.0.dist-info/entry_points.txt,sha256=2GtLKi4xRLIh3no0XUbqorABt3N33Pj2DyIfTHr-0_0,40
12
+ aisec_suite-0.2.0.dist-info/top_level.txt,sha256=f1wDe-4raesgkQQklIK5P9A3_n6oyDiYpYzazZ5LKb4,6
13
+ aisec_suite-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aisec = aisec.cli:app
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pyhroff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1 @@
1
+ aisec