agent-capmap 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.
- agent_capmap-0.1.0.dist-info/METADATA +295 -0
- agent_capmap-0.1.0.dist-info/RECORD +48 -0
- agent_capmap-0.1.0.dist-info/WHEEL +4 -0
- agent_capmap-0.1.0.dist-info/entry_points.txt +2 -0
- agent_capmap-0.1.0.dist-info/licenses/LICENSE +21 -0
- agentcapmap/__init__.py +3 -0
- agentcapmap/__main__.py +5 -0
- agentcapmap/baseline.py +125 -0
- agentcapmap/classify/__init__.py +221 -0
- agentcapmap/classify/cache.py +74 -0
- agentcapmap/classify/prompt.py +160 -0
- agentcapmap/classify/provider.py +122 -0
- agentcapmap/classify/reference.py +115 -0
- agentcapmap/classify/reference_labels.json +6135 -0
- agentcapmap/cli.py +392 -0
- agentcapmap/diff.py +103 -0
- agentcapmap/discovery/__init__.py +209 -0
- agentcapmap/discovery/_common.py +297 -0
- agentcapmap/discovery/claude_code.py +258 -0
- agentcapmap/discovery/claude_desktop.py +28 -0
- agentcapmap/discovery/cursor.py +33 -0
- agentcapmap/discovery/mcp_json.py +29 -0
- agentcapmap/discovery/vscode.py +65 -0
- agentcapmap/enumerate/__init__.py +278 -0
- agentcapmap/enumerate/hashing.py +37 -0
- agentcapmap/enumerate/http.py +313 -0
- agentcapmap/enumerate/netguard.py +60 -0
- agentcapmap/enumerate/stdio.py +273 -0
- agentcapmap/enumerate/transport.py +86 -0
- agentcapmap/evaluation.py +498 -0
- agentcapmap/findings.py +129 -0
- agentcapmap/graph/__init__.py +264 -0
- agentcapmap/graph/server_attrs.py +164 -0
- agentcapmap/limits.py +35 -0
- agentcapmap/models.py +186 -0
- agentcapmap/policies/default.yaml +113 -0
- agentcapmap/policy/__init__.py +24 -0
- agentcapmap/policy/engine.py +229 -0
- agentcapmap/policy/loader.py +223 -0
- agentcapmap/py.typed +0 -0
- agentcapmap/redact.py +268 -0
- agentcapmap/report/__init__.py +36 -0
- agentcapmap/report/_text.py +62 -0
- agentcapmap/report/markdown.py +152 -0
- agentcapmap/report/sarif.py +209 -0
- agentcapmap/schemas/policy.schema.json +172 -0
- agentcapmap/schemas/tool-tags.schema.json +57 -0
- agentcapmap/taxonomy.py +56 -0
agentcapmap/redact.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Secret redaction for everything agent-capmap prints or logs.
|
|
2
|
+
|
|
3
|
+
Config files routinely embed credentials (env values, headers, CLI flags, connection
|
|
4
|
+
strings). Nothing derived from a config may reach output or logs without passing
|
|
5
|
+
through this module. Redaction is deliberately aggressive: a false positive costs a
|
|
6
|
+
little readability, a false negative leaks a credential.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
import threading
|
|
14
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
15
|
+
|
|
16
|
+
REDACTED = "<redacted>"
|
|
17
|
+
|
|
18
|
+
# Shortest value we register as a known secret. Shorter values ("1", "true") would
|
|
19
|
+
# scrub unrelated text without protecting anything meaningful.
|
|
20
|
+
_MIN_KNOWN_SECRET_LEN = 6
|
|
21
|
+
|
|
22
|
+
_TOKEN_PATTERNS: list[re.Pattern[str]] = [
|
|
23
|
+
re.compile(p)
|
|
24
|
+
for p in (
|
|
25
|
+
r"gh[pousr]_[A-Za-z0-9]{20,}",
|
|
26
|
+
r"github_pat_[A-Za-z0-9_]{20,}",
|
|
27
|
+
r"glpat-[A-Za-z0-9_\-]{20,}",
|
|
28
|
+
r"sk-ant-[A-Za-z0-9_\-]{10,}",
|
|
29
|
+
r"sk-[A-Za-z0-9_\-]{20,}",
|
|
30
|
+
r"xox[abposr]-[A-Za-z0-9\-]{10,}",
|
|
31
|
+
r"(?:AKIA|ASIA)[0-9A-Z]{16}",
|
|
32
|
+
r"AIza[0-9A-Za-z_\-]{35}",
|
|
33
|
+
r"npm_[A-Za-z0-9]{36}",
|
|
34
|
+
r"eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}", # JWT
|
|
35
|
+
r"-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----[\s\S]{0,16384}?"
|
|
36
|
+
r"-----END [A-Z ]{0,40}PRIVATE KEY-----",
|
|
37
|
+
)
|
|
38
|
+
]
|
|
39
|
+
# Every quantifier below is bounded, and matches may only start at an identifier
|
|
40
|
+
# boundary, so hostile input (e.g. a malicious server's stderr) cannot trigger
|
|
41
|
+
# catastrophic backtracking.
|
|
42
|
+
_BEARER = re.compile(r"(?i)(?<![a-z])(bearer|basic|token)[ \t]{1,8}[A-Za-z0-9._~+/=\-]{8,4096}")
|
|
43
|
+
_URL_USERINFO = re.compile(
|
|
44
|
+
r"(?i)(?<![a-z0-9+.\-])([a-z][a-z0-9+.\-]{0,30}://[^/\s:@]{1,256}):[^@\s/]{1,256}@"
|
|
45
|
+
)
|
|
46
|
+
_ASSIGNMENT_IN_TEXT = re.compile(
|
|
47
|
+
r"(?<![A-Za-z0-9_.\-])([A-Za-z0-9_.\-]{1,128})([ \t]{0,8}[:=][ \t]{0,8})"
|
|
48
|
+
r"(\"[^\"\n]{0,1024}\"|'[^'\n]{0,1024}'|[^\s,;&\"']{1,1024})"
|
|
49
|
+
)
|
|
50
|
+
_SECRET_KEYWORD = re.compile(
|
|
51
|
+
r"(?i)token|secret|passw(?:or)?d|api[_\-]?key|apikey|authorization|credential|"
|
|
52
|
+
r"private[_\-]?key|access[_\-]?key"
|
|
53
|
+
)
|
|
54
|
+
# C0/C1 control characters (except tab/newline): could drive a terminal via escape codes.
|
|
55
|
+
_CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
|
|
56
|
+
|
|
57
|
+
_SENSITIVE_SUBSTRINGS = (
|
|
58
|
+
"token",
|
|
59
|
+
"secret",
|
|
60
|
+
"password",
|
|
61
|
+
"passwd",
|
|
62
|
+
"pwd",
|
|
63
|
+
"apikey",
|
|
64
|
+
"api_key",
|
|
65
|
+
"api-key",
|
|
66
|
+
"auth",
|
|
67
|
+
"credential",
|
|
68
|
+
"privatekey",
|
|
69
|
+
"private_key",
|
|
70
|
+
"private-key",
|
|
71
|
+
"accesskey",
|
|
72
|
+
"access_key",
|
|
73
|
+
"access-key",
|
|
74
|
+
"cookie",
|
|
75
|
+
"session",
|
|
76
|
+
"signature",
|
|
77
|
+
"dsn",
|
|
78
|
+
)
|
|
79
|
+
_SENSITIVE_WORDS = re.compile(r"(?i)(?:^|[_\-.])(?:pat|key|pass|sig|jwt)(?:$|[_\-.])")
|
|
80
|
+
_VAR_REFERENCE = re.compile(r"^\$\{[^}]+\}$|^\$[A-Za-z_][A-Za-z0-9_]*$|^%[A-Za-z_][A-Za-z0-9_]*%$")
|
|
81
|
+
_ANY_VAR_REFERENCE = re.compile(r"\$\{[^}]+\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _KnownSecrets:
|
|
85
|
+
"""Raw secret values seen in configs, scrubbed verbatim from any text."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self._values: set[str] = set()
|
|
89
|
+
self._lock = threading.Lock()
|
|
90
|
+
|
|
91
|
+
def add(self, value: str) -> None:
|
|
92
|
+
if len(value) >= _MIN_KNOWN_SECRET_LEN and not is_variable_reference(value):
|
|
93
|
+
with self._lock:
|
|
94
|
+
self._values.add(value)
|
|
95
|
+
|
|
96
|
+
def scrub(self, text: str) -> str:
|
|
97
|
+
with self._lock:
|
|
98
|
+
values = sorted(self._values, key=len, reverse=True)
|
|
99
|
+
for value in values:
|
|
100
|
+
if value in text:
|
|
101
|
+
text = text.replace(value, REDACTED)
|
|
102
|
+
return text
|
|
103
|
+
|
|
104
|
+
def clear(self) -> None:
|
|
105
|
+
with self._lock:
|
|
106
|
+
self._values.clear()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
known_secrets = _KnownSecrets()
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def is_sensitive_name(name: str) -> bool:
|
|
113
|
+
"""Whether a key, header, flag or parameter name likely holds a credential."""
|
|
114
|
+
lowered = name.strip().lstrip("-").lower()
|
|
115
|
+
if not lowered:
|
|
116
|
+
return False
|
|
117
|
+
return any(s in lowered for s in _SENSITIVE_SUBSTRINGS) or bool(
|
|
118
|
+
_SENSITIVE_WORDS.search(lowered)
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def is_variable_reference(value: str) -> bool:
|
|
123
|
+
"""`${VAR}`, `${env:VAR}`, `${input:id}`, `$VAR` or `%VAR%`: a pointer, not a secret."""
|
|
124
|
+
return bool(_VAR_REFERENCE.match(value.strip()))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def looks_like_secret(value: str) -> bool:
|
|
128
|
+
"""High-entropy-looking opaque string (long, mixes letters and digits)."""
|
|
129
|
+
if len(value) < 24 or any(c in value for c in " ./\\"):
|
|
130
|
+
return False
|
|
131
|
+
return any(c.isdigit() for c in value) and any(c.isalpha() for c in value)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _redact_assignment(m: re.Match[str]) -> str:
|
|
135
|
+
key, sep, _value = m.groups()
|
|
136
|
+
if _SECRET_KEYWORD.search(key):
|
|
137
|
+
return f"{key}{sep}{REDACTED}"
|
|
138
|
+
return m.group(0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def redact_text(text: str) -> str:
|
|
142
|
+
"""Scrub known secrets and secret-shaped substrings from free text."""
|
|
143
|
+
if not text:
|
|
144
|
+
return text
|
|
145
|
+
text = known_secrets.scrub(text)
|
|
146
|
+
for pattern in _TOKEN_PATTERNS:
|
|
147
|
+
text = pattern.sub(REDACTED, text)
|
|
148
|
+
text = _BEARER.sub(lambda m: f"{m.group(1)} {REDACTED}", text)
|
|
149
|
+
text = _URL_USERINFO.sub(lambda m: f"{m.group(1)}:{REDACTED}@", text)
|
|
150
|
+
text = _ASSIGNMENT_IN_TEXT.sub(_redact_assignment, text)
|
|
151
|
+
return text
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def escape_control_chars(text: str) -> str:
|
|
155
|
+
"""Make control characters visible (`\\x1b`) so untrusted text cannot drive a terminal."""
|
|
156
|
+
return _CONTROL_CHARS.sub(lambda m: f"\\x{ord(m.group(0)):02x}", text)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def redact_value(value: str) -> str:
|
|
160
|
+
"""Redact a value whose key is already known (env var, header): keep only references.
|
|
161
|
+
|
|
162
|
+
`Bearer ${input:pat}` is kept too: an auth scheme plus a reference holds no secret.
|
|
163
|
+
"""
|
|
164
|
+
remainder = _ANY_VAR_REFERENCE.sub("", value).strip().lower()
|
|
165
|
+
if remainder in ("", "bearer", "basic", "token"):
|
|
166
|
+
return value
|
|
167
|
+
return REDACTED
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def redact_url(url: str) -> str:
|
|
171
|
+
"""Redact userinfo passwords, sensitive query params and opaque path segments."""
|
|
172
|
+
try:
|
|
173
|
+
parts = urlsplit(url)
|
|
174
|
+
except ValueError:
|
|
175
|
+
return redact_text(url)
|
|
176
|
+
if not parts.scheme or not parts.netloc:
|
|
177
|
+
return redact_text(url)
|
|
178
|
+
|
|
179
|
+
netloc = parts.netloc
|
|
180
|
+
if "@" in netloc:
|
|
181
|
+
userinfo, host = netloc.rsplit("@", 1)
|
|
182
|
+
user = userinfo.split(":", 1)[0]
|
|
183
|
+
netloc = f"{user}:{REDACTED}@{host}" if ":" in userinfo else f"{REDACTED}@{host}"
|
|
184
|
+
|
|
185
|
+
path = "/".join(REDACTED if looks_like_secret(seg) else seg for seg in parts.path.split("/"))
|
|
186
|
+
query_pairs = parse_qsl(parts.query, keep_blank_values=True)
|
|
187
|
+
query = urlencode(
|
|
188
|
+
[
|
|
189
|
+
(k, REDACTED if is_sensitive_name(k) or looks_like_secret(v) else v)
|
|
190
|
+
for k, v in query_pairs
|
|
191
|
+
],
|
|
192
|
+
safe="<>",
|
|
193
|
+
)
|
|
194
|
+
return redact_text(urlunsplit((parts.scheme, netloc, path, query, parts.fragment)))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
_FLAG_WITH_VALUE = re.compile(r"^(--?[A-Za-z0-9_.\-]+)=(.*)$", re.DOTALL)
|
|
198
|
+
_ASSIGNMENT = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", re.DOTALL)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def redact_args(args: list[str]) -> list[str]:
|
|
202
|
+
"""Redact a command line: values of sensitive flags, KEY=VALUE pairs, URLs, tokens."""
|
|
203
|
+
out: list[str] = []
|
|
204
|
+
redact_next = False
|
|
205
|
+
for arg in args:
|
|
206
|
+
if redact_next:
|
|
207
|
+
out.append(redact_value(arg))
|
|
208
|
+
redact_next = False
|
|
209
|
+
continue
|
|
210
|
+
if m := _FLAG_WITH_VALUE.match(arg):
|
|
211
|
+
flag, value = m.groups()
|
|
212
|
+
if is_sensitive_name(flag):
|
|
213
|
+
out.append(f"{flag}={redact_value(value)}")
|
|
214
|
+
else:
|
|
215
|
+
out.append(f"{flag}={_redact_free_value(value)}")
|
|
216
|
+
continue
|
|
217
|
+
if arg.startswith("-") and is_sensitive_name(arg):
|
|
218
|
+
out.append(arg)
|
|
219
|
+
redact_next = True
|
|
220
|
+
continue
|
|
221
|
+
if (m := _ASSIGNMENT.match(arg)) and is_sensitive_name(m.group(1)):
|
|
222
|
+
out.append(f"{m.group(1)}={redact_value(m.group(2))}")
|
|
223
|
+
continue
|
|
224
|
+
out.append(_redact_free_value(arg))
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def sensitive_arg_names(args: list[str]) -> list[str]:
|
|
229
|
+
"""Names of credential-carrying args, for auth evidence (never values)."""
|
|
230
|
+
names: list[str] = []
|
|
231
|
+
for i, arg in enumerate(args):
|
|
232
|
+
if m := _FLAG_WITH_VALUE.match(arg):
|
|
233
|
+
if is_sensitive_name(m.group(1)):
|
|
234
|
+
names.append(m.group(1))
|
|
235
|
+
elif arg.startswith("-") and is_sensitive_name(arg) and i + 1 < len(args):
|
|
236
|
+
names.append(arg)
|
|
237
|
+
elif (m := _ASSIGNMENT.match(arg)) and is_sensitive_name(m.group(1)):
|
|
238
|
+
names.append(m.group(1))
|
|
239
|
+
elif redact_text(arg) != arg or ("://" in arg and redact_url(arg) != arg):
|
|
240
|
+
names.append(f"arg[{i}]")
|
|
241
|
+
return names
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _redact_free_value(value: str) -> str:
|
|
245
|
+
if "://" in value:
|
|
246
|
+
return redact_url(value)
|
|
247
|
+
return redact_text(value)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class RedactingFilter(logging.Filter):
|
|
251
|
+
"""Logging filter that scrubs the fully formatted message before any handler sees it."""
|
|
252
|
+
|
|
253
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
254
|
+
record.msg = escape_control_chars(redact_text(record.getMessage()))
|
|
255
|
+
record.args = None
|
|
256
|
+
if record.exc_info and record.exc_info[1] is not None:
|
|
257
|
+
formatted = logging.Formatter().formatException(record.exc_info)
|
|
258
|
+
record.exc_text = escape_control_chars(redact_text(formatted))
|
|
259
|
+
record.exc_info = None
|
|
260
|
+
return True
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_logger(name: str) -> logging.Logger:
|
|
264
|
+
"""Logger with redaction attached at the logger level, so every handler is covered."""
|
|
265
|
+
logger = logging.getLogger(name)
|
|
266
|
+
if not any(isinstance(f, RedactingFilter) for f in logger.filters):
|
|
267
|
+
logger.addFilter(RedactingFilter())
|
|
268
|
+
return logger
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Render an evaluation as JSON, Markdown or SARIF, and decide the exit status."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
from agentcapmap.findings import Evaluation, Severity
|
|
9
|
+
from agentcapmap.report import markdown, sarif
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Format(StrEnum):
|
|
13
|
+
JSON = "json"
|
|
14
|
+
MARKDOWN = "markdown"
|
|
15
|
+
SARIF = "sarif"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render(evaluation: Evaluation, fmt: Format) -> str:
|
|
19
|
+
if fmt is Format.MARKDOWN:
|
|
20
|
+
return markdown.render(evaluation)
|
|
21
|
+
if fmt is Format.SARIF:
|
|
22
|
+
return json.dumps(sarif.render(evaluation), indent=2, ensure_ascii=False) + "\n"
|
|
23
|
+
return evaluation.model_dump_json(indent=2) + "\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def should_fail(
|
|
27
|
+
evaluation: Evaluation, fail_on: Severity | None, fail_on_not_assessed: bool = False
|
|
28
|
+
) -> bool:
|
|
29
|
+
"""True when a finding is at or above `fail_on`, or anything was not assessed and
|
|
30
|
+
`fail_on_not_assessed` is set."""
|
|
31
|
+
if fail_on is not None and any(f.severity.rank >= fail_on.rank for f in evaluation.findings):
|
|
32
|
+
return True
|
|
33
|
+
return fail_on_not_assessed and bool(evaluation.not_assessed)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
__all__ = ["Format", "render", "should_fail"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Helpers for putting untrusted text (tool names, descriptions, evidence, policy
|
|
2
|
+
messages) into reports.
|
|
3
|
+
|
|
4
|
+
Everything that came from an MCP server, a config file or a policy file is treated as
|
|
5
|
+
hostile: redacted again (defense in depth), stripped of control characters, collapsed
|
|
6
|
+
to one line, and truncated. In Markdown it is rendered only inside code spans, where
|
|
7
|
+
links, HTML and emphasis are inert.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from agentcapmap.redact import redact_text
|
|
15
|
+
|
|
16
|
+
# C0/C1 controls, line/paragraph separators, zero-width and bidi-override characters
|
|
17
|
+
# ("Trojan Source"). Built from code points so this source file stays ASCII.
|
|
18
|
+
_INVISIBLE_RANGES = (
|
|
19
|
+
(0x00, 0x1F),
|
|
20
|
+
(0x7F, 0x9F),
|
|
21
|
+
(0x200B, 0x200F),
|
|
22
|
+
(0x2028, 0x2029),
|
|
23
|
+
(0x202A, 0x202E),
|
|
24
|
+
(0x2066, 0x2069),
|
|
25
|
+
)
|
|
26
|
+
_CONTROL = re.compile(
|
|
27
|
+
"["
|
|
28
|
+
+ "".join(f"{re.escape(chr(lo))}-{re.escape(chr(hi))}" for lo, hi in _INVISIBLE_RANGES)
|
|
29
|
+
+ "]"
|
|
30
|
+
)
|
|
31
|
+
_SPACE = re.compile(r"\s+")
|
|
32
|
+
_BACKTICKS = re.compile(r"`+")
|
|
33
|
+
ELLIPSIS = chr(0x2026) # horizontal ellipsis
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def clean(text: object, limit: int = 300) -> str:
|
|
37
|
+
"""One line of plain text: redacted, no control or bidi characters, bounded length."""
|
|
38
|
+
value = redact_text(str(text))
|
|
39
|
+
value = _CONTROL.sub(" ", value)
|
|
40
|
+
value = _SPACE.sub(" ", value).strip()
|
|
41
|
+
if len(value) > limit:
|
|
42
|
+
value = value[: limit - 1].rstrip() + ELLIPSIS
|
|
43
|
+
return value
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def md_code(text: object, limit: int = 300, *, in_table: bool = False) -> str:
|
|
47
|
+
"""Render untrusted text as an inline code span.
|
|
48
|
+
|
|
49
|
+
The fence is one backtick longer than the longest backtick run inside, so the text
|
|
50
|
+
cannot close the span early. In tables `|` is escaped, which GFM honours even inside
|
|
51
|
+
code spans.
|
|
52
|
+
"""
|
|
53
|
+
value = clean(text, limit)
|
|
54
|
+
if not value:
|
|
55
|
+
return "-"
|
|
56
|
+
longest = max((len(m.group(0)) for m in _BACKTICKS.finditer(value)), default=0)
|
|
57
|
+
fence = "`" * (longest + 1)
|
|
58
|
+
if value.startswith("`") or value.endswith("`"):
|
|
59
|
+
value = f" {value} "
|
|
60
|
+
if in_table:
|
|
61
|
+
value = value.replace("|", "\\|")
|
|
62
|
+
return f"{fence}{value}{fence}"
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Markdown report (also used for the GitHub Actions job summary).
|
|
2
|
+
|
|
3
|
+
Only constant labels, numbers and severities appear outside code spans; every value
|
|
4
|
+
derived from configs, servers or policy files goes through `md_code`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from agentcapmap.findings import Evaluation, Finding, Severity
|
|
10
|
+
from agentcapmap.report._text import md_code
|
|
11
|
+
|
|
12
|
+
_SEVERITY_LABEL = {
|
|
13
|
+
Severity.CRITICAL: "Critical",
|
|
14
|
+
Severity.HIGH: "High",
|
|
15
|
+
Severity.MEDIUM: "Medium",
|
|
16
|
+
Severity.LOW: "Low",
|
|
17
|
+
Severity.INFO: "Info",
|
|
18
|
+
}
|
|
19
|
+
_MAX_FINDINGS = 200
|
|
20
|
+
_MAX_ROWS = 50
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _finding(index: int, finding: Finding) -> list[str]:
|
|
24
|
+
where = finding.location
|
|
25
|
+
location = f"{where.path}:{where.line}" if where and where.line else finding.config_path
|
|
26
|
+
lines = [
|
|
27
|
+
f"### {index}. {md_code(finding.rule_id, 40)} | {_SEVERITY_LABEL[finding.severity]}",
|
|
28
|
+
"",
|
|
29
|
+
md_code(finding.message, 500),
|
|
30
|
+
"",
|
|
31
|
+
f"- **Agent:** {md_code(finding.agent_id, 200)} at {md_code(location, 200)}",
|
|
32
|
+
f"- **Servers:** {', '.join(md_code(s, 100) for s in finding.servers)}",
|
|
33
|
+
]
|
|
34
|
+
if finding.tools:
|
|
35
|
+
lines.append(
|
|
36
|
+
f"- **Tools:** {', '.join(md_code(t, 120) for t in finding.tools[:_MAX_ROWS])}"
|
|
37
|
+
)
|
|
38
|
+
if finding.maps_to:
|
|
39
|
+
lines.append(f"- **Maps to:** {', '.join(md_code(m, 64) for m in finding.maps_to)}")
|
|
40
|
+
if finding.details:
|
|
41
|
+
for key in ("changed", "added", "removed"):
|
|
42
|
+
names = finding.details.get(key)
|
|
43
|
+
if names:
|
|
44
|
+
rendered = ", ".join(md_code(n, 120) for n in names[:_MAX_ROWS])
|
|
45
|
+
lines.append(f"- **Tools {key}:** {rendered}")
|
|
46
|
+
if finding.evidence:
|
|
47
|
+
lines += ["", "| Server | Tool | Matched | Evidence |", "|---|---|---|---|"]
|
|
48
|
+
for ev in finding.evidence[:_MAX_ROWS]:
|
|
49
|
+
matched = ", ".join(f"{k}={str(v).lower()}" for k, v in ev.matched.items())
|
|
50
|
+
phrases = "; ".join(f"{k}: {v}" for k, v in ev.phrases.items())
|
|
51
|
+
if ev.unverified:
|
|
52
|
+
phrases += f" (unverified: {', '.join(ev.unverified)})"
|
|
53
|
+
lines.append(
|
|
54
|
+
"| "
|
|
55
|
+
+ " | ".join(
|
|
56
|
+
[
|
|
57
|
+
md_code(ev.server, 100, in_table=True),
|
|
58
|
+
md_code(ev.tool or "", 100, in_table=True),
|
|
59
|
+
md_code(matched, 200, in_table=True),
|
|
60
|
+
md_code(phrases, 300, in_table=True),
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
+ " |"
|
|
64
|
+
)
|
|
65
|
+
if len(finding.evidence) > _MAX_ROWS:
|
|
66
|
+
lines.append(f"\n_{len(finding.evidence) - _MAX_ROWS} more evidence rows omitted._")
|
|
67
|
+
return [*lines, ""]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def render(evaluation: Evaluation) -> str:
|
|
71
|
+
summary = evaluation.summary
|
|
72
|
+
lines = [
|
|
73
|
+
"# agent-capmap report",
|
|
74
|
+
"",
|
|
75
|
+
f"**Policy:** {md_code(evaluation.policy.source, 200)} "
|
|
76
|
+
f"(sha256 {md_code(evaluation.policy.sha256[:12], 12)}) | "
|
|
77
|
+
f"**Baseline:** {'used' if evaluation.baseline_used else 'not used'} | "
|
|
78
|
+
f"**Agents:** {len(evaluation.agents)}",
|
|
79
|
+
"",
|
|
80
|
+
"| Severity | Findings |",
|
|
81
|
+
"|---|---|",
|
|
82
|
+
*(f"| {_SEVERITY_LABEL[s]} | {summary.get(s.value, 0)} |" for s in Severity),
|
|
83
|
+
"",
|
|
84
|
+
]
|
|
85
|
+
not_assessed = len(evaluation.not_assessed)
|
|
86
|
+
if not_assessed:
|
|
87
|
+
lines += [
|
|
88
|
+
f"> [!WARNING]\n> {not_assessed} server(s) or tool(s) were **not assessed**, so "
|
|
89
|
+
"findings may be incomplete. See the table at the end.",
|
|
90
|
+
"",
|
|
91
|
+
]
|
|
92
|
+
if evaluation.skipped_rules:
|
|
93
|
+
skipped = ", ".join(
|
|
94
|
+
f"{md_code(s.rule_id, 40)} ({md_code(s.reason, 100)})" for s in evaluation.skipped_rules
|
|
95
|
+
)
|
|
96
|
+
lines += [f"Skipped rules: {skipped}", ""]
|
|
97
|
+
|
|
98
|
+
lines += ["## Findings", ""]
|
|
99
|
+
if not evaluation.findings and not_assessed:
|
|
100
|
+
lines += [
|
|
101
|
+
"No findings, but this is **not an all-clear**: some tools were not assessed. "
|
|
102
|
+
"To assess them, run `agentcapmap scan <path> --enumerate --classify --evaluate` "
|
|
103
|
+
"(this starts the configured servers and needs an API key), or use a saved "
|
|
104
|
+
"inventory whose tools are already classified.",
|
|
105
|
+
"",
|
|
106
|
+
]
|
|
107
|
+
elif not evaluation.findings:
|
|
108
|
+
lines += ["No findings.", ""]
|
|
109
|
+
for index, finding in enumerate(evaluation.findings[:_MAX_FINDINGS], start=1):
|
|
110
|
+
lines += _finding(index, finding)
|
|
111
|
+
if len(evaluation.findings) > _MAX_FINDINGS:
|
|
112
|
+
lines += [
|
|
113
|
+
f"_{len(evaluation.findings) - _MAX_FINDINGS} more findings omitted; "
|
|
114
|
+
"see the JSON output._",
|
|
115
|
+
"",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
if not_assessed:
|
|
119
|
+
lines += ["## Not assessed", "", "| Agent | Server | Tool | Reason |", "|---|---|---|---|"]
|
|
120
|
+
for item in evaluation.not_assessed[:_MAX_FINDINGS]:
|
|
121
|
+
lines.append(
|
|
122
|
+
"| "
|
|
123
|
+
+ " | ".join(
|
|
124
|
+
[
|
|
125
|
+
md_code(item.agent_id, 120, in_table=True),
|
|
126
|
+
md_code(item.server, 100, in_table=True),
|
|
127
|
+
md_code(item.tool or "", 100, in_table=True),
|
|
128
|
+
md_code(item.reason, 200, in_table=True),
|
|
129
|
+
]
|
|
130
|
+
)
|
|
131
|
+
+ " |"
|
|
132
|
+
)
|
|
133
|
+
lines.append("")
|
|
134
|
+
|
|
135
|
+
lines += ["## Capabilities by agent", ""]
|
|
136
|
+
for agent in evaluation.agents:
|
|
137
|
+
lines += [
|
|
138
|
+
f"### {md_code(agent.agent_id, 200)}",
|
|
139
|
+
"",
|
|
140
|
+
f"{agent.tools_assessed} of {agent.tools_total} tool(s) assessed.",
|
|
141
|
+
"",
|
|
142
|
+
]
|
|
143
|
+
if not agent.capabilities:
|
|
144
|
+
lines += ["No capabilities beyond the defaults.", ""]
|
|
145
|
+
continue
|
|
146
|
+
lines += ["| Capability | Tools |", "|---|---|"]
|
|
147
|
+
for capability, refs in agent.capabilities.items():
|
|
148
|
+
tools = ", ".join(f"{r.server}/{r.tool}" for r in refs[:_MAX_ROWS])
|
|
149
|
+
cap_cell = md_code(capability, 60, in_table=True)
|
|
150
|
+
lines.append(f"| {cap_cell} | {md_code(tools, 500, in_table=True)} |")
|
|
151
|
+
lines.append("")
|
|
152
|
+
return "\n".join(lines).rstrip() + "\n"
|