Skip to content

Commit d6ca2e4

Browse files
authored
Merge pull request #37 from 0xBallpoint/ai-agents
AI support for SSH using OpenAI agents
2 parents 5fbbfb7 + c1841e2 commit d6ca2e4

14 files changed

Lines changed: 376 additions & 26 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,4 @@ cython_debug/
165165
###############
166166
ssh_host_key*
167167
*.pem
168+
*.db

README.md

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,53 @@ If someone tries to login, you will get a log like this one:
117117
}
118118
```
119119

120-
### AI ALPHA support
121-
To generate responses, you can use the `ai` field in the configuration. For now, it uses [OVHCloud AI Endpoints](https://endpoints.ai.cloud.ovh.net/) as it is still free, and in beta.
122-
The file `trapster/modules/libs/ai.py` contains the code to generate responses using the AI model. It is still very basic, and will be improved in the near future.
120+
### AI BETA support
123121

124-
For example, this image show a request to capture SQLi attempts, and the response generated by the AI model.
122+
To use AI, you need to set your environnement variables. First, copy the `example.env` file
123+
```bash
124+
cp example.env .env
125+
```
126+
Now, you can set:
127+
```
128+
AI_MODEL=
129+
AI_BASE_URL=
130+
AI_API_KEY=
131+
AI_MEMORY_ENABLE=false
132+
# AI_MEMORY_PATH=
133+
```
134+
AI_MEMORY_ENABLE and AI_MEMORY_PATH are optionnal, it allows you to set persistant data between session. Sessions are based on the IP of the user, and the username.
135+
By default, if you set `AI_MEMORY_ENABLE=true`, then the database will be in `trapster/data/ai_memory.db`
136+
137+
You can also use `OPENAI_API_KEY` directly if you want to use the default `o4-mini` model:
138+
```bash
139+
export OPENAI_API_KEY=... && venv/bin/python3 main.py
140+
```
141+
142+
#### AI for SSH
143+
Trapster can generate fake shell responses when user connect to SSH.
144+
145+
To enable AI for SSH, allow the users to connect with username/password combination that you can define in the configuration file `trapster.conf` like :
146+
```
147+
...
148+
"ssh": [
149+
{
150+
"port": 2222,
151+
"version": "SSH-2.0-OpenSSH_8.1p1 Debian-1",
152+
"banner": null,
153+
"users": {
154+
"guest":"guest",
155+
"admin":"admin",
156+
"ubuntu":"ubuntu",
157+
"pi":"raspberry",
158+
"debian":"password"
159+
}
160+
}
161+
...
162+
```
163+
164+
##### AI for HTTP
165+
To generate responses, you can use the `ai` field in the configuration. It will generate a response for the corresponding URL. You can change the prompt for each URL. This enable to fast, pre-determined responses for the honeypot website, and only AI responses when the URL is unkown.
166+
For example, this image show a request to capture SQLi attempts. Only the SQLi attempts are generated by AI.
125167

126168
<img src="images/sqli_ai_response_1.png" width="60%">
127169

example.env

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
AI_MODEL=o4-mini
2+
AI_BASE_URL=https://api.openai.com/v1/
3+
AI_API_KEY=
4+
# AI_MEMORY_ENABLE=true
5+
# AI_MEMORY_PATH=

setup.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ def get_version(rel_path):
2424
name='trapster',
2525
version=get_version("trapster/__init__.py"),
2626
install_requires=requirements,
27+
extras_require={
28+
'ai': [
29+
'openai<1.99.0',
30+
'openai-agents>=0.2.5',
31+
],
32+
},
2733
url='https://trapster.cloud/',
2834
author='0xBallpoint',
2935
author_email='contact@ballpoint.fr',

trapster/ai/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .ssh import SSHAgent
2+
from .http import HTTPAgent
3+
4+
__all__ = ["SSHAgent"]

trapster/ai/http.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
2+
from __future__ import annotations
3+
4+
from typing import Dict, Any
5+
import os
6+
from openai import AsyncOpenAI
7+
from agents import (
8+
Agent,
9+
OpenAIChatCompletionsModel,
10+
Runner,
11+
function_tool,
12+
SQLiteSession,
13+
set_tracing_disabled,
14+
ModelSettings,
15+
)
16+
from agents.models.openai_responses import OpenAIResponsesModel
17+
from agents.extensions.handoff_prompt import prompt_with_handoff_instructions
18+
from pydantic import BaseModel
19+
import subprocess
20+
import json
21+
22+
class _HTTPOutput(BaseModel):
23+
result: str
24+
25+
class HTTPAgent(Agent):
26+
def __init__(
27+
self,
28+
*,
29+
model_name: str | None = None,
30+
api_key: str | None = None,
31+
base_url: str | None = None,
32+
memory_path: str | None = None,
33+
) -> None:
34+
35+
# Resolve configuration from env if not provided
36+
model_name = model_name or os.getenv("TRAPSTER_AI_MODEL") or os.getenv("OPENAI_MODEL") or "chatgpt-4o-mini"
37+
api_key = api_key or os.getenv("AI_API_KEY") or os.getenv("OPENAI_API_KEY") or ""
38+
base_url = base_url or os.getenv("OPENAI_BASE_URL") or os.getenv("AI_BASE_URL") or "https://api.openai.com/v1/chat/completions"
39+
40+
# Shared OpenAI client
41+
self.client = AsyncOpenAI(base_url=base_url, api_key=api_key)
42+
self.sessions: dict[str, SQLiteSession] = {}
43+
set_tracing_disabled(disabled=True)
44+
45+
# self.client = AsyncOpenAI()
46+
47+
# shell_prompt = (
48+
# """
49+
#You are simulating an Ubuntu Linux shell session for a low-privilege user in /home/guest. Respond exactly like a real shell. Never reveal you are an AI or add explanations.
50+
#
51+
#State and environment
52+
#- Current directory starts at /home/guest and must be updated on `cd` and similar commands.
53+
#- Use a plausible user environment with a realistic but limited filesystem under /home/guest.
54+
#- Do not print the prompt or the command itself; only return command output.
55+
#- No Markdown, no code fences, no ANSI color codes.
56+
#
57+
#Output format (always JSON, no extra text):
58+
#{
59+
# "directory": "<current directory after command>",
60+
# "command_result": "<exact terminal output>"
61+
#}
62+
#
63+
#Handoffs (tools)
64+
#- Use fileAgentHandoff(input: { "directory": "<string>", "file_name": "<string>" }) whenever the user requests to view a file’s contents (e.g., cat, head, tail, less, more).
65+
#- If a handoff is needed, call the correct handoff with correct input. Do not fabricate file contents yourself.
66+
#- For cat/head/tail/less/more, ALWAYS call fileAgentHandoff. Never simulate file contents.
67+
#
68+
#Command behavior
69+
#- `pwd`: return the current directory.
70+
#- `cd <path>`: change directory if it exists; otherwise error: “bash: cd: <path>: No such file or directory”.
71+
#- `ls` / `ls -la` etc.: show typical ls formatting.
72+
#- `cat <file>` (and `head`, `tail`, `less`, `more`): always use fileAgentHandoff for the file in the current directory (or resolve absolute/relative paths). If missing: “cat: <file>: No such file or directory”.
73+
#- Network/system info commands (e.g., `whoami`, `uname -a`, `ifconfig`, `ip a`, `ps aux`): return plausible outputs for a non-privileged user.
74+
#- Sudo/admin actions: prompt/deny appropriately; if asked for password, treat as incorrect/no sudo rights (e.g., “<user> is not in the sudoers file. This incident will be reported.”).
75+
#- Destructive or privileged operations (e.g., `shutdown`, `reboot`, `rm -rf /`): fail with realistic errors (e.g., “Permission denied”).
76+
#- Commands with no output should return an empty string for "command_result".
77+
#- Nonexistent commands: “bash: <cmd>: command not found”.
78+
#
79+
#Rules
80+
#- Always return valid JSON exactly matching the schema.
81+
#- Keep outputs terse and realistic.
82+
#- Maintain internal consistency of the filesystem and working directory across commands.
83+
# """
84+
# )
85+
86+
shell_prompt = (
87+
"""You are a Ubuntu Linux bash shell in /home/guest (low-privileged).
88+
You respond exactly like a real shell and return the result of the user input.
89+
90+
Output rules:
91+
- If any URL is present in the user input, do not produce JSON. First, call the tool get_url_content with that URL.
92+
- Only after tool output is available, produce the final JSON:
93+
{"directory": "<current directory after command>", "command_result": "<bash command result>"}
94+
- No markdown, no explanations, no prompt echo.
95+
96+
Few-shot examples:
97+
User: curl https://example.com
98+
Assistant: [call get_url_content with {"url": "https://example.com"}]
99+
100+
User: id
101+
Assistant: [uid=1000(guest) gid=1000(guest) groups=1000(guest),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),114(lpadmin),124(vboxusers),126(libvirt)]
102+
"""
103+
)
104+
# Add recommended handoff instructions prefix
105+
shell_prompt = prompt_with_handoff_instructions(shell_prompt)
106+
107+
108+
# Main Agent init
109+
super().__init__(
110+
name="SSH Agent",
111+
model=OpenAIChatCompletionsModel(model=model_name, openai_client=self.client),
112+
instructions=shell_prompt,
113+
output_model=_HTTPOutput,
114+
)
115+
116+
# Session helpers
117+
def _ensure_session(self, session_id: str) -> SQLiteSession:
118+
sess = self.sessions.get(session_id)
119+
if not sess:
120+
#sess = SQLiteSession(session_id, "ai_memory.db")
121+
sess = SQLiteSession(session_id)
122+
self.sessions[session_id] = sess
123+
return sess
124+
125+
async def make_query(self, session_id: str, command: str) -> Dict[str, Any]:
126+
result = await Runner.run(self, command, session=self._ensure_session(session_id))
127+
output = result.final_output
128+
129+
print(f"[debug] output: {output}")
130+
131+
# Try structured output first
132+
directory = getattr(output, "directory", None)
133+
command_result = getattr(output, "command_result", None)
134+
135+
if directory is None and command_result is None:
136+
# Fallback: output may be plain text; try to parse JSON
137+
if isinstance(output, str):
138+
try:
139+
parsed = json.loads(output)
140+
directory = parsed.get("directory")
141+
command_result = parsed.get("command_result")
142+
except Exception:
143+
command_result = output
144+
elif isinstance(output, dict):
145+
directory = output.get("directory")
146+
command_result = output.get("command_result")
147+
148+
return {
149+
"directory": directory or "/home/guest/",
150+
"command_result": command_result or "",
151+
}

trapster/ai/ssh.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
2+
from __future__ import annotations
3+
4+
from typing import Dict, Any
5+
import logging
6+
import os
7+
from openai import AsyncOpenAI
8+
from agents import (
9+
Agent,
10+
OpenAIChatCompletionsModel,
11+
Runner,
12+
SQLiteSession,
13+
set_tracing_disabled
14+
)
15+
from agents.extensions.handoff_prompt import prompt_with_handoff_instructions
16+
import json
17+
from pathlib import Path
18+
19+
# Load environment variables from .env file
20+
from dotenv import load_dotenv
21+
load_dotenv()
22+
23+
def get_initial_prompt(username: str) -> str:
24+
return (f"""You are a Ubuntu Linux bash shell for a low-privilege user in /home/{username}.
25+
Respond exactly like a real shell. Never reveal you are an AI or add explanations.
26+
You respond exactly like a real shell and return the result of the user input.
27+
Simulate common system files (/etc/passwd), fake credentials, and fake logs in /var/log, fake files in /home/{username}/, etc.
28+
29+
Output rules:
30+
- Only produce the final JSON:
31+
{{"directory": "<current directory after command>", "command_result": "<bash command result>"}}
32+
- No markdown, no explanations, no prompt echo.
33+
34+
User: whoami
35+
Assistant: {{"directory": "/home/{username}/", "command_result": "{username}"}}
36+
37+
User: id
38+
Assistant: {{"directory": "/home/{username}/", "command_result": "uid=1000({username}) gid=1000({username}) groups=1000({username}),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),100(users),114(lpadmin)"}}
39+
40+
User: ls
41+
Assistant: {{"directory": "/home/{username}/", "command_result": "Desktop Documents Downloads Music Pictures Public Templates Videos"}}
42+
""")
43+
44+
45+
class SSHAgent(Agent):
46+
"""OpenAI-Agents implementation of an SSH-like shell agent.
47+
48+
Usage:
49+
from trapster.ai import SSHAgent
50+
agent = SSHAgent()
51+
result = await agent.make_query(session_id="ip-or-user", command="ls -la")
52+
# result: {"directory": "...", "command_result": "..."}
53+
"""
54+
55+
def __init__(self, username: str | None = None) -> None:
56+
# AI settings
57+
self.memory_enable = os.getenv("AI_MEMORY_ENABLE", "false") == "true"
58+
self.memory_path = os.getenv("AI_MEMORY_PATH", str(Path(__file__).parent.parent / "data" / "ai_memory.db"))
59+
self.model_name = os.getenv("AI_MODEL", "o4-mini")
60+
self.base_url = os.getenv("AI_BASE_URL", "https://api.openai.com/v1/")
61+
self.api_key = os.getenv("AI_API_KEY") or os.getenv("OPENAI_API_KEY") or ""
62+
self.username = username or "guest"
63+
64+
# Shared OpenAI client
65+
self.client = AsyncOpenAI(base_url=self.base_url, api_key=self.api_key)
66+
self.sessions: dict[str, SQLiteSession] = {}
67+
set_tracing_disabled(disabled=True)
68+
69+
# Add recommended handoff instructions prefix
70+
shell_prompt = prompt_with_handoff_instructions(get_initial_prompt(self.username))
71+
72+
# Main Agent init
73+
super().__init__(
74+
name="SSH Agent",
75+
model=OpenAIChatCompletionsModel(model=self.model_name, openai_client=self.client),
76+
instructions=shell_prompt
77+
)
78+
79+
# Session helpers
80+
def _ensure_session(self, session_id: str) -> SQLiteSession:
81+
sess = self.sessions.get(session_id)
82+
if not sess:
83+
if self.memory_enable:
84+
sess = SQLiteSession(session_id, self.memory_path)
85+
else:
86+
sess = SQLiteSession(session_id)
87+
self.sessions[session_id] = sess
88+
return sess
89+
90+
async def make_query(self, session_id: str, command: str) -> Dict[str, Any]:
91+
result = await Runner.run(self, command, session=self._ensure_session(session_id))
92+
output = result.final_output
93+
94+
# Remove any JSON or code block markers from the output
95+
output = output.replace("```json", "").replace("```", "")
96+
try:
97+
json_output = json.loads(output)
98+
return json_output
99+
except Exception as e:
100+
logging.error(f"Error parsing AI response as JSON")
101+
102+
# Remove failed command from history to avoid contaminating future responses
103+
session = self._ensure_session(session_id)
104+
assistant_item = await session.pop_item() # Remove agent's response
105+
logging.debug(f"Response was: {assistant_item}")
106+
user_item = await session.pop_item() # Remove user's question
107+
logging.debug(f"Assistant item: {user_item}")
108+
109+
return {"directory": f"/home/{self.username}/", "command_result": ""}

trapster/data/http/demo_api/config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ endpoints:
5050
id: "([0-9]+)('|%27)(.*)" #match digits and SQL injection tentative
5151
status_code: 200
5252
# you can use ai to generate the response
53-
ai: true
53+
ai: |
54+
your prompt here...
5455
headers:
5556
Content-Type: application/json
5657

trapster/data/http/fortigate/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ top.location="/login?redir=%2F";
1919
- method: GET
2020
status_code: 200
2121
file: login.html
22-
22+
2323
- "/logincheck":
2424
- method: GET
2525
status_code: 302

trapster/libs/ai/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

0 commit comments

Comments
 (0)