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.
Files changed (48) hide show
  1. nti_scanner/__init__.py +2 -0
  2. nti_scanner/analysis/__init__.py +1 -0
  3. nti_scanner/analysis/behavior_graph.py +31 -0
  4. nti_scanner/ast_analysis/__init__.py +1 -0
  5. nti_scanner/ast_analysis/callgraph.py +15 -0
  6. nti_scanner/ast_analysis/parser.py +20 -0
  7. nti_scanner/ast_analysis/taint.py +42 -0
  8. nti_scanner/cli.py +85 -0
  9. nti_scanner/cwe/__init__.py +1 -0
  10. nti_scanner/cwe/mapping.py +10 -0
  11. nti_scanner/framework/__init__.py +1 -0
  12. nti_scanner/framework/detector.py +39 -0
  13. nti_scanner/report/__init__.py +1 -0
  14. nti_scanner/report/html.py +10 -0
  15. nti_scanner/report/json_report.py +6 -0
  16. nti_scanner/report/sarif.py +29 -0
  17. nti_scanner/report/terminal.py +41 -0
  18. nti_scanner/rules/__init__.py +1 -0
  19. nti_scanner/rules/base.py +42 -0
  20. nti_scanner/rules/nti/__init__.py +1 -0
  21. nti_scanner/rules/nti/audit.py +31 -0
  22. nti_scanner/rules/nti/capability_diff.py +38 -0
  23. nti_scanner/rules/nti/consensus.py +29 -0
  24. nti_scanner/rules/nti/governance.py +32 -0
  25. nti_scanner/rules/nti/identity.py +33 -0
  26. nti_scanner/rules/nti/persistence.py +25 -0
  27. nti_scanner/rules/nti/trust_boundary.py +36 -0
  28. nti_scanner/rules/registry.py +31 -0
  29. nti_scanner/rules/security/__init__.py +1 -0
  30. nti_scanner/rules/security/a2a_rules.py +21 -0
  31. nti_scanner/rules/security/capability_escalation.py +29 -0
  32. nti_scanner/rules/security/command_injection.py +38 -0
  33. nti_scanner/rules/security/llm_trust.py +35 -0
  34. nti_scanner/rules/security/mcp_rules.py +21 -0
  35. nti_scanner/rules/security/path_traversal.py +28 -0
  36. nti_scanner/rules/security/pqc_readiness.py +24 -0
  37. nti_scanner/rules/security/prompt_injection.py +24 -0
  38. nti_scanner/rules/security/secret_leak.py +22 -0
  39. nti_scanner/rules/security/sql_injection.py +27 -0
  40. nti_scanner/rules/security/ssrf.py +31 -0
  41. nti_scanner/rules/security/unsafe_deserialization.py +29 -0
  42. nti_scanner/scanner.py +78 -0
  43. nti_scanner-0.1.0.dist-info/METADATA +60 -0
  44. nti_scanner-0.1.0.dist-info/RECORD +48 -0
  45. nti_scanner-0.1.0.dist-info/WHEEL +5 -0
  46. nti_scanner-0.1.0.dist-info/entry_points.txt +2 -0
  47. nti_scanner-0.1.0.dist-info/licenses/LICENSE +201 -0
  48. nti_scanner-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2 @@
1
+ """nti-scanner: Professional NTI-1 compliance and AI agent security scanner."""
2
+ __version__ = "0.1.0"
@@ -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,6 @@
1
+ """JSON report."""
2
+ import json
3
+
4
+
5
+ def render_json(result) -> str:
6
+ return json.dumps(result.to_dict(), indent=2)
@@ -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 ""