-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathagent.py
More file actions
424 lines (375 loc) · 17.2 KB
/
Copy pathagent.py
File metadata and controls
424 lines (375 loc) · 17.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
"""
Agentic Security Investigator — powered by Gemini Function Calling.
Instead of the admin deciding which logs to pull, Gemini autonomously:
1. Interprets the admin's natural-language request.
2. Calls the right workspace functions (fetch user logs, fetch org logs, etc.).
3. Analyzes the returned data.
4. Decides if more data is needed and fetches it.
5. Delivers a final investigation report.
The admin just describes the situation and the AI does the rest.
"""
import json
import google.generativeai as genai
from google.protobuf.struct_pb2 import Struct
from workspace_client import WorkspaceClient
import config
# ── Colors ──────────────────────────────────────────────────────
CYAN = "\033[0;36m"
GREEN = "\033[0;32m"
YELLOW = "\033[1;33m"
RED = "\033[0;31m"
BOLD = "\033[1m"
DIM = "\033[2m"
NC = "\033[0m"
# ── Tool definitions (what Gemini can call) ─────────────────────
# Using the manual function-declaration dict format for google.generativeai
TOOL_DECLARATIONS = [
{
"name": "fetch_user_logs",
"description": (
"Fetch Google Workspace audit logs for a SPECIFIC user. "
"Returns login, admin, drive, and token events for that user. "
"Use this when investigating a particular user."
),
"parameters": {
"type": "object",
"properties": {
"user_email": {
"type": "string",
"description": "The email address of the user to investigate."
},
"days_back": {
"type": "integer",
"description": "How many days of history to fetch. Default 7. Use 30 for deeper investigations."
},
"application": {
"type": "string",
"enum": ["login", "admin", "drive", "token", "user_accounts",
"gmail", "calendar", "chat", "meet", "mobile",
"groups_enterprise", "rules", "saml", "all"],
"description": (
"Which log type to fetch. Options: "
"'login' (sign-ins), 'admin' (admin changes), 'drive' (file activity), "
"'token' (OAuth grants), 'user_accounts' (email settings/forwarding/delegates), "
"'gmail' (email send/receive/spam), 'calendar', 'chat', 'meet', "
"'mobile' (device mgmt), 'groups_enterprise', 'rules' (DLP), "
"'saml' (SSO), or 'all' to fetch everything."
)
}
},
"required": ["user_email"]
}
},
{
"name": "fetch_org_logs",
"description": (
"Fetch Google Workspace audit logs for the ENTIRE organization. "
"Returns events for all users. Use this for org-wide sweeps or "
"when no specific user is mentioned."
),
"parameters": {
"type": "object",
"properties": {
"days_back": {
"type": "integer",
"description": "How many days of history to fetch. Default 1."
},
"application": {
"type": "string",
"enum": ["login", "admin", "drive", "token", "user_accounts",
"gmail", "calendar", "chat", "meet", "mobile",
"groups_enterprise", "rules", "saml"],
"description": (
"Which audit log category to fetch. Use 'gmail' or 'user_accounts' "
"for email-related investigations."
)
}
},
"required": ["application"]
}
},
{
"name": "list_available_log_types",
"description": (
"Returns the list of audit log categories available to query. "
"Call this if you need to know what data sources are available."
),
"parameters": {
"type": "object",
"properties": {}
}
}
]
SYSTEM_PROMPT = """You are an autonomous cybersecurity AI agent embedded in a Google Workspace
security operations center. You have direct access to the organization's audit logs through
function calls.
When an administrator describes a security concern, you should:
1. THINK about what data you need to investigate the issue.
2. CALL the appropriate functions to fetch the relevant logs.
3. ANALYZE the returned data thoroughly.
4. If you need MORE data to complete the investigation, call more functions.
5. Provide a comprehensive security report.
You can call multiple functions in sequence. For example, if someone reports a compromised
account, you might:
- First fetch their login logs to check for suspicious sign-ins.
- Then fetch their drive logs to check for data exfiltration.
- Then fetch their token logs to check for unauthorized app access.
- Then fetch org-wide admin logs to see if privileges were escalated.
ALWAYS fetch data before making conclusions. Never guess — use the tools.
When delivering findings, use this structure:
- **Summary**: One-paragraph overview.
- **Timeline**: Key events in chronological order.
- **Risk Level**: Low / Medium / High / Critical with justification.
- **Indicators of Compromise**: Specific suspicious findings.
- **Recommended Actions**: Concrete steps the admin should take.
Be thorough but concise. The administrator is technical and expects professional SOC-level analysis."""
class AgentInvestigator:
"""
An agentic investigation engine that lets Gemini autonomously decide
which Workspace logs to fetch and analyze.
"""
def __init__(self):
if not config.GEMINI_API_KEY:
raise ValueError("GEMINI_API_KEY is not set.")
genai.configure(api_key=config.GEMINI_API_KEY)
self.workspace = WorkspaceClient()
self._model_index = 0
self._history = [] # preserved across model switches
self._build_model()
def _build_model(self):
"""Build (or rebuild) the GenerativeModel with the current model index."""
model_name = config.MODELS[self._model_index]
print(f" {DIM}Using model: {model_name}{NC}")
self.model = genai.GenerativeModel(
model_name,
tools=[{"function_declarations": TOOL_DECLARATIONS}],
system_instruction=SYSTEM_PROMPT
)
self.chat = self.model.start_chat(
history=list(self._history),
enable_automatic_function_calling=False
)
def start_session(self):
"""Start a new agentic chat session (resets history)."""
self._model_index = 0
self._history = []
self._build_model()
self._turn_count = 0
def _switch_model(self):
"""Try the next model in the fallback list. Returns True if switched."""
self._model_index += 1
if self._model_index >= len(config.MODELS):
return False
print(f" {YELLOW}↻ Switching model...{NC}")
self._build_model()
return True
def _send_with_retry(self, message, max_retries=3):
"""Send a message with retry logic + automatic model fallback."""
import time
while True:
for attempt in range(max_retries):
try:
response = self.chat.send_message(message)
# Save history on success (for model-switch resilience)
self._history = list(self.chat.history)
return response
except Exception as e:
error_str = str(e)
if "429" in error_str or "quota" in error_str.lower():
wait = 2 ** attempt * 2 # 2s, 4s, 8s
print(f" {YELLOW}⏳ Rate limited — waiting {wait}s before retry ({attempt+1}/{max_retries})...{NC}")
time.sleep(wait)
else:
raise e
# All retries exhausted — try next model
if not self._switch_model():
raise Exception(
"All models rate-limited. Wait a minute and try again."
)
def process_query(self, user_query):
"""
Send a query to the agent and run the agentic loop.
The agent will autonomously call functions until it has enough data
to deliver a final answer.
"""
if not self.chat:
self.start_session()
self._turn_count += 1
print(f"\n{DIM}Agent is thinking...{NC}")
try:
response = self._send_with_retry(user_query)
except Exception as e:
return f"Error communicating with Gemini: {e}"
# Agentic loop: keep executing function calls until the model returns text
max_iterations = 10 # safety limit
iteration = 0
while iteration < max_iterations:
iteration += 1
# Check if the response contains function calls
function_calls = self._extract_function_calls(response)
if not function_calls:
# No more function calls — the model has a final answer
break
# Execute each function call and collect results
function_responses = []
for fc in function_calls:
fname = fc.name
fargs = dict(fc.args) if fc.args else {}
print(f" {GREEN}⚡ Agent calling:{NC} {BOLD}{fname}{NC}({self._format_args(fargs)})")
result = self._execute_function(fname, fargs)
# Build the function response part
function_responses.append(
genai.protos.Part(
function_response=genai.protos.FunctionResponse(
name=fname,
response=self._to_proto_struct({"result": result})
)
)
)
# Send function results back to the model
print(f" {DIM}Agent analyzing results...{NC}")
try:
response = self._send_with_retry(
genai.protos.Content(parts=function_responses)
)
except Exception as e:
return f"Error in agentic loop: {e}"
# Extract the final text response
if response.candidates and response.candidates[0].content.parts:
final_parts = []
for part in response.candidates[0].content.parts:
if hasattr(part, 'text') and part.text:
final_parts.append(part.text)
return "\n".join(final_parts) if final_parts else "Agent completed but produced no output."
return "Agent completed but produced no output."
def _extract_function_calls(self, response):
"""Extract function call parts from a model response."""
calls = []
if response.candidates and response.candidates[0].content.parts:
for part in response.candidates[0].content.parts:
if hasattr(part, 'function_call') and part.function_call.name:
calls.append(part.function_call)
return calls
def _execute_function(self, name, args):
"""Execute a workspace function by name and return the result."""
# Gemini sometimes passes days_back as a float (e.g., 30.0)
if 'days_back' in args:
args['days_back'] = int(args['days_back'])
if name == "fetch_user_logs":
return self._fn_fetch_user_logs(**args)
elif name == "fetch_org_logs":
return self._fn_fetch_org_logs(**args)
elif name == "list_available_log_types":
return self._fn_list_log_types()
else:
return {"error": f"Unknown function: {name}"}
# ── Log summarization (reduces token count) ─────────────────
def _summarize_events(self, events, max_events=50):
"""
Strip bulky nested fields from log events to reduce token usage.
Keeps only the security-relevant fields.
"""
summarized = []
for event in events[:max_events]:
summary = {}
# Keep key fields
for key in ('id', 'actor', 'ipAddress', 'events', 'kind'):
if key in event:
val = event[key]
# For 'events', flatten to just name + parameters
if key == 'events' and isinstance(val, list):
summary['events'] = []
for ev in val:
flat = {"name": ev.get("name", "")}
params = ev.get("parameters", [])
if params:
flat["parameters"] = {
p.get("name", ""): (
p.get("value") or p.get("boolValue") or
p.get("intValue") or p.get("multiValue", "")
)
for p in params[:10] # cap params per event
}
summary['events'].append(flat)
else:
summary[key] = val
summarized.append(summary)
result = summarized
if len(events) > max_events:
result.append({"_note": f"Showing {max_events} of {len(events)} total events"})
return result
# ── Function implementations ────────────────────────────────
def _fn_fetch_user_logs(self, user_email, days_back=7, application="all"):
"""Fetch logs for a specific user."""
if application == "all":
logs = {}
total = 0
for app in config.APPLICATIONS_TO_MONITOR:
events = self.workspace.fetch_logs(
app, user_key=user_email, days_back=days_back
)
logs[app] = self._summarize_events(events)
total += len(events)
return {
"user": user_email,
"days_back": days_back,
"total_events": total,
"logs": logs
}
else:
events = self.workspace.fetch_logs(
application, user_key=user_email, days_back=days_back
)
return {
"user": user_email,
"application": application,
"days_back": days_back,
"total_events": len(events),
"logs": self._summarize_events(events)
}
def _fn_fetch_org_logs(self, application, days_back=1):
"""Fetch org-wide logs for a specific application."""
events = self.workspace.fetch_logs(
application, user_key='all', days_back=days_back
)
return {
"application": application,
"days_back": days_back,
"total_events": len(events),
"logs": self._summarize_events(events)
}
def _fn_list_log_types(self):
"""List available log types."""
return {
"available_log_types": config.APPLICATIONS_TO_MONITOR,
"descriptions": {
"login": "User sign-in events, failed attempts, suspicious locations",
"admin": "Admin console changes, privilege modifications, org settings",
"drive": "File sharing, downloads, external sharing, deletions",
"token": "OAuth token grants, third-party app authorizations",
"user_accounts": "Email forwarding rules, delegates, password changes, POP/IMAP",
"gmail": "Email send/receive activity, spam, phishing reports",
"calendar": "Calendar sharing, event modifications",
"chat": "Google Chat messages and spaces",
"meet": "Meet calls, participants, recordings",
"mobile": "Mobile device activity, wipes, policy changes",
"groups_enterprise": "Group membership changes, settings",
"rules": "DLP rule triggers and actions",
"saml": "SAML SSO login events"
}
}
# ── Helpers ──────────────────────────────────────────────────
def _format_args(self, args):
"""Format function args for display."""
parts = []
for k, v in args.items():
parts.append(f"{k}={repr(v)}")
return ", ".join(parts)
def _to_proto_struct(self, data):
"""Convert a Python dict to a protobuf Struct for function responses."""
# We need to serialize to JSON-safe format first
# (the API expects simple types, not complex nested objects with large log data)
safe_data = json.loads(json.dumps(data, default=str))
struct = Struct()
struct.update(safe_data)
return struct