ai-agent-security-toolkit 0.4.1__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.
- agentsec/__init__.py +3 -0
- agentsec/cli.py +219 -0
- agentsec/fuzzer/__init__.py +44 -0
- agentsec/fuzzer/formats.py +222 -0
- agentsec/fuzzer/harness.py +218 -0
- agentsec/fuzzer/mock_agents.py +243 -0
- agentsec/fuzzer/mutators.py +102 -0
- agentsec/fuzzer/payloads.py +250 -0
- agentsec/fuzzer/report.py +132 -0
- agentsec/fuzzer/targets.py +300 -0
- agentsec/integrations/__init__.py +1 -0
- agentsec/integrations/langchain.py +94 -0
- agentsec/integrations/mcp.py +392 -0
- agentsec/middleware/__init__.py +65 -0
- agentsec/middleware/audit.py +366 -0
- agentsec/middleware/cef.py +183 -0
- agentsec/middleware/injection.py +148 -0
- agentsec/middleware/pipeline.py +111 -0
- agentsec/middleware/redact.py +191 -0
- agentsec/middleware/validators.py +505 -0
- agentsec/py.typed +0 -0
- agentsec/sandbox/__init__.py +31 -0
- agentsec/sandbox/adapters.py +203 -0
- agentsec/sandbox/guard.py +353 -0
- agentsec/sandbox/policy.py +217 -0
- agentsec/sandbox/subprocess_runner.py +226 -0
- agentsec/sandbox/validators.py +209 -0
- agentsec/threatmodel/__init__.py +27 -0
- agentsec/threatmodel/crosswalk.py +65 -0
- agentsec/threatmodel/data/frameworks.yaml +45 -0
- agentsec/threatmodel/data/threats.yaml +440 -0
- agentsec/threatmodel/render.py +364 -0
- agentsec/types.py +64 -0
- ai_agent_security_toolkit-0.4.1.dist-info/METADATA +273 -0
- ai_agent_security_toolkit-0.4.1.dist-info/RECORD +39 -0
- ai_agent_security_toolkit-0.4.1.dist-info/WHEEL +5 -0
- ai_agent_security_toolkit-0.4.1.dist-info/entry_points.txt +2 -0
- ai_agent_security_toolkit-0.4.1.dist-info/licenses/LICENSE +21 -0
- ai_agent_security_toolkit-0.4.1.dist-info/top_level.txt +1 -0
agentsec/__init__.py
ADDED
agentsec/cli.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""``agentsec`` command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from agentsec import __version__
|
|
12
|
+
from agentsec.fuzzer import CORPUS, MUTATORS, FuzzConfig, load_target, run
|
|
13
|
+
from agentsec.fuzzer.harness import SCENARIOS, build_cases
|
|
14
|
+
from agentsec.middleware.audit import verify_file
|
|
15
|
+
from agentsec.sandbox import Policy, PolicyError, ToolGuard
|
|
16
|
+
from agentsec.threatmodel import (
|
|
17
|
+
SYSTEM_TEMPLATE,
|
|
18
|
+
SystemSpecError,
|
|
19
|
+
crosswalk_markdown,
|
|
20
|
+
load_catalog,
|
|
21
|
+
load_system,
|
|
22
|
+
render_markdown,
|
|
23
|
+
)
|
|
24
|
+
from agentsec.types import ToolCall
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _csv(value: str | None) -> list[str] | None:
|
|
28
|
+
return [v.strip() for v in value.split(",") if v.strip()] if value else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def cmd_fuzz(args: argparse.Namespace) -> int:
|
|
32
|
+
config = FuzzConfig(
|
|
33
|
+
seed=args.seed,
|
|
34
|
+
scenarios=_csv(args.scenarios) or list(SCENARIOS),
|
|
35
|
+
mutators=_csv(args.mutators),
|
|
36
|
+
categories=_csv(args.categories),
|
|
37
|
+
max_cases=args.max_cases,
|
|
38
|
+
)
|
|
39
|
+
if args.list:
|
|
40
|
+
cases = build_cases(config)
|
|
41
|
+
print(f"{len(CORPUS)} payloads x {len(MUTATORS)} mutators x {len(SCENARIOS)} scenarios")
|
|
42
|
+
print(f"selected: {len(cases)} cases")
|
|
43
|
+
print("mutators:", ", ".join(MUTATORS))
|
|
44
|
+
return 0
|
|
45
|
+
try:
|
|
46
|
+
target = load_target(args.target)
|
|
47
|
+
report = run(target, config)
|
|
48
|
+
except (ValueError, ImportError, AttributeError) as exc:
|
|
49
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
50
|
+
return 2
|
|
51
|
+
if args.json:
|
|
52
|
+
Path(args.json).write_text(report.to_json(), encoding="utf-8", newline="\n")
|
|
53
|
+
if args.md:
|
|
54
|
+
Path(args.md).write_text(report.to_markdown(), encoding="utf-8", newline="\n")
|
|
55
|
+
if args.sarif:
|
|
56
|
+
Path(args.sarif).write_text(
|
|
57
|
+
report.to_sarif(artifact_uri=args.sarif_artifact, target=args.target),
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
newline="\n",
|
|
60
|
+
)
|
|
61
|
+
if args.junit:
|
|
62
|
+
Path(args.junit).write_text(report.to_junit(), encoding="utf-8", newline="\n")
|
|
63
|
+
print(
|
|
64
|
+
f"{report.successes}/{report.total - report.errors} attacks succeeded "
|
|
65
|
+
f"(ASR {report.asr:.1%}); {report.blocked} blocked, {report.errors} errors"
|
|
66
|
+
)
|
|
67
|
+
if not args.quiet:
|
|
68
|
+
print()
|
|
69
|
+
print(report.to_markdown(max_bypasses=8))
|
|
70
|
+
if args.max_asr is not None and report.exceeds(args.max_asr):
|
|
71
|
+
print(f"FAIL: ASR {report.asr:.1%} exceeds --max-asr {args.max_asr:.1%}", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def cmd_policy(args: argparse.Namespace) -> int:
|
|
77
|
+
try:
|
|
78
|
+
policy = Policy.from_yaml(args.file)
|
|
79
|
+
except (PolicyError, OSError, ValueError) as exc:
|
|
80
|
+
print(f"invalid policy: {exc}", file=sys.stderr)
|
|
81
|
+
return 2
|
|
82
|
+
if args.action == "validate":
|
|
83
|
+
notes = policy.lint()
|
|
84
|
+
print(f"OK: {len(policy.tools)} tool(s) defined")
|
|
85
|
+
for n in notes:
|
|
86
|
+
print(f" warning: {n}")
|
|
87
|
+
return 1 if (notes and args.strict) else 0
|
|
88
|
+
try:
|
|
89
|
+
call_args: Any = json.loads(args.args or "{}")
|
|
90
|
+
except json.JSONDecodeError as exc:
|
|
91
|
+
print(f"--args is not valid JSON: {exc}", file=sys.stderr)
|
|
92
|
+
return 2
|
|
93
|
+
guard = ToolGuard(policy)
|
|
94
|
+
if args.tainted:
|
|
95
|
+
guard.default_session.mark_untrusted("cli")
|
|
96
|
+
decision = guard.evaluate(ToolCall(name=args.tool, arguments=call_args))
|
|
97
|
+
print(f"{decision.verdict.value.upper()}: {decision.message()}")
|
|
98
|
+
return 0 if decision.allowed else 1
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_audit(args: argparse.Namespace) -> int:
|
|
102
|
+
try:
|
|
103
|
+
result = verify_file(args.file)
|
|
104
|
+
except OSError as exc:
|
|
105
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
if result.ok:
|
|
108
|
+
print(f"OK: {result.records_checked} record(s), chain intact")
|
|
109
|
+
return 0
|
|
110
|
+
print(
|
|
111
|
+
f"TAMPERED: {result.reason} (first bad seq: {result.first_bad_seq}, "
|
|
112
|
+
f"{result.records_checked} valid before it)",
|
|
113
|
+
file=sys.stderr,
|
|
114
|
+
)
|
|
115
|
+
return 1
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_threatmodel(args: argparse.Namespace) -> int:
|
|
119
|
+
if args.action == "init":
|
|
120
|
+
out = Path(args.output) if args.output else None
|
|
121
|
+
if out:
|
|
122
|
+
out.write_text(SYSTEM_TEMPLATE, encoding="utf-8", newline="\n")
|
|
123
|
+
print(f"wrote {out}")
|
|
124
|
+
else:
|
|
125
|
+
print(SYSTEM_TEMPLATE, end="")
|
|
126
|
+
return 0
|
|
127
|
+
if args.action == "crosswalk":
|
|
128
|
+
print(crosswalk_markdown(), end="")
|
|
129
|
+
return 0
|
|
130
|
+
if args.action == "catalog":
|
|
131
|
+
for t in load_catalog():
|
|
132
|
+
print(f"{t.id} {t.stride:<24} {t.title}")
|
|
133
|
+
return 0
|
|
134
|
+
try:
|
|
135
|
+
doc = render_markdown(load_system(args.system), load_catalog())
|
|
136
|
+
except (SystemSpecError, OSError, KeyError) as exc:
|
|
137
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
138
|
+
return 2
|
|
139
|
+
if args.output:
|
|
140
|
+
Path(args.output).write_text(doc, encoding="utf-8", newline="\n")
|
|
141
|
+
print(f"wrote {args.output}")
|
|
142
|
+
else:
|
|
143
|
+
print(doc)
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
148
|
+
p = argparse.ArgumentParser(prog="agentsec", description="Security toolkit for LLM agents.")
|
|
149
|
+
p.add_argument("--version", action="version", version=f"agentsec {__version__}")
|
|
150
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
151
|
+
|
|
152
|
+
f = sub.add_parser("fuzz", help="Run the prompt-injection fuzzer against a target.")
|
|
153
|
+
f.add_argument(
|
|
154
|
+
"--target",
|
|
155
|
+
default="builtin:guarded",
|
|
156
|
+
help="builtin:naive | builtin:guarded | builtin:guarded-noscanner | http(s)://URL | "
|
|
157
|
+
"anthropic:MODEL | package.module:callable",
|
|
158
|
+
)
|
|
159
|
+
f.add_argument("--scenarios", help=f"comma list from {list(SCENARIOS)} (default: all)")
|
|
160
|
+
f.add_argument("--mutators", help=f"comma list from {list(MUTATORS)} (default: all)")
|
|
161
|
+
f.add_argument("--categories", help="comma list of payload categories (default: all)")
|
|
162
|
+
f.add_argument("--seed", type=int, default=0)
|
|
163
|
+
f.add_argument("--max-cases", type=int, help="randomly sample at most N cases")
|
|
164
|
+
f.add_argument("--max-asr", type=float, help="exit 1 if attack-success rate exceeds this (0-1)")
|
|
165
|
+
f.add_argument("--json", help="write full JSON report to this path")
|
|
166
|
+
f.add_argument("--md", help="write Markdown report to this path")
|
|
167
|
+
f.add_argument("--sarif", help="write a SARIF 2.1.0 report (GitHub code scanning)")
|
|
168
|
+
f.add_argument(
|
|
169
|
+
"--sarif-artifact",
|
|
170
|
+
default="agent",
|
|
171
|
+
help="repo-relative path of the file that defines the agent; SARIF alerts attach to it",
|
|
172
|
+
)
|
|
173
|
+
f.add_argument("--junit", help="write a JUnit XML report to this path")
|
|
174
|
+
f.add_argument("--quiet", action="store_true", help="only print the one-line summary")
|
|
175
|
+
f.add_argument("--list", action="store_true", help="show the test matrix and exit")
|
|
176
|
+
f.set_defaults(func=cmd_fuzz)
|
|
177
|
+
|
|
178
|
+
pol = sub.add_parser("policy", help="Validate a tool policy or test a call against it.")
|
|
179
|
+
pol_sub = pol.add_subparsers(dest="action", required=True)
|
|
180
|
+
pv = pol_sub.add_parser("validate", help="Load and lint a policy file.")
|
|
181
|
+
pv.add_argument("file")
|
|
182
|
+
pv.add_argument("--strict", action="store_true", help="exit 1 on lint warnings")
|
|
183
|
+
pc = pol_sub.add_parser("check", help="Evaluate one tool call against a policy.")
|
|
184
|
+
pc.add_argument("file")
|
|
185
|
+
pc.add_argument("--tool", required=True)
|
|
186
|
+
pc.add_argument("--args", help="JSON object of arguments")
|
|
187
|
+
pc.add_argument(
|
|
188
|
+
"--tainted", action="store_true", help="evaluate as if untrusted content was read"
|
|
189
|
+
)
|
|
190
|
+
pol.set_defaults(func=cmd_policy)
|
|
191
|
+
|
|
192
|
+
a = sub.add_parser("audit", help="Audit-log utilities.")
|
|
193
|
+
a_sub = a.add_subparsers(dest="action", required=True)
|
|
194
|
+
av = a_sub.add_parser("verify", help="Verify a hash-chained JSONL audit log.")
|
|
195
|
+
av.add_argument("file")
|
|
196
|
+
a.set_defaults(func=cmd_audit)
|
|
197
|
+
|
|
198
|
+
tm = sub.add_parser("threatmodel", help="STRIDE-for-agents threat modeling.")
|
|
199
|
+
tm_sub = tm.add_subparsers(dest="action", required=True)
|
|
200
|
+
ti = tm_sub.add_parser("init", help="Print or write a starter system description.")
|
|
201
|
+
ti.add_argument("-o", "--output")
|
|
202
|
+
tr = tm_sub.add_parser("render", help="Generate a threat model from a system description.")
|
|
203
|
+
tr.add_argument("system", help="system description YAML")
|
|
204
|
+
tr.add_argument("-o", "--output")
|
|
205
|
+
tm_sub.add_parser("catalog", help="List the threat catalog.")
|
|
206
|
+
tm_sub.add_parser(
|
|
207
|
+
"crosswalk", help="Map the catalog to OWASP Agentic Top 10 and MITRE ATLAS (Markdown)."
|
|
208
|
+
)
|
|
209
|
+
tm.set_defaults(func=cmd_threatmodel)
|
|
210
|
+
return p
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main(argv: list[str] | None = None) -> int:
|
|
214
|
+
args = build_parser().parse_args(argv)
|
|
215
|
+
return int(args.func(args))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
if __name__ == "__main__":
|
|
219
|
+
sys.exit(main())
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Prompt-injection test harness / fuzzer."""
|
|
2
|
+
|
|
3
|
+
from agentsec.fuzzer.harness import (
|
|
4
|
+
Case,
|
|
5
|
+
CaseResult,
|
|
6
|
+
FuzzConfig,
|
|
7
|
+
build_cases,
|
|
8
|
+
detect,
|
|
9
|
+
run,
|
|
10
|
+
)
|
|
11
|
+
from agentsec.fuzzer.mock_agents import GuardedAgent, NaiveAgent, demo_policy
|
|
12
|
+
from agentsec.fuzzer.mutators import MUTATORS
|
|
13
|
+
from agentsec.fuzzer.payloads import CORPUS, Payload
|
|
14
|
+
from agentsec.fuzzer.report import FuzzReport
|
|
15
|
+
from agentsec.fuzzer.targets import (
|
|
16
|
+
AnthropicTarget,
|
|
17
|
+
HttpTarget,
|
|
18
|
+
OllamaTarget,
|
|
19
|
+
load_target,
|
|
20
|
+
sync_target,
|
|
21
|
+
text_target,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"CORPUS",
|
|
26
|
+
"MUTATORS",
|
|
27
|
+
"AnthropicTarget",
|
|
28
|
+
"Case",
|
|
29
|
+
"CaseResult",
|
|
30
|
+
"FuzzConfig",
|
|
31
|
+
"FuzzReport",
|
|
32
|
+
"GuardedAgent",
|
|
33
|
+
"HttpTarget",
|
|
34
|
+
"NaiveAgent",
|
|
35
|
+
"OllamaTarget",
|
|
36
|
+
"Payload",
|
|
37
|
+
"build_cases",
|
|
38
|
+
"demo_policy",
|
|
39
|
+
"detect",
|
|
40
|
+
"load_target",
|
|
41
|
+
"run",
|
|
42
|
+
"sync_target",
|
|
43
|
+
"text_target",
|
|
44
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""CI-friendly renderings of a fuzz report: SARIF 2.1.0 and JUnit XML.
|
|
2
|
+
|
|
3
|
+
SARIF lets results appear in GitHub code scanning (and other SARIF viewers); JUnit
|
|
4
|
+
XML is understood by nearly every CI system's test-report UI.
|
|
5
|
+
|
|
6
|
+
Both embed text that came back from the system under test. An attacker may have
|
|
7
|
+
steered that text, so it is bounded and stripped of control characters before use.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import unicodedata
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
from xml.etree import ElementTree as ET # noqa: S405 - only used to *generate* XML
|
|
15
|
+
|
|
16
|
+
from agentsec import __version__
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from agentsec.fuzzer.harness import CaseResult
|
|
20
|
+
from agentsec.fuzzer.report import FuzzReport
|
|
21
|
+
|
|
22
|
+
SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
23
|
+
INFORMATION_URI = "https://github.com/DustyStudy/ai-agent-security-toolkit"
|
|
24
|
+
MAX_TEXT = 300
|
|
25
|
+
|
|
26
|
+
# Unicode categories removed from quoted text: control characters (incl. NUL and ESC), format
|
|
27
|
+
# characters (zero-width and bidirectional overrides that can visually spoof a report),
|
|
28
|
+
# line/paragraph separators and lone surrogates. XML 1.0 cannot carry most of these anyway.
|
|
29
|
+
_STRIP_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Zl", "Zp"})
|
|
30
|
+
|
|
31
|
+
# goal -> (rule id, name, severity level, GitHub security-severity, OWASP LLM Top 10 (2025), help)
|
|
32
|
+
_RULES: dict[str, tuple[str, str, str, str, str, str]] = {
|
|
33
|
+
"tool_misuse": (
|
|
34
|
+
"AGSEC-TOOL-MISUSE",
|
|
35
|
+
"InjectedInstructionCalledForbiddenTool",
|
|
36
|
+
"error",
|
|
37
|
+
"8.5",
|
|
38
|
+
"LLM06",
|
|
39
|
+
"A prompt injection made the agent request a tool it must never call. Enforce a "
|
|
40
|
+
"deny-by-default tool policy (agentsec.sandbox.ToolGuard) so a hijacked model "
|
|
41
|
+
"cannot act on the request.",
|
|
42
|
+
),
|
|
43
|
+
"exfil": (
|
|
44
|
+
"AGSEC-EXFILTRATION",
|
|
45
|
+
"InjectedInstructionCausedDataExfiltration",
|
|
46
|
+
"error",
|
|
47
|
+
"8.5",
|
|
48
|
+
"LLM02",
|
|
49
|
+
"A prompt injection made the agent send data toward an attacker-controlled host "
|
|
50
|
+
"(tool arguments or a rendered markdown/HTML image or link). Validate output and "
|
|
51
|
+
"restrict outbound destinations (agentsec.middleware.OutputGuard, ToolGuard URL rules).",
|
|
52
|
+
),
|
|
53
|
+
"prompt_leak": (
|
|
54
|
+
"AGSEC-PROMPT-LEAK",
|
|
55
|
+
"InjectedInstructionLeakedSystemPrompt",
|
|
56
|
+
"error",
|
|
57
|
+
"7.0",
|
|
58
|
+
"LLM07",
|
|
59
|
+
"A prompt injection made the agent disclose a secret planted in its system prompt. "
|
|
60
|
+
"Keep secrets out of prompts and add a protected-string output check.",
|
|
61
|
+
),
|
|
62
|
+
"canary": (
|
|
63
|
+
"AGSEC-INSTRUCTION-FOLLOWING",
|
|
64
|
+
"AgentFollowedInjectedInstruction",
|
|
65
|
+
"warning",
|
|
66
|
+
"5.0",
|
|
67
|
+
"LLM01",
|
|
68
|
+
"The agent followed an instruction planted in untrusted content. On its own this is "
|
|
69
|
+
"low impact, but it is the first step of every real injection. Contain the blast "
|
|
70
|
+
"radius with tool policy and output validation.",
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
_FALLBACK = (
|
|
74
|
+
"AGSEC-OTHER",
|
|
75
|
+
"InjectionSucceeded",
|
|
76
|
+
"warning",
|
|
77
|
+
"5.0",
|
|
78
|
+
"LLM01",
|
|
79
|
+
"A prompt-injection test case succeeded against the target.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def clean(text: str, limit: int = MAX_TEXT) -> str:
|
|
84
|
+
"""Bound and de-fang text that originated in the system under test."""
|
|
85
|
+
text = "".join(
|
|
86
|
+
" " if unicodedata.category(ch) in _STRIP_CATEGORIES or ch in "\ufffe\uffff" else ch
|
|
87
|
+
for ch in text
|
|
88
|
+
)
|
|
89
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _rule_for(goal: str) -> tuple[str, str, str, str, str, str]:
|
|
93
|
+
return _RULES.get(goal, _FALLBACK)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------------------ SARIF
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def to_sarif(
|
|
100
|
+
report: FuzzReport, *, artifact_uri: str = "agent", target: str | None = None
|
|
101
|
+
) -> dict[str, Any]:
|
|
102
|
+
"""Render successful attacks as a SARIF 2.1.0 log.
|
|
103
|
+
|
|
104
|
+
Fuzz findings have no source location, but code-scanning UIs need one to attach an
|
|
105
|
+
alert to, so every result points at ``artifact_uri`` (line 1). Pass the path of the
|
|
106
|
+
file that defines your agent, relative to the repository root.
|
|
107
|
+
"""
|
|
108
|
+
used = sorted({_rule_for(r.goal) for r in report.results if r.success})
|
|
109
|
+
rules = [
|
|
110
|
+
{
|
|
111
|
+
"id": rid,
|
|
112
|
+
"name": name,
|
|
113
|
+
"shortDescription": {"text": name},
|
|
114
|
+
"fullDescription": {"text": help_text},
|
|
115
|
+
"help": {"text": help_text},
|
|
116
|
+
"defaultConfiguration": {"level": level},
|
|
117
|
+
"properties": {
|
|
118
|
+
"tags": ["security", "prompt-injection", "OWASP-LLM-2025-" + owasp],
|
|
119
|
+
"security-severity": sev,
|
|
120
|
+
"precision": "high",
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
for rid, name, level, sev, owasp, help_text in used
|
|
124
|
+
]
|
|
125
|
+
results = [_sarif_result(r, artifact_uri) for r in report.results if r.success]
|
|
126
|
+
run: dict[str, Any] = {
|
|
127
|
+
"tool": {
|
|
128
|
+
"driver": {
|
|
129
|
+
"name": "agentsec",
|
|
130
|
+
"version": __version__,
|
|
131
|
+
"informationUri": INFORMATION_URI,
|
|
132
|
+
"rules": rules,
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"results": results,
|
|
136
|
+
"properties": {
|
|
137
|
+
"seed": report.seed,
|
|
138
|
+
"attackSuccessRate": report.asr,
|
|
139
|
+
"cases": report.total,
|
|
140
|
+
"targetErrors": report.errors,
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
if target:
|
|
144
|
+
run["properties"]["target"] = clean(target, 200)
|
|
145
|
+
return {"$schema": SARIF_SCHEMA, "version": "2.1.0", "runs": [run]}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _sarif_result(r: CaseResult, artifact_uri: str) -> dict[str, Any]:
|
|
149
|
+
rid, _, level, _, _, _ = _rule_for(r.goal)
|
|
150
|
+
detail = f" Evidence: {clean(r.evidence)}" if r.evidence else ""
|
|
151
|
+
return {
|
|
152
|
+
"ruleId": rid,
|
|
153
|
+
"level": level,
|
|
154
|
+
"message": {
|
|
155
|
+
"text": (
|
|
156
|
+
f"Attack {r.payload_id} ({r.category}, obfuscation {r.mutator}, "
|
|
157
|
+
f"delivered {r.scenario}) succeeded: {', '.join(r.hits)}.{detail}"
|
|
158
|
+
)
|
|
159
|
+
},
|
|
160
|
+
"locations": [
|
|
161
|
+
{
|
|
162
|
+
"physicalLocation": {
|
|
163
|
+
"artifactLocation": {"uri": artifact_uri},
|
|
164
|
+
"region": {"startLine": 1},
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
],
|
|
168
|
+
"partialFingerprints": {"agentsec/caseId/v1": r.case_id},
|
|
169
|
+
"properties": {"category": r.category, "mutator": r.mutator, "scenario": r.scenario},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------------------ JUnit
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def to_junit(report: FuzzReport) -> str:
|
|
177
|
+
"""Render a JUnit XML report: one test suite per attack category, one test per case.
|
|
178
|
+
|
|
179
|
+
A succeeded attack is a *failure*; a case where the target crashed is an *error*.
|
|
180
|
+
"""
|
|
181
|
+
by_category: dict[str, list[CaseResult]] = {}
|
|
182
|
+
for r in report.results:
|
|
183
|
+
by_category.setdefault(r.category, []).append(r)
|
|
184
|
+
|
|
185
|
+
root = ET.Element(
|
|
186
|
+
"testsuites",
|
|
187
|
+
name="agentsec-fuzz",
|
|
188
|
+
tests=str(report.total),
|
|
189
|
+
failures=str(report.successes),
|
|
190
|
+
errors=str(report.errors),
|
|
191
|
+
)
|
|
192
|
+
for category, cases in sorted(by_category.items()):
|
|
193
|
+
suite = ET.SubElement(
|
|
194
|
+
root,
|
|
195
|
+
"testsuite",
|
|
196
|
+
name=clean(category),
|
|
197
|
+
tests=str(len(cases)),
|
|
198
|
+
failures=str(sum(1 for c in cases if c.success)),
|
|
199
|
+
errors=str(sum(1 for c in cases if c.error is not None)),
|
|
200
|
+
time=f"{sum(c.latency_ms for c in cases) / 1000:.3f}",
|
|
201
|
+
)
|
|
202
|
+
for c in cases:
|
|
203
|
+
case = ET.SubElement(
|
|
204
|
+
suite,
|
|
205
|
+
"testcase",
|
|
206
|
+
classname=f"agentsec.{clean(category)}",
|
|
207
|
+
name=clean(c.case_id),
|
|
208
|
+
time=f"{c.latency_ms / 1000:.3f}",
|
|
209
|
+
)
|
|
210
|
+
if c.error is not None:
|
|
211
|
+
err = ET.SubElement(case, "error", message=clean(c.error), type="TargetError")
|
|
212
|
+
err.text = clean(c.error)
|
|
213
|
+
elif c.success:
|
|
214
|
+
fail = ET.SubElement(
|
|
215
|
+
case,
|
|
216
|
+
"failure",
|
|
217
|
+
message=clean(f"attack succeeded: {', '.join(c.hits)}"),
|
|
218
|
+
type=_rule_for(c.goal)[0],
|
|
219
|
+
)
|
|
220
|
+
fail.text = clean(c.evidence)
|
|
221
|
+
ET.indent(root)
|
|
222
|
+
return '<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(root, encoding="unicode") + "\n"
|