nti-scanner 0.1.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.
- nti_scanner/__init__.py +2 -0
- nti_scanner/analysis/__init__.py +1 -0
- nti_scanner/analysis/behavior_graph.py +31 -0
- nti_scanner/ast_analysis/__init__.py +1 -0
- nti_scanner/ast_analysis/callgraph.py +15 -0
- nti_scanner/ast_analysis/parser.py +20 -0
- nti_scanner/ast_analysis/taint.py +42 -0
- nti_scanner/cli.py +85 -0
- nti_scanner/cwe/__init__.py +1 -0
- nti_scanner/cwe/mapping.py +10 -0
- nti_scanner/framework/__init__.py +1 -0
- nti_scanner/framework/detector.py +39 -0
- nti_scanner/report/__init__.py +1 -0
- nti_scanner/report/html.py +10 -0
- nti_scanner/report/json_report.py +6 -0
- nti_scanner/report/sarif.py +29 -0
- nti_scanner/report/terminal.py +41 -0
- nti_scanner/rules/__init__.py +1 -0
- nti_scanner/rules/base.py +42 -0
- nti_scanner/rules/nti/__init__.py +1 -0
- nti_scanner/rules/nti/audit.py +31 -0
- nti_scanner/rules/nti/capability_diff.py +38 -0
- nti_scanner/rules/nti/consensus.py +29 -0
- nti_scanner/rules/nti/governance.py +32 -0
- nti_scanner/rules/nti/identity.py +33 -0
- nti_scanner/rules/nti/persistence.py +25 -0
- nti_scanner/rules/nti/trust_boundary.py +36 -0
- nti_scanner/rules/registry.py +31 -0
- nti_scanner/rules/security/__init__.py +1 -0
- nti_scanner/rules/security/a2a_rules.py +21 -0
- nti_scanner/rules/security/capability_escalation.py +29 -0
- nti_scanner/rules/security/command_injection.py +38 -0
- nti_scanner/rules/security/llm_trust.py +35 -0
- nti_scanner/rules/security/mcp_rules.py +21 -0
- nti_scanner/rules/security/path_traversal.py +28 -0
- nti_scanner/rules/security/pqc_readiness.py +24 -0
- nti_scanner/rules/security/prompt_injection.py +24 -0
- nti_scanner/rules/security/secret_leak.py +22 -0
- nti_scanner/rules/security/sql_injection.py +27 -0
- nti_scanner/rules/security/ssrf.py +31 -0
- nti_scanner/rules/security/unsafe_deserialization.py +29 -0
- nti_scanner/scanner.py +78 -0
- nti_scanner-0.1.0.dist-info/METADATA +60 -0
- nti_scanner-0.1.0.dist-info/RECORD +48 -0
- nti_scanner-0.1.0.dist-info/WHEEL +5 -0
- nti_scanner-0.1.0.dist-info/entry_points.txt +2 -0
- nti_scanner-0.1.0.dist-info/licenses/LICENSE +201 -0
- nti_scanner-0.1.0.dist-info/top_level.txt +1 -0
nti_scanner/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Advanced NTI-specific analysis."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Build an agent behavior graph from AST."""
|
|
2
|
+
import ast
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_behavior_graph(trees: Dict) -> dict:
|
|
9
|
+
nodes = []
|
|
10
|
+
edges = []
|
|
11
|
+
for f, tree in trees.items():
|
|
12
|
+
for node in ast.walk(tree):
|
|
13
|
+
if isinstance(node, ast.Call):
|
|
14
|
+
name = _name(node.func)
|
|
15
|
+
if name in {"Agent", "AgentExecutor", "Crew"}:
|
|
16
|
+
nodes.append({"id": f"{f}:{node.lineno}", "type": "agent", "name": name, "file": str(f)})
|
|
17
|
+
elif name in {"execute_transfer", "delete_account", "read_account", "transfer"}:
|
|
18
|
+
nodes.append({"id": f"{f}:{node.lineno}", "type": "action", "name": name, "file": str(f)})
|
|
19
|
+
elif name in {"os.system", "subprocess.run", "eval", "exec", "open"}:
|
|
20
|
+
nodes.append({"id": f"{f}:{node.lineno}", "type": "sink", "name": name, "file": str(f)})
|
|
21
|
+
return {"nodes": nodes, "edges": edges}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _name(node):
|
|
25
|
+
if isinstance(node, ast.Name): return node.id
|
|
26
|
+
if isinstance(node, ast.Attribute): return node.attr
|
|
27
|
+
return ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render_graph_json(graph: dict) -> str:
|
|
31
|
+
return json.dumps(graph, indent=2)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AST-based analysis utilities."""
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Build a call graph from Python AST."""
|
|
2
|
+
import ast
|
|
3
|
+
from typing import Dict, Set
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def build_call_graph(tree: ast.AST) -> Dict[str, Set[str]]:
|
|
7
|
+
graph: Dict[str, Set[str]] = {}
|
|
8
|
+
for node in ast.walk(tree):
|
|
9
|
+
if isinstance(node, ast.FunctionDef):
|
|
10
|
+
calls = set()
|
|
11
|
+
for sub in ast.walk(node):
|
|
12
|
+
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Name):
|
|
13
|
+
calls.add(sub.func.id)
|
|
14
|
+
graph[node.name] = calls
|
|
15
|
+
return graph
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Safe AST parser."""
|
|
2
|
+
import ast
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def parse_python_file(path: Path) -> Optional[ast.AST]:
|
|
8
|
+
try:
|
|
9
|
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
10
|
+
return ast.parse(source, filename=str(path))
|
|
11
|
+
except (SyntaxError, ValueError):
|
|
12
|
+
return None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_source_segment(path: Path, node: ast.AST) -> str:
|
|
16
|
+
try:
|
|
17
|
+
source = path.read_text(encoding="utf-8", errors="ignore")
|
|
18
|
+
return ast.get_source_segment(source, node) or ""
|
|
19
|
+
except Exception:
|
|
20
|
+
return ""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Lightweight taint tracking."""
|
|
2
|
+
import ast
|
|
3
|
+
from typing import Set
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
USER_INPUT_SOURCES = {"input", "request.form", "request.json", "request.args", "sys.stdin.read", "sys.argv"}
|
|
7
|
+
UNSAFE_SINKS = {"eval", "exec", "os.system", "subprocess.run", "subprocess.Popen", "subprocess.call", "pickle.loads", "yaml.load", "os.popen"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_tainted_calls(tree: ast.AST) -> list:
|
|
11
|
+
findings = []
|
|
12
|
+
tainted_names: Set[str] = set()
|
|
13
|
+
for node in ast.walk(tree):
|
|
14
|
+
if isinstance(node, ast.Assign):
|
|
15
|
+
if _is_user_input(node.value):
|
|
16
|
+
for target in node.targets:
|
|
17
|
+
if isinstance(target, ast.Name):
|
|
18
|
+
tainted_names.add(target.id)
|
|
19
|
+
if isinstance(node, ast.Call):
|
|
20
|
+
func_name = _get_call_name(node)
|
|
21
|
+
if func_name in UNSAFE_SINKS:
|
|
22
|
+
for arg in node.args:
|
|
23
|
+
if isinstance(arg, ast.Name) and arg.id in tainted_names:
|
|
24
|
+
findings.append({"line": node.lineno, "col": node.col_offset, "sink": func_name, "source": arg.id})
|
|
25
|
+
return findings
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_user_input(node: ast.AST) -> bool:
|
|
29
|
+
if isinstance(node, ast.Call):
|
|
30
|
+
return _get_call_name(node) in USER_INPUT_SOURCES
|
|
31
|
+
return False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_call_name(node: ast.Call) -> str:
|
|
35
|
+
parts = []
|
|
36
|
+
cur = node.func
|
|
37
|
+
while isinstance(cur, ast.Attribute):
|
|
38
|
+
parts.append(cur.attr)
|
|
39
|
+
cur = cur.value
|
|
40
|
+
if isinstance(cur, ast.Name):
|
|
41
|
+
parts.append(cur.id)
|
|
42
|
+
return ".".join(reversed(parts))
|
nti_scanner/cli.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Command-line interface."""
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import click
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from nti_scanner.scanner import scan_directory
|
|
7
|
+
from nti_scanner.report.terminal import render_terminal
|
|
8
|
+
from nti_scanner.report.json_report import render_json
|
|
9
|
+
from nti_scanner.report.sarif import render_sarif
|
|
10
|
+
from nti_scanner.report.html import render_html
|
|
11
|
+
console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.group()
|
|
15
|
+
@click.version_option()
|
|
16
|
+
def main():
|
|
17
|
+
"""nti-scanner: NTI-1 compliance and AI agent security scanner."""
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@main.command()
|
|
22
|
+
@click.argument("path", type=click.Path(exists=True, file_okay=False))
|
|
23
|
+
@click.option("--format", "fmt", type=click.Choice(["terminal", "json", "sarif", "html"]), default="terminal")
|
|
24
|
+
@click.option("--output", "-o", type=click.Path(), default=None)
|
|
25
|
+
@click.option("--min-score", default=0)
|
|
26
|
+
@click.option("--severity", type=click.Choice(["critical", "high", "medium", "low", "info"]), default="info")
|
|
27
|
+
@click.option("--exclude", multiple=True)
|
|
28
|
+
def scan(path, fmt, output, min_score, severity, exclude):
|
|
29
|
+
"""Scan a directory for NTI-1 compliance and AI agent security issues."""
|
|
30
|
+
if fmt == "terminal":
|
|
31
|
+
console.print(f"[bold cyan]nti-scanner v{__import__('nti_scanner').__version__}[/bold cyan]")
|
|
32
|
+
console.print(f"Scanning [bold]{path}[/bold]...\n")
|
|
33
|
+
result = scan_directory(Path(path).resolve(), min_severity=severity, excludes=list(exclude))
|
|
34
|
+
if fmt == "terminal":
|
|
35
|
+
render_terminal(result, console)
|
|
36
|
+
elif fmt == "json":
|
|
37
|
+
content = render_json(result)
|
|
38
|
+
if output: Path(output).write_text(content)
|
|
39
|
+
else: console.print_json(content)
|
|
40
|
+
elif fmt == "sarif":
|
|
41
|
+
content = render_sarif(result)
|
|
42
|
+
if output: Path(output).write_text(content)
|
|
43
|
+
else: print(content)
|
|
44
|
+
elif fmt == "html":
|
|
45
|
+
content = render_html(result)
|
|
46
|
+
if output: Path(output).write_text(content)
|
|
47
|
+
else: print(content)
|
|
48
|
+
if min_score and result.nti1_score < min_score:
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@main.command()
|
|
53
|
+
def rules():
|
|
54
|
+
"""List all available rules."""
|
|
55
|
+
from rich.table import Table
|
|
56
|
+
from nti_scanner.rules.registry import RULES
|
|
57
|
+
table = Table(title="Available Rules")
|
|
58
|
+
table.add_column("ID", style="cyan")
|
|
59
|
+
table.add_column("Category")
|
|
60
|
+
table.add_column("Severity")
|
|
61
|
+
table.add_column("Description")
|
|
62
|
+
for r in RULES:
|
|
63
|
+
table.add_row(r.meta.id, r.meta.category, r.meta.severity, r.meta.description[:60])
|
|
64
|
+
console.print(table)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@main.command()
|
|
68
|
+
@click.argument("path", type=click.Path(exists=True, file_okay=False))
|
|
69
|
+
@click.option("--output", "-o", type=click.Path(), required=True)
|
|
70
|
+
def graph(path, output):
|
|
71
|
+
"""Build and export the agent behavior graph."""
|
|
72
|
+
from pathlib import Path
|
|
73
|
+
from nti_scanner.ast_analysis.parser import parse_python_file
|
|
74
|
+
from nti_scanner.analysis.behavior_graph import build_behavior_graph, render_graph_json
|
|
75
|
+
trees = {}
|
|
76
|
+
for f in Path(path).rglob("*.py"):
|
|
77
|
+
t = parse_python_file(f)
|
|
78
|
+
if t: trees[f] = t
|
|
79
|
+
graph_data = build_behavior_graph(trees)
|
|
80
|
+
Path(output).write_text(render_graph_json(graph_data))
|
|
81
|
+
console.print(f"[green]Graph written to {output}[/green]")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
if __name__ == "__main__":
|
|
85
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CWE metadata."""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""CWE metadata lookup."""
|
|
2
|
+
CWE_NAMES = {"CWE-22": "Path Traversal", "CWE-78": "OS Command Injection", "CWE-89": "SQL Injection",
|
|
3
|
+
"CWE-269": "Privilege Management", "CWE-287": "Authentication", "CWE-353": "Missing Integrity",
|
|
4
|
+
"CWE-502": "Unsafe Deserialization", "CWE-778": "Insufficient Logging", "CWE-798": "Hardcoded Credentials",
|
|
5
|
+
"CWE-862": "Missing Authorization", "CWE-918": "SSRF", "CWE-1336": "Template Injection",
|
|
6
|
+
"CWE-1426": "AI Output Validation"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def cwe_url(cwe: str) -> str:
|
|
10
|
+
return f"https://cwe.mitre.org/data/definitions/{cwe.replace('CWE-', '')}.html"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework detection."""
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Detect which AI frameworks a codebase uses."""
|
|
2
|
+
import ast
|
|
3
|
+
from typing import Dict, List
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
FRAMEWORK_SIGNATURES = {
|
|
7
|
+
"langchain": {"langchain", "langchain_core", "langchain_openai", "ChatOpenAI", "AgentExecutor"},
|
|
8
|
+
"crewai": {"crewai", "Crew", "Agent", "Task"},
|
|
9
|
+
"autogen": {"autogen", "ConversableAgent", "AssistantAgent"},
|
|
10
|
+
"openai": {"openai", "OpenAI", "ChatCompletion"},
|
|
11
|
+
"anthropic": {"anthropic", "Anthropic"},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def detect_frameworks(trees: Dict) -> List[str]:
|
|
16
|
+
detected = set()
|
|
17
|
+
for tree in trees.values():
|
|
18
|
+
for node in ast.walk(tree):
|
|
19
|
+
if isinstance(node, ast.Import):
|
|
20
|
+
for n in node.names: _match(n.name, detected)
|
|
21
|
+
elif isinstance(node, ast.ImportFrom):
|
|
22
|
+
if node.module: _match(node.module, detected)
|
|
23
|
+
elif isinstance(node, ast.Call):
|
|
24
|
+
name = _name(node.func)
|
|
25
|
+
if name: _match(name, detected)
|
|
26
|
+
return sorted(detected)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _match(name: str, detected: set):
|
|
30
|
+
for fw, sigs in FRAMEWORK_SIGNATURES.items():
|
|
31
|
+
for sig in sigs:
|
|
32
|
+
if sig.lower() in name.lower():
|
|
33
|
+
detected.add(fw); return
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _name(node):
|
|
37
|
+
if isinstance(node, ast.Name): return node.id
|
|
38
|
+
if isinstance(node, ast.Attribute): return node.attr
|
|
39
|
+
return ""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Report renderers."""
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""HTML report."""
|
|
2
|
+
from html import escape
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def render_html(result) -> str:
|
|
6
|
+
findings_html = ""
|
|
7
|
+
for f in result.findings:
|
|
8
|
+
findings_html += f'<div class="finding {f.severity}"><div><strong>{f.severity.upper()}</strong> - {f.rule_id}</div><div>{escape(f.message)}</div><div>{escape(f.file)}:{f.line}</div></div>'
|
|
9
|
+
pillars_html = "".join(f'<div><span>{p}</span><span>{s}/100</span></div>' for p, s in result.pillar_scores.items())
|
|
10
|
+
return f'<!DOCTYPE html><html><head><meta charset="utf-8"><title>nti-scanner</title></head><body><h1>NTI-1 Report</h1><div>{result.nti1_score}/100</div><p>{result.files_scanned} files - {len(result.findings)} findings</p><h2>Pillars</h2>{pillars_html}<h2>Findings</h2>{findings_html}</body></html>'
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""SARIF 2.1.0 reporter for GitHub Security integration."""
|
|
2
|
+
import json
|
|
3
|
+
from nti_scanner.rules.registry import RULES
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SEVERITY_TO_LEVEL = {"critical": "error", "high": "error", "medium": "warning", "low": "note", "info": "note"}
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render_sarif(result) -> str:
|
|
10
|
+
rules_meta = []
|
|
11
|
+
for r in RULES:
|
|
12
|
+
rules_meta.append({"id": r.meta.id, "name": r.meta.id,
|
|
13
|
+
"shortDescription": {"text": r.meta.description},
|
|
14
|
+
"helpUri": f"https://cwe.mitre.org/data/definitions/{r.meta.cwe.replace('CWE-','')}.html" if r.meta.cwe else "",
|
|
15
|
+
"properties": {"category": r.meta.category, "confidence": r.meta.confidence}})
|
|
16
|
+
sarif_results = []
|
|
17
|
+
for f in result.findings:
|
|
18
|
+
sarif_results.append({"ruleId": f.rule_id, "level": SEVERITY_TO_LEVEL.get(f.severity, "warning"),
|
|
19
|
+
"message": {"text": f.message},
|
|
20
|
+
"locations": [{"physicalLocation": {"artifactLocation": {"uri": f.file},
|
|
21
|
+
"region": {"startLine": max(1, f.line), "startColumn": max(1, f.column)}}}],
|
|
22
|
+
"properties": {"cwe": f.cwe, "confidence": f.confidence}})
|
|
23
|
+
sarif = {"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
24
|
+
"version": "2.1.0",
|
|
25
|
+
"runs": [{"tool": {"driver": {"name": "nti-scanner",
|
|
26
|
+
"informationUri": "https://github.com/abisheakp197/nti-scanner",
|
|
27
|
+
"version": "0.1.0", "rules": rules_meta}},
|
|
28
|
+
"results": sarif_results}]}
|
|
29
|
+
return json.dumps(sarif, indent=2)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Rich terminal renderer."""
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
SEVERITY_COLORS = {"critical": "bold red", "high": "red", "medium": "yellow", "low": "dim", "info": "blue"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def render_terminal(result, console: Console):
|
|
11
|
+
score = result.nti1_score
|
|
12
|
+
if score >= 90: color, grade = "green", "A"
|
|
13
|
+
elif score >= 75: color, grade = "cyan", "B"
|
|
14
|
+
elif score >= 50: color, grade = "yellow", "C"
|
|
15
|
+
elif score >= 25: color, grade = "orange3", "D"
|
|
16
|
+
else: color, grade = "red", "F"
|
|
17
|
+
console.print(Panel.fit(f"[bold {color}]NTI-1 Score: {score}/100 (Grade {grade})[/bold {color}]\n"
|
|
18
|
+
f"Files scanned: {result.files_scanned} | Lines: {result.lines_scanned} | Findings: {len(result.findings)}\n"
|
|
19
|
+
f"Frameworks detected: {', '.join(result.frameworks) or 'none'}", title="nti-scanner"))
|
|
20
|
+
if result.pillar_scores:
|
|
21
|
+
table = Table(title="Pillar Breakdown")
|
|
22
|
+
table.add_column("Pillar", style="cyan")
|
|
23
|
+
table.add_column("Score", justify="right")
|
|
24
|
+
for p, s in result.pillar_scores.items():
|
|
25
|
+
color = "green" if s >= 80 else "yellow" if s >= 50 else "red"
|
|
26
|
+
table.add_row(p, f"[{color}]{s}/100[/]")
|
|
27
|
+
console.print(table)
|
|
28
|
+
if result.findings:
|
|
29
|
+
t = Table(title=f"Findings ({len(result.findings)})")
|
|
30
|
+
t.add_column("Severity")
|
|
31
|
+
t.add_column("Rule")
|
|
32
|
+
t.add_column("CWE")
|
|
33
|
+
t.add_column("Message")
|
|
34
|
+
t.add_column("File:Line")
|
|
35
|
+
for f in result.findings[:30]:
|
|
36
|
+
t.add_row(f"[{SEVERITY_COLORS.get(f.severity, 'white')}]{f.severity.upper()}[/]",
|
|
37
|
+
f.rule_id, f.cwe or "-", f.message[:70], f"{f.file.split('/')[-1]}:{f.line}")
|
|
38
|
+
console.print(t)
|
|
39
|
+
console.print()
|
|
40
|
+
console.print("[bold]Fix your score:[/bold] pip install ube-foundation")
|
|
41
|
+
console.print("[dim]Spec: https://abisheakp197.github.io/nti-spec/[/dim]")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Rule package."""
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Base class for rules."""
|
|
2
|
+
import ast
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class Finding:
|
|
10
|
+
rule_id: str
|
|
11
|
+
category: str
|
|
12
|
+
severity: str
|
|
13
|
+
confidence: str
|
|
14
|
+
message: str
|
|
15
|
+
file: str
|
|
16
|
+
line: int
|
|
17
|
+
column: int
|
|
18
|
+
cwe: Optional[str] = None
|
|
19
|
+
remediation: str = ""
|
|
20
|
+
code_snippet: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RuleMeta:
|
|
25
|
+
id: str
|
|
26
|
+
category: str
|
|
27
|
+
severity: str
|
|
28
|
+
confidence: str
|
|
29
|
+
description: str
|
|
30
|
+
cwe: str = ""
|
|
31
|
+
remediation: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BaseRule:
|
|
35
|
+
meta: RuleMeta
|
|
36
|
+
def check(self, path: Path, tree: ast.AST, frameworks: List[str]) -> List[Finding]:
|
|
37
|
+
raise NotImplementedError
|
|
38
|
+
def make_finding(self, path: Path, node: ast.AST, message: str, snippet: str = "") -> Finding:
|
|
39
|
+
return Finding(rule_id=self.meta.id, category=self.meta.category, severity=self.meta.severity,
|
|
40
|
+
confidence=self.meta.confidence, message=message, file=str(path),
|
|
41
|
+
line=getattr(node, "lineno", 0), column=getattr(node, "col_offset", 0),
|
|
42
|
+
cwe=self.meta.cwe, remediation=self.meta.remediation, code_snippet=snippet)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""NTI-1 compliance rules."""
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""NTI-1 Pillar 4: Audit."""
|
|
2
|
+
import ast
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
from nti_scanner.rules.base import BaseRule, RuleMeta
|
|
6
|
+
from nti_scanner.scanner import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuditRule(BaseRule):
|
|
10
|
+
meta = RuleMeta(id="NTI-AUDIT-001", category="Audit", severity="high", confidence="high",
|
|
11
|
+
description="No Merkle-chained audit trail detected", cwe="CWE-778",
|
|
12
|
+
remediation="Install ube-foundation for Merkle-chained audit.")
|
|
13
|
+
AUDIT_KEYWORDS = {"audit", "merkle", "audit_event", "verify_history", "log_event", "ube_foundation"}
|
|
14
|
+
def check(self, path: Path, tree: ast.AST, frameworks: List[str]) -> List[Finding]:
|
|
15
|
+
has_agent = False
|
|
16
|
+
first_node = None
|
|
17
|
+
for node in ast.walk(tree):
|
|
18
|
+
if isinstance(node, ast.Call) and "Agent" in _name(node.func):
|
|
19
|
+
has_agent = True
|
|
20
|
+
first_node = node
|
|
21
|
+
break
|
|
22
|
+
if not has_agent: return []
|
|
23
|
+
content = path.read_text(errors="ignore").lower()
|
|
24
|
+
if any(kw in content for kw in self.AUDIT_KEYWORDS): return []
|
|
25
|
+
return [self.make_finding(path, first_node, "Agent without audit trail. NTI-1 requires Merkle-chained logs.")]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _name(node):
|
|
29
|
+
if isinstance(node, ast.Name): return node.id
|
|
30
|
+
if isinstance(node, ast.Attribute): return node.attr
|
|
31
|
+
return ""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""NTI-unique: Detect unauthorized tool capabilities."""
|
|
2
|
+
import ast
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
from nti_scanner.rules.base import BaseRule, RuleMeta
|
|
6
|
+
from nti_scanner.scanner import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CapabilityDiffRule(BaseRule):
|
|
10
|
+
meta = RuleMeta(id="NTI-CAPDIFF-001", category="Governance", severity="critical", confidence="high",
|
|
11
|
+
description="Tool declared but never granted as a capability", cwe="CWE-862",
|
|
12
|
+
remediation="Every tool must have grant_capability().")
|
|
13
|
+
def check(self, path: Path, tree: ast.AST, frameworks: List[str]) -> List[Finding]:
|
|
14
|
+
declared_tools = set()
|
|
15
|
+
granted_caps = set()
|
|
16
|
+
for node in ast.walk(tree):
|
|
17
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
18
|
+
for dec in node.decorator_list:
|
|
19
|
+
name = _name(dec.func if isinstance(dec, ast.Call) else dec)
|
|
20
|
+
if name in {"tool", "function_tool"}: declared_tools.add(node.name)
|
|
21
|
+
if isinstance(node, ast.Call):
|
|
22
|
+
if _name(node.func) == "grant_capability" and node.args:
|
|
23
|
+
if isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str):
|
|
24
|
+
granted_caps.add(node.args[0].value)
|
|
25
|
+
if not declared_tools: return []
|
|
26
|
+
unguarded = declared_tools - granted_caps
|
|
27
|
+
if not unguarded: return []
|
|
28
|
+
content = path.read_text(errors="ignore")
|
|
29
|
+
if "grant_capability" not in content:
|
|
30
|
+
node = next((n for n in ast.walk(tree) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))), tree)
|
|
31
|
+
return [self.make_finding(path, node, f"Tools declared but no grant_capability(). All {len(declared_tools)} tools unauthorized.")]
|
|
32
|
+
return []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _name(node):
|
|
36
|
+
if isinstance(node, ast.Name): return node.id
|
|
37
|
+
if isinstance(node, ast.Attribute): return node.attr
|
|
38
|
+
return ""
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""NTI-1 Pillar 3: BFT Consensus."""
|
|
2
|
+
import ast
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
from nti_scanner.rules.base import BaseRule, RuleMeta
|
|
6
|
+
from nti_scanner.scanner import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ConsensusRule(BaseRule):
|
|
10
|
+
meta = RuleMeta(id="NTI-CONS-001", category="Consensus", severity="medium", confidence="low",
|
|
11
|
+
description="Multi-agent system without BFT consensus", cwe="CWE-703",
|
|
12
|
+
remediation="Use NTI BFT consensus for multi-agent systems.")
|
|
13
|
+
def check(self, path: Path, tree: ast.AST, frameworks: List[str]) -> List[Finding]:
|
|
14
|
+
agent_count = 0
|
|
15
|
+
first_node = None
|
|
16
|
+
for node in ast.walk(tree):
|
|
17
|
+
if isinstance(node, ast.Call) and _name(node.func) == "Agent":
|
|
18
|
+
agent_count += 1
|
|
19
|
+
if first_node is None: first_node = node
|
|
20
|
+
if agent_count < 2: return []
|
|
21
|
+
content = path.read_text(errors="ignore").lower()
|
|
22
|
+
if any(kw in content for kw in {"consensus", "voter", "bft", "threshold"}): return []
|
|
23
|
+
return [self.make_finding(path, first_node, f"Multi-agent system ({agent_count} agents) without BFT consensus.")]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _name(node):
|
|
27
|
+
if isinstance(node, ast.Name): return node.id
|
|
28
|
+
if isinstance(node, ast.Attribute): return node.attr
|
|
29
|
+
return ""
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""NTI-1 Pillar 2: Governance."""
|
|
2
|
+
import ast
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
from nti_scanner.rules.base import BaseRule, RuleMeta
|
|
6
|
+
from nti_scanner.scanner import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GovernanceRule(BaseRule):
|
|
10
|
+
meta = RuleMeta(id="NTI-GOV-001", category="Governance", severity="critical", confidence="high",
|
|
11
|
+
description="Tool functions without zero-trust capability bounds", cwe="CWE-862",
|
|
12
|
+
remediation="Grant explicit capabilities per tool.")
|
|
13
|
+
TOOL_DECORATORS = {"tool", "function_tool"}
|
|
14
|
+
GOVERNANCE_KEYWORDS = {"grant_capability", "check_capability", "trustengine", "trust_engine", "nti", "policy"}
|
|
15
|
+
def check(self, path: Path, tree: ast.AST, frameworks: List[str]) -> List[Finding]:
|
|
16
|
+
tool_functions = []
|
|
17
|
+
for node in ast.walk(tree):
|
|
18
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
19
|
+
for dec in node.decorator_list:
|
|
20
|
+
if isinstance(dec, ast.Call) and _name(dec.func) in self.TOOL_DECORATORS: tool_functions.append(node)
|
|
21
|
+
elif isinstance(dec, ast.Name) and dec.id in self.TOOL_DECORATORS: tool_functions.append(node)
|
|
22
|
+
if not tool_functions: return []
|
|
23
|
+
content = path.read_text(errors="ignore").lower()
|
|
24
|
+
if not any(kw in content for kw in self.GOVERNANCE_KEYWORDS):
|
|
25
|
+
return [self.make_finding(path, tool_functions[0], f"Found {len(tool_functions)} tool function(s) without governance.")]
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _name(node):
|
|
30
|
+
if isinstance(node, ast.Name): return node.id
|
|
31
|
+
if isinstance(node, ast.Attribute): return node.attr
|
|
32
|
+
return ""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""NTI-1 Pillar 1: Identity."""
|
|
2
|
+
import ast
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
from nti_scanner.rules.base import BaseRule, RuleMeta
|
|
6
|
+
from nti_scanner.scanner import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class IdentityRule(BaseRule):
|
|
10
|
+
meta = RuleMeta(id="NTI-IDENT-001", category="Identity", severity="high", confidence="medium",
|
|
11
|
+
description="Agent code lacks cryptographic identity (PQC signatures)", cwe="CWE-287",
|
|
12
|
+
remediation="Install ube-foundation and wrap your agent with NTICallbackHandler.")
|
|
13
|
+
IDENTITY_KEYWORDS = {"dilithium", "kyber", "pqc", "sign", "verify", "signature", "ube_foundation", "nti"}
|
|
14
|
+
def check(self, path: Path, tree: ast.AST, frameworks: List[str]) -> List[Finding]:
|
|
15
|
+
findings = []
|
|
16
|
+
agent_nodes = []
|
|
17
|
+
for node in ast.walk(tree):
|
|
18
|
+
if isinstance(node, ast.Call):
|
|
19
|
+
fname = _name(node.func)
|
|
20
|
+
if fname in {"Agent", "ChatOpenAI", "LLMChain"} or "agent" in fname.lower():
|
|
21
|
+
agent_nodes.append(node)
|
|
22
|
+
if not agent_nodes: return []
|
|
23
|
+
content = path.read_text(errors="ignore").lower()
|
|
24
|
+
if not any(kw in content for kw in self.IDENTITY_KEYWORDS):
|
|
25
|
+
for node in agent_nodes[:3]:
|
|
26
|
+
findings.append(self.make_finding(path, node, "Agent declared without cryptographic identity. NTI-1 requires Dilithium5 signatures."))
|
|
27
|
+
return findings
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _name(node):
|
|
31
|
+
if isinstance(node, ast.Name): return node.id
|
|
32
|
+
if isinstance(node, ast.Attribute): return node.attr
|
|
33
|
+
return ""
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""NTI-1 Pillar 5: Persistence."""
|
|
2
|
+
import ast
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
from nti_scanner.rules.base import BaseRule, RuleMeta
|
|
6
|
+
from nti_scanner.scanner import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PersistenceRule(BaseRule):
|
|
10
|
+
meta = RuleMeta(id="NTI-PERS-001", category="Persistence", severity="low", confidence="low",
|
|
11
|
+
description="No state persistence with integrity verification", cwe="CWE-353",
|
|
12
|
+
remediation="Persist agent state with cryptographic integrity.")
|
|
13
|
+
PERSISTENCE_KEYWORDS = {"persist", "save_state", "load_state", "verify_history", "integrity"}
|
|
14
|
+
def check(self, path: Path, tree: ast.AST, frameworks: List[str]) -> List[Finding]:
|
|
15
|
+
has_agent = any(isinstance(n, ast.Call) and "Agent" in _name(n.func) for n in ast.walk(tree))
|
|
16
|
+
if not has_agent: return []
|
|
17
|
+
content = path.read_text(errors="ignore").lower()
|
|
18
|
+
if any(kw in content for kw in self.PERSISTENCE_KEYWORDS): return []
|
|
19
|
+
return []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _name(node):
|
|
23
|
+
if isinstance(node, ast.Name): return node.id
|
|
24
|
+
if isinstance(node, ast.Attribute): return node.attr
|
|
25
|
+
return ""
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""NTI-unique: Trace trust boundary crossings."""
|
|
2
|
+
import ast
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import List
|
|
5
|
+
from nti_scanner.rules.base import BaseRule, RuleMeta
|
|
6
|
+
from nti_scanner.scanner import Finding
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TrustBoundaryRule(BaseRule):
|
|
10
|
+
meta = RuleMeta(id="NTI-TRUST-001", category="Security", severity="high", confidence="high",
|
|
11
|
+
description="Trust boundary crossing without NTI verification", cwe="CWE-1426",
|
|
12
|
+
remediation="Wrap the boundary crossing with an NTI callback.")
|
|
13
|
+
PRIVILEGED_SINKS = {"eval", "exec", "compile", "__import__", "open"}
|
|
14
|
+
def check(self, path: Path, tree: ast.AST, frameworks: List[str]) -> List[Finding]:
|
|
15
|
+
findings = []
|
|
16
|
+
llm_outputs = set()
|
|
17
|
+
for node in ast.walk(tree):
|
|
18
|
+
if isinstance(node, ast.Assign):
|
|
19
|
+
if isinstance(node.value, ast.Call):
|
|
20
|
+
fname = _name(node.value.func)
|
|
21
|
+
if fname in {"invoke", "run", "predict", "generate", "chat", "completion"}:
|
|
22
|
+
for t in node.targets:
|
|
23
|
+
if isinstance(t, ast.Name): llm_outputs.add(t.id)
|
|
24
|
+
if isinstance(node, ast.Call):
|
|
25
|
+
fname = _name(node.func)
|
|
26
|
+
if fname in self.PRIVILEGED_SINKS:
|
|
27
|
+
for arg in node.args:
|
|
28
|
+
if isinstance(arg, ast.Name) and arg.id in llm_outputs:
|
|
29
|
+
findings.append(self.make_finding(path, node, f"Trust boundary crossing: LLM output '{arg.id}' flows to {fname}()."))
|
|
30
|
+
return findings[:3]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _name(node):
|
|
34
|
+
if isinstance(node, ast.Name): return node.id
|
|
35
|
+
if isinstance(node, ast.Attribute): return node.attr
|
|
36
|
+
return ""
|