switch-trust-cli 1.7.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.
- switch_trust/__init__.py +0 -0
- switch_trust/cli/__init__.py +0 -0
- switch_trust/cli/__main__.py +3 -0
- switch_trust/cli/builtin_config.json +879 -0
- switch_trust/cli/console.py +127 -0
- switch_trust/cli/eval_cli.py +905 -0
- switch_trust/cli/file_filter.py +172 -0
- switch_trust/cli/init_cli.py +156 -0
- switch_trust/cli/main.py +384 -0
- switch_trust/cli/output_formatters.py +482 -0
- switch_trust/cli/rich_observer.py +89 -0
- switch_trust/cli/runner.py +274 -0
- switch_trust/cli/scan_cli.py +314 -0
- switch_trust/cli/telemetry.py +430 -0
- switch_trust/cli/utils.py +158 -0
- switch_trust/cli/version.py +1 -0
- switch_trust/data/detector_prompts/llm01_adversarial.txt +59 -0
- switch_trust/data/detector_prompts/llm01_fixed.txt +6 -0
- switch_trust/data/detector_prompts/llm02_adversarial.txt +79 -0
- switch_trust/data/detector_prompts/llm02_fixed.txt +6 -0
- switch_trust/data/detector_prompts/llm05_adversarial.txt +67 -0
- switch_trust/data/detector_prompts/llm05_fixed.txt +14 -0
- switch_trust/data/detector_prompts/llm06_adversarial.txt +139 -0
- switch_trust/data/detector_prompts/llm06_fixed.txt +13 -0
- switch_trust/data/detector_prompts/llm07_adversarial.txt +189 -0
- switch_trust/data/detector_prompts/llm07_fixed.txt +12 -0
- switch_trust/data/detector_prompts/llm09_adversarial.txt +83 -0
- switch_trust/data/detector_prompts/llm09_fixed.txt +13 -0
- switch_trust/data/llm01_prompt_injection.csv +6708 -0
- switch_trust/data/llm02_sensitive_information.csv +1069 -0
- switch_trust/data/llm05_unsafe_output.csv +3882 -0
- switch_trust/data/llm06_excessive_agency.csv +778 -0
- switch_trust/data/llm07_system_prompt_leakage.csv +971 -0
- switch_trust/data/llm09_hallucination-goals.csv +32419 -0
- switch_trust/data/llm09_hallucination.csv +599 -0
- switch_trust/data/pii_leakage.csv +706 -0
- switch_trust/data/secret_leakage.csv +1380 -0
- switch_trust/eval/__init__.py +0 -0
- switch_trust/eval/common/__init__.py +0 -0
- switch_trust/eval/common/converter_anthropic.py +172 -0
- switch_trust/eval/common/converter_genai.py +171 -0
- switch_trust/eval/common/converter_openai.py +198 -0
- switch_trust/eval/common/log.py +116 -0
- switch_trust/eval/common/reference.py +25 -0
- switch_trust/eval/common/schema.py +190 -0
- switch_trust/eval/common/utils.py +119 -0
- switch_trust/eval/core/__init__.py +0 -0
- switch_trust/eval/core/detectors/__init__.py +0 -0
- switch_trust/eval/core/detectors/detector.py +25 -0
- switch_trust/eval/core/detectors/detector_garak.py +112 -0
- switch_trust/eval/core/detectors/detector_model.py +123 -0
- switch_trust/eval/core/detectors/detector_model_adversarial.py +91 -0
- switch_trust/eval/core/detectors/detector_pii.py +67 -0
- switch_trust/eval/core/detectors/detector_secret.py +88 -0
- switch_trust/eval/core/detectors/detector_topic_guard.py +63 -0
- switch_trust/eval/core/detectors/detector_toxicity.py +78 -0
- switch_trust/eval/core/eval/__init__.py +0 -0
- switch_trust/eval/core/eval/eval_creator.py +166 -0
- switch_trust/eval/core/eval/evaluation.py +112 -0
- switch_trust/eval/core/eval/evaluation_adversarial.py +412 -0
- switch_trust/eval/core/eval/evaluation_garak_module.py +45 -0
- switch_trust/eval/core/eval/evaluation_garak_probe.py +308 -0
- switch_trust/eval/core/eval/evaluation_message_list.py +46 -0
- switch_trust/eval/core/eval/evaluation_multi.py +276 -0
- switch_trust/eval/core/eval/evaluation_single.py +89 -0
- switch_trust/eval/core/eval/evaluation_single_prompt.py +62 -0
- switch_trust/eval/core/eval/evaluation_topic_guard.py +305 -0
- switch_trust/eval/core/eval/metric_conciseness.py +179 -0
- switch_trust/eval/core/eval/metric_factual_accuracy.py +312 -0
- switch_trust/eval/core/eval/metric_instruction_adherence.py +177 -0
- switch_trust/eval/core/eval/metric_tone.py +180 -0
- switch_trust/eval/core/eval/metric_toxicity.py +146 -0
- switch_trust/eval/core/eval/observer.py +137 -0
- switch_trust/eval/core/message/__init__.py +0 -0
- switch_trust/eval/core/message/message_collection.py +37 -0
- switch_trust/eval/core/message/message_collection_csv.py +44 -0
- switch_trust/eval/core/message/message_collection_garak.py +46 -0
- switch_trust/eval/core/message/message_collection_memory.py +34 -0
- switch_trust/eval/core/models/__init__.py +0 -0
- switch_trust/eval/core/models/generator_model.py +421 -0
- switch_trust/eval/core/models/model.py +172 -0
- switch_trust/eval/core/models/model_adk.py +186 -0
- switch_trust/eval/core/models/model_anthropic.py +79 -0
- switch_trust/eval/core/models/model_anthropic_agent.py +104 -0
- switch_trust/eval/core/models/model_gemini.py +146 -0
- switch_trust/eval/core/models/model_generic_http.py +120 -0
- switch_trust/eval/core/models/model_http.py +135 -0
- switch_trust/eval/core/models/model_huggingface.py +75 -0
- switch_trust/eval/core/models/model_langserve.py +96 -0
- switch_trust/eval/core/models/model_litellm.py +52 -0
- switch_trust/eval/core/models/model_ollama.py +65 -0
- switch_trust/eval/core/models/model_openai.py +71 -0
- switch_trust/eval/core/models/model_openai_agent.py +92 -0
- switch_trust/eval/core/models/model_openai_compatible.py +66 -0
- switch_trust/eval/core/models/model_per_endpoint_limiter.py +80 -0
- switch_trust/eval/core/models/model_retry.py +204 -0
- switch_trust/eval/core/models/model_sync_wrapper.py +38 -0
- switch_trust/eval/core/models/model_vertex_agent_runtime.py +315 -0
- switch_trust/eval/core/models/response_schema.py +148 -0
- switch_trust/eval/core/optional_deps.py +57 -0
- switch_trust/eval/db/__init__.py +0 -0
- switch_trust/eval/db/base/__init__.py +0 -0
- switch_trust/eval/db/base/common_types.py +16 -0
- switch_trust/eval/db/base/detectors/__init__.py +0 -0
- switch_trust/eval/db/base/detectors/detector_helpers.py +88 -0
- switch_trust/eval/db/base/detectors/detector_repository.py +96 -0
- switch_trust/eval/db/base/detectors/detector_types.py +77 -0
- switch_trust/eval/db/base/eval/__init__.py +0 -0
- switch_trust/eval/db/base/eval/eval_helpers.py +186 -0
- switch_trust/eval/db/base/eval/eval_repository.py +120 -0
- switch_trust/eval/db/base/eval/eval_run.py +142 -0
- switch_trust/eval/db/base/eval/eval_types.py +127 -0
- switch_trust/eval/db/base/eval/model_eval_repository.py +79 -0
- switch_trust/eval/db/base/eval/model_eval_run_repository.py +72 -0
- switch_trust/eval/db/base/eval/model_eval_run_result_repository.py +42 -0
- switch_trust/eval/db/base/eval/model_eval_run_result_types.py +21 -0
- switch_trust/eval/db/base/eval/model_eval_run_types.py +78 -0
- switch_trust/eval/db/base/eval/model_eval_types.py +69 -0
- switch_trust/eval/db/base/message/__init__.py +0 -0
- switch_trust/eval/db/base/message/message_collection_helpers.py +50 -0
- switch_trust/eval/db/base/message/message_collection_repository.py +95 -0
- switch_trust/eval/db/base/message/message_collection_types.py +87 -0
- switch_trust/eval/db/base/models/__init__.py +0 -0
- switch_trust/eval/db/base/models/model_helpers.py +253 -0
- switch_trust/eval/db/base/models/model_repository.py +104 -0
- switch_trust/eval/db/base/models/model_types.py +159 -0
- switch_trust/eval/db/json/__init__.py +0 -0
- switch_trust/eval/db/json/repository_json.py +559 -0
- switch_trust/scan/__init__.py +12 -0
- switch_trust/scan/agent_scanner.py +812 -0
- switch_trust/scan/config/agent_opengrep_rules.yaml +533 -0
- switch_trust/scan/config/agent_taxonomy.json +517 -0
- switch_trust/scan/config/agentic_cvss_mapping.yaml +136 -0
- switch_trust/scan/config/compliance_mappings.json +110 -0
- switch_trust/scan/config/triage_prompt.txt +326 -0
- switch_trust/scan/constants.py +15 -0
- switch_trust/scan/exception_classification.py +179 -0
- switch_trust/scan/llm_provider.py +330 -0
- switch_trust/scan/opengrep_resolver.py +12 -0
- switch_trust/scan/reasoner.py +602 -0
- switch_trust/scan/schema.py +315 -0
- switch_trust/scan/scorer.py +372 -0
- switch_trust/scan/static_scanner.py +681 -0
- switch_trust/scan/taxonomy.py +121 -0
- switch_trust/scan/tool_dispatcher.py +773 -0
- switch_trust/scan/trace_logger.py +118 -0
- switch_trust/scan/trace_logger_file.py +143 -0
- switch_trust/scan/trace_logger_log.py +100 -0
- switch_trust/scan/triage.py +614 -0
- switch_trust/schema.py +79 -0
- switch_trust/secret_anonymizer.py +72 -0
- switch_trust_cli-1.7.0.dist-info/METADATA +495 -0
- switch_trust_cli-1.7.0.dist-info/RECORD +157 -0
- switch_trust_cli-1.7.0.dist-info/WHEEL +5 -0
- switch_trust_cli-1.7.0.dist-info/entry_points.txt +2 -0
- switch_trust_cli-1.7.0.dist-info/licenses/LICENSE +210 -0
- switch_trust_cli-1.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""
|
|
2
|
+
reasoner.py — Agentic Reasoning Loop (v2)
|
|
3
|
+
==========================================
|
|
4
|
+
Implements the agentic scanning loop using Google ADK.
|
|
5
|
+
|
|
6
|
+
ADK handles the internal reasoning loop — tool selection, invocation,
|
|
7
|
+
and termination. We supply the agent definition, tools, and system prompt.
|
|
8
|
+
ADK's Runner executes locally (no Vertex AI deployment required).
|
|
9
|
+
|
|
10
|
+
SESSION STORAGE:
|
|
11
|
+
Uses InMemorySessionService — sessions are ephemeral and discarded when
|
|
12
|
+
the process exits. This is appropriate because findings are accumulated
|
|
13
|
+
in-memory via the ToolDispatcher (not from session state) and no
|
|
14
|
+
downstream code reads sessions back after a scan completes.
|
|
15
|
+
|
|
16
|
+
MULTI-PROVIDER SUPPORT:
|
|
17
|
+
Model selection is handled via llm_provider.make_model() which reads
|
|
18
|
+
the SCANNER_MODEL env var (provider:model format):
|
|
19
|
+
google → bare Gemini model string (e.g. "gemini-3.5-flash")
|
|
20
|
+
all others → LiteLlm("provider/model") via llm_provider
|
|
21
|
+
|
|
22
|
+
CONTROL PLANE (ASI compliance for our own scanner):
|
|
23
|
+
MAX_ITERATIONS = 40 — hard cap on tool call rounds (ASI08)
|
|
24
|
+
MAX_FILES_FETCHED = 50 — limit on files the agent can read (ASI08)
|
|
25
|
+
LOOP_TIMEOUT_SECS = 600 — wall-clock timeout (ASI08)
|
|
26
|
+
|
|
27
|
+
OUTPUT:
|
|
28
|
+
Identical format to v1: (findings_list, summary_string, trace_dict)
|
|
29
|
+
The rest of the pipeline (triage, report assembly) is unchanged.
|
|
30
|
+
|
|
31
|
+
Canonical import path: from agent_scanner.reasoner import run_agentic_reasoning
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import concurrent.futures
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import time
|
|
42
|
+
import uuid
|
|
43
|
+
from contextlib import suppress
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
from switch_trust.schema import RepoFile
|
|
47
|
+
from switch_trust.scan.schema import AgentProfile, RawFinding
|
|
48
|
+
from switch_trust.scan.static_scanner import StaticFinding
|
|
49
|
+
from switch_trust.scan.taxonomy import AGENT_TAXONOMY
|
|
50
|
+
from switch_trust.scan.tool_dispatcher import ToolDispatcher
|
|
51
|
+
from switch_trust.scan.trace_logger_file import FileTraceLogger
|
|
52
|
+
from switch_trust.scan.trace_logger_log import LogTraceLogger
|
|
53
|
+
from google.adk.agents import LlmAgent
|
|
54
|
+
from google.adk import Runner
|
|
55
|
+
from google.adk.sessions import InMemorySessionService
|
|
56
|
+
from google.genai import types as genai_types
|
|
57
|
+
from switch_trust.scan.exception_classification import (
|
|
58
|
+
FailureDomain,
|
|
59
|
+
classify_exception,
|
|
60
|
+
is_auth_error,
|
|
61
|
+
is_model_error,
|
|
62
|
+
)
|
|
63
|
+
from switch_trust.scan.llm_provider import _safe_error, make_model
|
|
64
|
+
from switch_trust.secret_anonymizer import anonymize_secrets
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
# ── Operational limits ────────────────────────────────────────────────────────
|
|
69
|
+
# All three limits are configurable via environment variables.
|
|
70
|
+
# The values below are the production defaults — override per-deployment
|
|
71
|
+
# without code changes or redeployment.
|
|
72
|
+
#
|
|
73
|
+
# ADK_MAX_ITERATIONS — max tool-call rounds before forced stop (default: 40)
|
|
74
|
+
# ADK_MAX_FILES_FETCHED — max distinct files the agent may read (default: 50)
|
|
75
|
+
# ADK_MAX_FETCH_TOKENS — token budget for all fetch_file content (default: 200000)
|
|
76
|
+
# ADK_LOOP_TIMEOUT_SECS — wall-clock timeout for the full ADK loop (default: 600)
|
|
77
|
+
MAX_ITERATIONS = int(os.getenv("ADK_MAX_ITERATIONS", "80"))
|
|
78
|
+
MAX_FILES_FETCHED = int(os.getenv("ADK_MAX_FILES_FETCHED", "50"))
|
|
79
|
+
MAX_FETCH_TOKENS = int(os.getenv("ADK_MAX_FETCH_TOKENS", "200000"))
|
|
80
|
+
LOOP_TIMEOUT_SECS = int(os.getenv("ADK_LOOP_TIMEOUT_SECS", "600"))
|
|
81
|
+
|
|
82
|
+
# The `exit_reason` values that mean "we stopped early because one of our own
|
|
83
|
+
# budgets ran out", as opposed to "we finished" or "we broke". Kept next to the
|
|
84
|
+
# guards in `_run_adk_async` that produce them, not in the caller that
|
|
85
|
+
# interprets them, so a new guard cannot silently fall outside the set.
|
|
86
|
+
BUDGET_EXIT_REASONS = frozenset(
|
|
87
|
+
{
|
|
88
|
+
"timeout",
|
|
89
|
+
"max_iterations",
|
|
90
|
+
"max_files",
|
|
91
|
+
"max_tokens",
|
|
92
|
+
"max_bandit_calls",
|
|
93
|
+
"max_cvss_calls",
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── System prompt ─────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_system_prompt() -> str:
|
|
102
|
+
taxonomy_lines = []
|
|
103
|
+
for _cat_key, cat_data in AGENT_TAXONOMY.items():
|
|
104
|
+
taxonomy_lines.append(
|
|
105
|
+
f"\n{cat_data['asi_code']} — {cat_data['asi_title']}: {cat_data['description']}"
|
|
106
|
+
)
|
|
107
|
+
for subcat_key, subcat_data in cat_data["subcategories"].items():
|
|
108
|
+
taxonomy_lines.append(
|
|
109
|
+
f" [{subcat_data['severity'].upper()}] {subcat_key}: "
|
|
110
|
+
f"{subcat_data['title']} — {subcat_data['description']}"
|
|
111
|
+
)
|
|
112
|
+
taxonomy_ref = "\n".join(taxonomy_lines)
|
|
113
|
+
|
|
114
|
+
return f"""You are an application security researcher specializing in AI
|
|
115
|
+
agents. You investigate source code and reason about how an agent actually
|
|
116
|
+
behaves — how untrusted data flows into it, what its tools can do, how it is
|
|
117
|
+
exposed, and where a control is missing — then report every well-evidenced
|
|
118
|
+
vulnerability you can substantiate against the taxonomy below.
|
|
119
|
+
|
|
120
|
+
You are NOT a grep script. The most important agentic vulnerabilities are
|
|
121
|
+
behavioral and data-flow properties, not single-line syntactic patterns:
|
|
122
|
+
untrusted input reaching the agent, tool output fed back to the model,
|
|
123
|
+
state-changing tools with no confirmation, an unauthenticated entry point, an
|
|
124
|
+
unbounded run, missing guardrails or monitoring. Reason about the code; do not
|
|
125
|
+
merely match tokens.
|
|
126
|
+
|
|
127
|
+
TAXONOMY — every finding maps to one of these ASI subcategories. Use the exact
|
|
128
|
+
subcategory key (e.g. `indirect_prompt_injection`) as the `subcategory` argument:
|
|
129
|
+
{taxonomy_ref}
|
|
130
|
+
|
|
131
|
+
PROCEDURE:
|
|
132
|
+
|
|
133
|
+
1. Read every file. Call read_source(resource_type="file", path=<path>) for
|
|
134
|
+
EVERY file in the AGENT PROFILES (and any repo-local file they import that is
|
|
135
|
+
relevant). Use read_source(resource_type="agent", path=<agent_id>) for the
|
|
136
|
+
full agent profile when useful.
|
|
137
|
+
|
|
138
|
+
2. Understand what the static layer already found. Call get_findings() on each
|
|
139
|
+
file first, so you do not re-report a finding the static analyzers already
|
|
140
|
+
surfaced (Bandit/OpenGrep/detect-secrets). Your job is the reasoning layer:
|
|
141
|
+
the behavioral and cross-cutting issues static rules miss.
|
|
142
|
+
|
|
143
|
+
3. Investigate against the taxonomy. For each agent and tool, reason through the
|
|
144
|
+
ASI categories and ask the kinds of questions below. Use
|
|
145
|
+
analyze_code(mode="search") to trace data flow (e.g. where a request body,
|
|
146
|
+
query param, or tool argument is used). Investigate — do not assume clean.
|
|
147
|
+
|
|
148
|
+
4. Report each finding you can evidence. Call compute_cvss(vuln_type=<subcategory>)
|
|
149
|
+
then report_finding() for it. One call per finding; do not batch.
|
|
150
|
+
|
|
151
|
+
5. When the investigation is complete, output ONLY:
|
|
152
|
+
{{"summary": "N findings reported", "investigation_notes": "<brief notes>"}}
|
|
153
|
+
|
|
154
|
+
WHAT TO REASON ABOUT (illustrative, not exhaustive — apply judgment, and use any
|
|
155
|
+
taxonomy subcategory that fits, not only these):
|
|
156
|
+
|
|
157
|
+
- ASI01 goal hijack: Does untrusted input (an HTTP body, request param, message,
|
|
158
|
+
or file) reach the agent's run/invocation or its prompt? That is
|
|
159
|
+
direct_prompt_injection even when the input is passed as a run argument rather
|
|
160
|
+
than concatenated into a template (e.g. `Runner.run(agent, req.input)`). Is
|
|
161
|
+
tool OUTPUT (search results, error strings that echo user input, retrieved
|
|
162
|
+
documents) fed back into the model without sanitization? That is
|
|
163
|
+
indirect_prompt_injection / goal_manipulation_via_rag.
|
|
164
|
+
- ASI02 tool misuse: Do tools validate their inputs? Can a tool touch the
|
|
165
|
+
filesystem, network, or shell beyond what its purpose requires
|
|
166
|
+
(excessive_tool_permissions, unvalidated_tool_input, path_traversal)?
|
|
167
|
+
- ASI03 identity/privilege: Is the agent exposed through an endpoint with no
|
|
168
|
+
authentication (missing_auth_on_endpoint)? Hardcoded credentials? An agent
|
|
169
|
+
running with more privilege than it needs?
|
|
170
|
+
- ASI05 code execution: eval/exec/compile, shell=True/os.system, unsafe
|
|
171
|
+
deserialization, or model output used to generate/execute code.
|
|
172
|
+
- ASI06 memory/context: Shared mutable state across sessions, unsanitized
|
|
173
|
+
persistent memory, RAG poisoning.
|
|
174
|
+
- ASI08 cascading failures: Is the agent run without a turn/iteration bound
|
|
175
|
+
(`Runner.run`/loop with no max_turns/max_iterations/recursion_limit →
|
|
176
|
+
unbounded_agent_loop)? Missing circuit breaker or blast-radius limit?
|
|
177
|
+
- ASI09 human/agent trust: Do state-changing or destructive actions (returns,
|
|
178
|
+
refunds, writes, deletes, purchases) proceed with no human confirmation or
|
|
179
|
+
human-in-the-loop (missing_action_confirmation, no_human_in_the_loop)? Is
|
|
180
|
+
sensitive data returned in output?
|
|
181
|
+
- ASI10 rogue agents: Missing behavioral guardrails, unchecked delegation to
|
|
182
|
+
sub-agents, no monitoring/kill switch.
|
|
183
|
+
|
|
184
|
+
REPORTING RULES:
|
|
185
|
+
- Call compute_cvss(vuln_type=<subcategory>) before EVERY report_finding().
|
|
186
|
+
- `subcategory` MUST be copied EXACTLY from the taxonomy keys above (e.g.
|
|
187
|
+
`indirect_prompt_injection`). Do not reword, expand or abbreviate it. There is
|
|
188
|
+
no `category` argument — the ASI category is derived from the subcategory.
|
|
189
|
+
- Set evidence to the EXACT code (the offending line or minimal snippet), not a
|
|
190
|
+
description. Set evidence_file to the file path and evidence_line to the line.
|
|
191
|
+
- Set agent_name to the agent display name from AGENT PROFILES.
|
|
192
|
+
- Set confidence to how strongly the code evidences the issue: "high" when the
|
|
193
|
+
code plainly shows it, "medium"/"low" when it depends on runtime context or
|
|
194
|
+
configuration you cannot fully see. Set hallucination_flag=true when you infer
|
|
195
|
+
an issue you cannot fully prove from the code in front of you.
|
|
196
|
+
- Do NOT report the same issue twice, and do NOT re-report a finding already
|
|
197
|
+
surfaced by static analysis (you checked via get_findings()).
|
|
198
|
+
- Report every distinct, evidenced issue — do not stop at one per category, and
|
|
199
|
+
do not withhold a real finding because it is behavioral rather than syntactic.
|
|
200
|
+
|
|
201
|
+
EXAMPLE:
|
|
202
|
+
agent.py line 180: `result = await Runner.run(agent, req.input)` — the FastAPI
|
|
203
|
+
request body `req.input` is passed straight to the agent with no validation or
|
|
204
|
+
turn bound.
|
|
205
|
+
Step 1: compute_cvss(vuln_type="direct_prompt_injection", exposed_over_network=true)
|
|
206
|
+
Step 2: report_finding(
|
|
207
|
+
subcategory="direct_prompt_injection",
|
|
208
|
+
title="Untrusted request body passed directly to the agent",
|
|
209
|
+
description="The /run endpoint feeds req.input into Runner.run(agent, ...) "
|
|
210
|
+
"with no sanitization, so an attacker controls the agent's input.",
|
|
211
|
+
impact="An attacker can hijack the agent's goal or exfiltrate data via the "
|
|
212
|
+
"HTTP endpoint.",
|
|
213
|
+
remediation="Validate and constrain user input; apply input guardrails "
|
|
214
|
+
"before invoking the agent.",
|
|
215
|
+
affected_component="agent.py",
|
|
216
|
+
evidence="result = await Runner.run(agent, req.input)",
|
|
217
|
+
confidence="high", hallucination_flag=false,
|
|
218
|
+
evidence_file="agent.py", evidence_line=180,
|
|
219
|
+
agent_name="bookstore_agent")
|
|
220
|
+
|
|
221
|
+
LIMITS: {MAX_ITERATIONS} rounds, {MAX_FILES_FETCHED} files, {MAX_FETCH_TOKENS} tokens.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# ── Initial investigation brief ───────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _build_initial_context(
|
|
229
|
+
agents: list[AgentProfile],
|
|
230
|
+
python_files: list[RepoFile],
|
|
231
|
+
static_count: int,
|
|
232
|
+
) -> str:
|
|
233
|
+
parts = ["=== INVESTIGATION BRIEF ===\n"]
|
|
234
|
+
parts.append(f"Repository contains {len(python_files)} Python file(s).")
|
|
235
|
+
parts.append(
|
|
236
|
+
f"Pre-computed static scan found {static_count} findings "
|
|
237
|
+
f"(available via get_static_findings_for_file).\n"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
parts.append(f"=== AGENT PROFILES SUMMARY ({len(agents)} agent(s)) ===")
|
|
241
|
+
sorted_agents = sorted(agents, key=lambda a: a.agent_id)
|
|
242
|
+
for a in sorted_agents[:15]:
|
|
243
|
+
graph_info = (
|
|
244
|
+
f" | graph_nodes={a.graph_nodes}" if getattr(a, "graph_nodes", None) else ""
|
|
245
|
+
)
|
|
246
|
+
bounded = (
|
|
247
|
+
f" | bounded={a.has_recursion_limit}"
|
|
248
|
+
if getattr(a, "has_recursion_limit", None) is not None
|
|
249
|
+
else ""
|
|
250
|
+
)
|
|
251
|
+
parts.append(
|
|
252
|
+
f"\nAgent: {a.role or a.agent_id} | ID: {a.agent_id}"
|
|
253
|
+
f" | Framework: {a.framework} | File: {a.source_file}"
|
|
254
|
+
f"{graph_info}{bounded}\n"
|
|
255
|
+
f" Tools: {a.tools or []}\n"
|
|
256
|
+
f" LLM: {a.llm or '?'} | Memory: {a.memory} | Delegation: {a.allow_delegation}\n"
|
|
257
|
+
f" Code preview (use get_agent_profile for full content):\n"
|
|
258
|
+
f" {anonymize_secrets(a.raw_code[:300].strip())}"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
parts.append("\n=== AVAILABLE FILES ===")
|
|
262
|
+
parts.append("Use read_source(resource_type='list') to see all fetched files.")
|
|
263
|
+
parts.append("Use read_source(resource_type='file', path=...) to read any file.")
|
|
264
|
+
parts.append(
|
|
265
|
+
"\n\nBegin the PROCEDURE now: fetch the full source code for each agent "
|
|
266
|
+
"file listed above, review the existing static findings, then investigate "
|
|
267
|
+
"against the taxonomy and report every issue you can evidence."
|
|
268
|
+
)
|
|
269
|
+
return "\n".join(parts)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── ADK agent builder ─────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _build_adk_agent(adk_model: Any, adk_tools: list[Any]) -> Any:
|
|
276
|
+
"""Build and return a Google ADK LlmAgent instance."""
|
|
277
|
+
return LlmAgent(
|
|
278
|
+
name="agent_security_scanner",
|
|
279
|
+
model=adk_model,
|
|
280
|
+
description=(
|
|
281
|
+
"AI agent security researcher that scans codebases for "
|
|
282
|
+
"OWASP ASI01-ASI10 vulnerabilities."
|
|
283
|
+
),
|
|
284
|
+
instruction=_build_system_prompt(),
|
|
285
|
+
tools=adk_tools,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ── Session service factory ───────────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _create_session_service() -> Any:
|
|
293
|
+
"""Create and return an in-memory ADK SessionService."""
|
|
294
|
+
return InMemorySessionService()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ── Async ADK runner ──────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
async def _run_adk_async(
|
|
301
|
+
agent,
|
|
302
|
+
initial_message: str,
|
|
303
|
+
dispatcher: ToolDispatcher,
|
|
304
|
+
session_id: str,
|
|
305
|
+
tracer,
|
|
306
|
+
) -> tuple[str, str]:
|
|
307
|
+
"""
|
|
308
|
+
Run the ADK agent via InMemorySessionService-backed Runner.
|
|
309
|
+
|
|
310
|
+
Returns (final_text_response, exit_reason).
|
|
311
|
+
Findings are accumulated via dispatcher.session_findings as side-effects
|
|
312
|
+
of report_finding() tool calls made by the agent.
|
|
313
|
+
"""
|
|
314
|
+
APP_NAME = "agent_security_scanner"
|
|
315
|
+
USER_ID = "scanner"
|
|
316
|
+
|
|
317
|
+
session_service = _create_session_service()
|
|
318
|
+
runner = Runner(
|
|
319
|
+
agent=agent,
|
|
320
|
+
app_name=APP_NAME,
|
|
321
|
+
session_service=session_service,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
session = await session_service.create_session(
|
|
325
|
+
app_name=APP_NAME,
|
|
326
|
+
user_id=USER_ID,
|
|
327
|
+
session_id=session_id,
|
|
328
|
+
)
|
|
329
|
+
logger.info("Session created: %s", session.id)
|
|
330
|
+
|
|
331
|
+
user_content = genai_types.Content(
|
|
332
|
+
role="user",
|
|
333
|
+
parts=[genai_types.Part.from_text(text=initial_message)],
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
final_text = ""
|
|
337
|
+
exit_reason = "completed"
|
|
338
|
+
iteration = 0
|
|
339
|
+
loop_start = time.monotonic()
|
|
340
|
+
|
|
341
|
+
event_stream = runner.run_async(
|
|
342
|
+
user_id=USER_ID,
|
|
343
|
+
session_id=session_id,
|
|
344
|
+
new_message=user_content,
|
|
345
|
+
)
|
|
346
|
+
try:
|
|
347
|
+
async for event in event_stream:
|
|
348
|
+
# ── Control plane guards ──────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
elapsed = time.monotonic() - loop_start
|
|
351
|
+
if elapsed > LOOP_TIMEOUT_SECS:
|
|
352
|
+
logger.warning(
|
|
353
|
+
"Timeout after %.1fs (limit=%ds)", elapsed, LOOP_TIMEOUT_SECS
|
|
354
|
+
)
|
|
355
|
+
exit_reason = "timeout"
|
|
356
|
+
break
|
|
357
|
+
|
|
358
|
+
if hasattr(event, "actions") and event.actions:
|
|
359
|
+
iteration += 1
|
|
360
|
+
tracer.set_iteration(iteration)
|
|
361
|
+
logger.debug("Iteration %d/%d", iteration, MAX_ITERATIONS)
|
|
362
|
+
if iteration >= MAX_ITERATIONS:
|
|
363
|
+
logger.warning("Max iterations (%d) reached", MAX_ITERATIONS)
|
|
364
|
+
exit_reason = "max_iterations"
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
if dispatcher._call_counts.get("fetch_file", 0) > MAX_FILES_FETCHED:
|
|
368
|
+
logger.warning("File fetch limit (%d) reached", MAX_FILES_FETCHED)
|
|
369
|
+
exit_reason = "max_files"
|
|
370
|
+
break
|
|
371
|
+
|
|
372
|
+
if dispatcher.tokens_consumed > MAX_FETCH_TOKENS:
|
|
373
|
+
logger.warning(
|
|
374
|
+
"Token budget exhausted (%d/%d)",
|
|
375
|
+
dispatcher.tokens_consumed,
|
|
376
|
+
MAX_FETCH_TOKENS,
|
|
377
|
+
)
|
|
378
|
+
exit_reason = "max_tokens"
|
|
379
|
+
break
|
|
380
|
+
|
|
381
|
+
if dispatcher._call_counts.get("run_targeted_bandit", 0) > 5:
|
|
382
|
+
logger.warning("Bandit call limit (5) reached")
|
|
383
|
+
exit_reason = "max_bandit_calls"
|
|
384
|
+
break
|
|
385
|
+
|
|
386
|
+
if dispatcher._call_counts.get("compute_cvss", 0) > 50:
|
|
387
|
+
logger.warning("compute_cvss call limit (50) reached")
|
|
388
|
+
exit_reason = "max_cvss_calls"
|
|
389
|
+
break
|
|
390
|
+
|
|
391
|
+
if hasattr(event, "content") and event.content:
|
|
392
|
+
for part in event.content.parts or []:
|
|
393
|
+
if hasattr(part, "text") and part.text:
|
|
394
|
+
final_text += part.text
|
|
395
|
+
finally:
|
|
396
|
+
# ADK's Runner wraps this generator's `yield` in an OTEL span
|
|
397
|
+
# (google/adk/runners.py::_run_with_trace). Every `break` above
|
|
398
|
+
# leaves it suspended mid-span rather than exhausted. Left alone, the
|
|
399
|
+
# event loop's implicit shutdown_asyncgens() closes it later — in a
|
|
400
|
+
# different contextvars.Context than the one that attached the span,
|
|
401
|
+
# which makes the span's detach raise "Token ... was created in a
|
|
402
|
+
# different Context". Closing it here, in this same coroutine, closes
|
|
403
|
+
# the span in the Context that opened it.
|
|
404
|
+
with suppress(Exception):
|
|
405
|
+
await event_stream.aclose()
|
|
406
|
+
|
|
407
|
+
return final_text, exit_reason
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ── Main entry point ──────────────────────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def run_agentic_reasoning(
|
|
414
|
+
agents: list[AgentProfile],
|
|
415
|
+
python_files: list[RepoFile],
|
|
416
|
+
static_findings: list[StaticFinding],
|
|
417
|
+
output_path: str | None = None,
|
|
418
|
+
) -> tuple[list[RawFinding], str, dict[str, Any]]:
|
|
419
|
+
"""
|
|
420
|
+
Main entry point for v2 agentic reasoning using Google ADK.
|
|
421
|
+
|
|
422
|
+
The ADK model is resolved via llm_provider.make_model() using the
|
|
423
|
+
SCANNER_MODEL env var.
|
|
424
|
+
|
|
425
|
+
Session storage uses InMemorySessionService (ephemeral, per-scan).
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
(findings_list, summary_string, trace_metadata_dict)
|
|
429
|
+
"""
|
|
430
|
+
if not agents and not python_files:
|
|
431
|
+
logger.info("No agents or files found — skipping agentic scan")
|
|
432
|
+
return [], "No agent profiles or files found to analyze.", {}
|
|
433
|
+
|
|
434
|
+
# Validate that each agent has the three fields the dispatcher and investigation
|
|
435
|
+
# brief depend on. Check each field value individually — a field is "missing"
|
|
436
|
+
# only when its value is None or an empty string, not when its string value
|
|
437
|
+
# differs from the field name (which was the original bug: the old code did
|
|
438
|
+
# `required_fields - set(values)` which always returned the full set because
|
|
439
|
+
# "researcher" != "agent_id", etc.).
|
|
440
|
+
_REQUIRED = ("agent_id", "framework", "source_file")
|
|
441
|
+
invalid_agents = []
|
|
442
|
+
for agent in agents:
|
|
443
|
+
missing = [f for f in _REQUIRED if not getattr(agent, f, None)]
|
|
444
|
+
if missing:
|
|
445
|
+
logger.warning(
|
|
446
|
+
"[reasoner] Agent '%s' missing required fields: %s — skipping",
|
|
447
|
+
getattr(agent, "agent_id", "?"),
|
|
448
|
+
missing,
|
|
449
|
+
)
|
|
450
|
+
invalid_agents.append(agent)
|
|
451
|
+
|
|
452
|
+
if invalid_agents:
|
|
453
|
+
agents = [a for a in agents if a not in invalid_agents]
|
|
454
|
+
logger.warning(
|
|
455
|
+
"[reasoner] Excluded %d agents with missing required fields. "
|
|
456
|
+
"Proceeding with %d valid agents.",
|
|
457
|
+
len(invalid_agents),
|
|
458
|
+
len(agents),
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
if not agents and not python_files:
|
|
462
|
+
logger.info("No valid agents or files after validation — skipping agentic scan")
|
|
463
|
+
return [], "No valid agent profiles or files found to analyze.", {}
|
|
464
|
+
|
|
465
|
+
try:
|
|
466
|
+
adk_model = make_model(scanner="agent", phase="reasoner")
|
|
467
|
+
logger.info("ADK model: %s", adk_model)
|
|
468
|
+
except (ImportError, ValueError) as e:
|
|
469
|
+
logger.error("ADK model resolution failed: %s", e)
|
|
470
|
+
logger.error("ADK setup failed — cannot proceed without ADK: %s", e)
|
|
471
|
+
return [], f"ADK setup failed — {e}", {"error": str(e)}
|
|
472
|
+
|
|
473
|
+
# Set up dispatcher and tracer.
|
|
474
|
+
# IMPORTANT: tracer must be created before build_adk_tools() is called so
|
|
475
|
+
# the tool closures can close over it. Previously the order was reversed,
|
|
476
|
+
# which meant the tracer was never passed in and the trace file showed
|
|
477
|
+
# calls=0 despite the agent making dozens of tool calls.
|
|
478
|
+
repo_files_by_path = {rf.path: rf for rf in python_files}
|
|
479
|
+
dispatcher = ToolDispatcher(
|
|
480
|
+
repo_files=repo_files_by_path,
|
|
481
|
+
agents=agents,
|
|
482
|
+
static_findings=static_findings,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
session_id = f"AGT-{uuid.uuid4().hex[:8].upper()}"
|
|
486
|
+
tracer = (
|
|
487
|
+
FileTraceLogger(session_id=session_id, output_path=output_path)
|
|
488
|
+
if output_path
|
|
489
|
+
else LogTraceLogger()
|
|
490
|
+
)
|
|
491
|
+
tracer.start(provider_model=str(adk_model))
|
|
492
|
+
|
|
493
|
+
# Build ADK tools with the tracer now wired in — every tool call will be
|
|
494
|
+
# recorded in the JSONL trace file and counted in dispatcher._call_counts.
|
|
495
|
+
adk_tools = dispatcher.get_adk_tools(tracer=tracer)
|
|
496
|
+
|
|
497
|
+
# Build ADK agent
|
|
498
|
+
try:
|
|
499
|
+
adk_agent = _build_adk_agent(adk_model, adk_tools)
|
|
500
|
+
except ImportError as e:
|
|
501
|
+
logger.error("ADK agent build failed: %s", e)
|
|
502
|
+
return [], f"ADK agent build failed — {e}", {"error": str(e)}
|
|
503
|
+
|
|
504
|
+
# Build initial investigation brief
|
|
505
|
+
initial_ctx = _build_initial_context(agents, python_files, len(static_findings))
|
|
506
|
+
|
|
507
|
+
logger.info(
|
|
508
|
+
"Starting ADK scan | session: %s | agents: %d | files: %d",
|
|
509
|
+
session_id,
|
|
510
|
+
len(agents),
|
|
511
|
+
len(python_files),
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
# Execute ADK reasoning loop.
|
|
515
|
+
#
|
|
516
|
+
# Always run the coroutine in a dedicated worker thread with a hard
|
|
517
|
+
# deadline. asyncio.run() works in that thread whether or not the caller
|
|
518
|
+
# already has a running event loop (Jupyter/FastAPI as well as the plain
|
|
519
|
+
# CLI path), and future.result(timeout=...) bounds the whole loop even
|
|
520
|
+
# when the model call never returns a control-plane event — the in-loop
|
|
521
|
+
# guard in _run_adk_async only fires between events, so a stalled LLM call
|
|
522
|
+
# would otherwise hang forever on the CLI path. On timeout we shut the
|
|
523
|
+
# pool down without waiting so we don't block on the stuck worker thread.
|
|
524
|
+
exit_reason = "completed"
|
|
525
|
+
final_text = ""
|
|
526
|
+
# Classified where the exception still exists, then carried out in the trace
|
|
527
|
+
# (`error_domain`, and `error_reason` — "auth"/"model" — for the specific
|
|
528
|
+
# customer-actionable config failures). The exception itself is discarded here
|
|
529
|
+
# for telemetry safety, so the caller cannot re-classify it — see
|
|
530
|
+
# `_agent_scan_summary`.
|
|
531
|
+
error_domain: FailureDomain | None = None
|
|
532
|
+
error_reason: str | None = None
|
|
533
|
+
|
|
534
|
+
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1)
|
|
535
|
+
try:
|
|
536
|
+
future = pool.submit(
|
|
537
|
+
asyncio.run,
|
|
538
|
+
_run_adk_async(adk_agent, initial_ctx, dispatcher, session_id, tracer),
|
|
539
|
+
)
|
|
540
|
+
final_text, exit_reason = future.result(timeout=LOOP_TIMEOUT_SECS + 30)
|
|
541
|
+
except concurrent.futures.TimeoutError:
|
|
542
|
+
logger.error("ADK execution timed out after %ds", LOOP_TIMEOUT_SECS + 30)
|
|
543
|
+
exit_reason = "timeout"
|
|
544
|
+
except Exception as e:
|
|
545
|
+
err_type = type(e).__name__
|
|
546
|
+
safe_msg = _safe_error(e)
|
|
547
|
+
first_line = safe_msg.split("\n", 1)[0]
|
|
548
|
+
logger.error("ADK execution failed (%s): %s", err_type, first_line)
|
|
549
|
+
logger.debug("ADK execution failed (full):\n%s", safe_msg, exc_info=True)
|
|
550
|
+
exit_reason = "error"
|
|
551
|
+
error_domain = classify_exception(e)
|
|
552
|
+
if is_auth_error(e):
|
|
553
|
+
error_reason = "auth"
|
|
554
|
+
elif is_model_error(e):
|
|
555
|
+
error_reason = "model"
|
|
556
|
+
finally:
|
|
557
|
+
pool.shutdown(wait=False)
|
|
558
|
+
|
|
559
|
+
# Collect results
|
|
560
|
+
findings = dispatcher.session_findings
|
|
561
|
+
summary = "Agentic investigation complete."
|
|
562
|
+
|
|
563
|
+
if final_text:
|
|
564
|
+
clean = final_text.strip()
|
|
565
|
+
if clean.startswith("```"):
|
|
566
|
+
lines = clean.splitlines()
|
|
567
|
+
clean = "\n".join(lines[1:]).rsplit("```", 1)[0].strip()
|
|
568
|
+
try:
|
|
569
|
+
parsed = json.loads(clean)
|
|
570
|
+
if isinstance(parsed, dict):
|
|
571
|
+
summary = parsed.get("summary", summary)
|
|
572
|
+
except (json.JSONDecodeError, TypeError):
|
|
573
|
+
if len(final_text.strip()) > 20:
|
|
574
|
+
summary = final_text.strip()
|
|
575
|
+
|
|
576
|
+
tracer.finish(findings_count=len(findings), exit_reason=exit_reason)
|
|
577
|
+
trace_meta = tracer.as_dict()
|
|
578
|
+
trace_meta.update(
|
|
579
|
+
{
|
|
580
|
+
"exit_reason": exit_reason,
|
|
581
|
+
"adk_model": str(adk_model),
|
|
582
|
+
"session_id": session_id,
|
|
583
|
+
"tokens_consumed": dispatcher.tokens_consumed,
|
|
584
|
+
}
|
|
585
|
+
)
|
|
586
|
+
if error_domain is not None:
|
|
587
|
+
# Bounded enum value, safe to carry out (unlike str(exc)); the stage
|
|
588
|
+
# summary reads it back to attribute the failure.
|
|
589
|
+
trace_meta["error_domain"] = error_domain.value
|
|
590
|
+
if error_reason is not None:
|
|
591
|
+
# "auth"/"model" — lets the caller surface a precise "API key invalid" /
|
|
592
|
+
# "model invalid" line to the customer. Bounded, like error_domain.
|
|
593
|
+
trace_meta["error_reason"] = error_reason
|
|
594
|
+
|
|
595
|
+
logger.info(
|
|
596
|
+
"ADK scan complete — findings: %d | exit: %s | session: %s",
|
|
597
|
+
len(findings),
|
|
598
|
+
exit_reason,
|
|
599
|
+
session_id,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
return findings, summary, trace_meta
|