attackmap-analyzer-atproto 0.2.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.
- attackmap_analyzer_atproto/__init__.py +3 -0
- attackmap_analyzer_atproto/analyzer.py +623 -0
- attackmap_analyzer_atproto-0.2.0.dist-info/METADATA +81 -0
- attackmap_analyzer_atproto-0.2.0.dist-info/RECORD +8 -0
- attackmap_analyzer_atproto-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_atproto-0.2.0.dist-info/entry_points.txt +2 -0
- attackmap_analyzer_atproto-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_atproto-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import bisect
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from attackmap.sdk import (
|
|
9
|
+
DEFAULT_SKIP_DIRS,
|
|
10
|
+
AnalyzerMetadata,
|
|
11
|
+
AuthHint,
|
|
12
|
+
ExternalCall,
|
|
13
|
+
iter_repo_files,
|
|
14
|
+
line_of,
|
|
15
|
+
line_snippet,
|
|
16
|
+
ProtocolHint,
|
|
17
|
+
read_source,
|
|
18
|
+
rel,
|
|
19
|
+
Route,
|
|
20
|
+
ScanResult,
|
|
21
|
+
SecretHint,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
CODE_SUFFIXES = {".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"}
|
|
26
|
+
# Pruned by repo-relative directory name; also skips AttackMap's own report
|
|
27
|
+
# output, so a previous run's attackmap-report.json isn't re-read (AttackMap#253).
|
|
28
|
+
SKIP_DIRS = DEFAULT_SKIP_DIRS
|
|
29
|
+
|
|
30
|
+
# Lockfiles and AttackMap's own output mention every package / NSID; never
|
|
31
|
+
# read them (detect() used to fire on a package-lock.json containing "lexicon").
|
|
32
|
+
NON_SOURCE_NAMES = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"package-lock.json",
|
|
35
|
+
"npm-shrinkwrap.json",
|
|
36
|
+
"attackmap-report.json",
|
|
37
|
+
"review-context-pack.json",
|
|
38
|
+
"defensive-review.json",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Namespace authorities always recognized. Any other authority is learned
|
|
43
|
+
# from the repo's own lexicon `id`s (e.g. `sh.tangled`, `blue.linkat`).
|
|
44
|
+
BUILTIN_NAMESPACE_ROOTS = ("com.atproto", "app.bsky", "chat.bsky", "tools.ozone")
|
|
45
|
+
# An NSID: reverse-DNS authority + name, at least three segments.
|
|
46
|
+
NSID = r"[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?){2,}"
|
|
47
|
+
NSID_PATTERN = re.compile(rf"^{NSID}$")
|
|
48
|
+
# `/xrpc/<nsid>` is specific enough to accept any authority.
|
|
49
|
+
XRPC_LITERAL_PATTERN = re.compile(rf"['\"`](?:https?://[^'\"`/]+)?/xrpc/({NSID})['\"`?]")
|
|
50
|
+
ENV_URL_PATTERN = re.compile(r"process\.env\.([A-Z0-9_]+_URL)\b")
|
|
51
|
+
|
|
52
|
+
# XRPC method types and their HTTP binding (query = GET, procedure = POST,
|
|
53
|
+
# subscription = WebSocket). `record`, `object` and defs-only lexicons are
|
|
54
|
+
# schemas, not endpoints.
|
|
55
|
+
XRPC_METHODS = {"query": "GET", "procedure": "POST", "subscription": "WS"}
|
|
56
|
+
|
|
57
|
+
# Handler registration: `server.method('<nsid>', handlerOrConfig)` and the
|
|
58
|
+
# generated-API form `server.app.bsky.feed.getTimeline({ auth, handler })`.
|
|
59
|
+
XRPC_METHOD_CALL_PATTERN = re.compile(rf"\b(?:server|xrpc|xrpcServer)\.method\(\s*['\"`]({NSID})['\"`]\s*,\s*")
|
|
60
|
+
XRPC_GENERATED_CALL_PATTERN = re.compile(rf"\bserver\.({NSID})\(\s*(?=\{{)")
|
|
61
|
+
# The current xrpc-server API: `server.add(app.bsky.actor.putPreferences, { auth, handler })`
|
|
62
|
+
# (or a bare handler), where the first argument is the generated lexicon
|
|
63
|
+
# namespace object, spelled like its NSID (mlaify/attackmap-analyzer-atproto#14).
|
|
64
|
+
XRPC_ADD_CALL_PATTERN = re.compile(rf"\b(?:server|xrpc|xrpcServer)\.add\(\s*({NSID})\s*,\s*")
|
|
65
|
+
# A handler passed as a function: no `auth:`, so the endpoint is anonymous.
|
|
66
|
+
INLINE_FUNCTION_PATTERN = re.compile(r"(?:async\s+)?(?:function\b|\([^()]{0,200}\)\s*=>|[A-Za-z_$][\w$]{0,63}\s*=>)")
|
|
67
|
+
# `{ auth, handler }` shorthand or `{ ...config }` spread: the verifier isn't visible here.
|
|
68
|
+
# `const auth = ctx.authVerifier.standard` earlier in the file, for `{ auth, handler }`
|
|
69
|
+
# or `auth: someVerifier` (#14).
|
|
70
|
+
CONST_DECL_PATTERN = re.compile(r"\b(?:const|let|var)[ \t]+([A-Za-z_$][\w$]{0,63})[ \t]*=[ \t]*([^\n;]{1,200})")
|
|
71
|
+
AUTH_SHORTHAND_NAME_PATTERN = re.compile(r"(?:^|[{,\s])auth\s*(?=[,}])")
|
|
72
|
+
BARE_NAME_PATTERN = re.compile(r"[A-Za-z_$][\w$]{0,63}$")
|
|
73
|
+
AUTH_SHORTHAND_PATTERN = re.compile(r"(?:^|[{,\s])(?:auth\s*(?=[,}])|\.\.\.)")
|
|
74
|
+
AUTH_KEY_PATTERN = re.compile(r"(?:^|[{,\s])auth\s*:\s*(?P<verifier>[^,}\n]+)")
|
|
75
|
+
# Verifiers that let unauthenticated callers through.
|
|
76
|
+
OPTIONAL_VERIFIER_PATTERN = re.compile(r"optional|nullCreds|\bnull\b|\bundefined\b", re.IGNORECASE)
|
|
77
|
+
|
|
78
|
+
# Auth, identity (DID/PLC) and signing cues: genuine auth signals, emitted as AuthHint.
|
|
79
|
+
AUTH_HINT_PATTERNS = [
|
|
80
|
+
(re.compile(r"\bjwt\b|\bjsonwebtoken\b", re.IGNORECASE), "atproto_auth:jwt"),
|
|
81
|
+
(re.compile(r"\bserviceauth\b|\bservice_auth\b", re.IGNORECASE), "atproto_auth:service_auth"),
|
|
82
|
+
(re.compile(r"\bdid:[a-z0-9:._-]+\b", re.IGNORECASE), "atproto_identity:did_reference"),
|
|
83
|
+
# The PLC directory / did:plc identifiers, not any word "plc".
|
|
84
|
+
(re.compile(r"did:plc:|\bplc\.directory\b|@did-plc/|\bPlcClient\b"), "atproto_identity:plc"),
|
|
85
|
+
# Signing/verification APIs and key types, not the words "sign"/"verify".
|
|
86
|
+
(
|
|
87
|
+
re.compile(
|
|
88
|
+
r"@atproto/crypto|\b(?:Secp256k1Keypair|P256Keypair|verifySignature|verifyJwt|createServiceJwt"
|
|
89
|
+
r"|createServiceAuthHeaders|signJwt|verifyCommitSig|signCommit)\b"
|
|
90
|
+
r"|\b(?:keypair|signingKey|repoSigningKey|crypto)\.(?:sign|verify)\s*\("
|
|
91
|
+
),
|
|
92
|
+
"atproto_crypto:signing",
|
|
93
|
+
),
|
|
94
|
+
]
|
|
95
|
+
# Protocol data-flow cues: ProtocolHint (AttackMap#258).
|
|
96
|
+
PROTOCOL_HINT_PATTERNS = [
|
|
97
|
+
(re.compile(r"xrpc", re.IGNORECASE), "atproto_protocol:xrpc"),
|
|
98
|
+
(re.compile(r"\brepo\b.*\bcommit\b|\bcommit\b.*\brepo\b", re.IGNORECASE), "atproto_repo:commit_flow"),
|
|
99
|
+
]
|
|
100
|
+
SECRET_ENV_PATTERN = re.compile(
|
|
101
|
+
r"process\.env\.([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|SIGNING)[A-Z0-9_]*)"
|
|
102
|
+
r"|process\.env\[\s*['\"]([A-Z0-9_]*(?:SECRET|TOKEN|KEY|PASSWORD|SIGNING)[A-Z0-9_]*)['\"]\s*\]"
|
|
103
|
+
)
|
|
104
|
+
LEXICON_ID_PATTERN = re.compile(r'"id"\s*:')
|
|
105
|
+
LEXICON_SUBSCRIPTION_PATTERN = re.compile(r'"type"\s*:\s*"subscription"', re.IGNORECASE)
|
|
106
|
+
|
|
107
|
+
EVENT_STREAM_PATTERNS = [
|
|
108
|
+
(re.compile(r"\bsubscriberepos\b", re.IGNORECASE), "atproto_event_stream:subscribe_repos"),
|
|
109
|
+
(re.compile(r"\bwebsocket\b|\bws://\b|\bwss://\b", re.IGNORECASE), "atproto_event_stream:websocket"),
|
|
110
|
+
(re.compile(r"\bfirehose\b", re.IGNORECASE), "atproto_event_stream:firehose"),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class AtprotoAnalyzer:
|
|
115
|
+
metadata = AnalyzerMetadata(
|
|
116
|
+
name="atproto",
|
|
117
|
+
display_name="AT Protocol Analyzer",
|
|
118
|
+
version="0.2.0",
|
|
119
|
+
description="Thin protocol-aware overlay analyzer for AT Protocol namespace and XRPC exposure.",
|
|
120
|
+
scope="AT Protocol repositories with lexicons, XRPC namespace usage, and protocol auth/identity cues.",
|
|
121
|
+
targets=["atproto", "bluesky", "xrpc"],
|
|
122
|
+
languages=["typescript", "javascript", "json"],
|
|
123
|
+
priority=90,
|
|
124
|
+
experimental=True,
|
|
125
|
+
enabled_by_default=False,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def name(self) -> str:
|
|
130
|
+
return self.metadata.name
|
|
131
|
+
|
|
132
|
+
def detect(self, repo_path: str | Path) -> bool:
|
|
133
|
+
root = Path(repo_path).resolve()
|
|
134
|
+
if not root.exists() or not root.is_dir():
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
if (root / "lexicons").is_dir():
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
nsid_literal = self._known_nsid_literal_pattern(BUILTIN_NAMESPACE_ROOTS)
|
|
141
|
+
for file_path in iter_repo_files(root, skip_dirs=SKIP_DIRS):
|
|
142
|
+
# Repo-relative, so a checkout under e.g. ~/src/app.bsky/ doesn't
|
|
143
|
+
# make every repo look like an AT Protocol one.
|
|
144
|
+
relative = rel(file_path, root)
|
|
145
|
+
path_text = relative.lower()
|
|
146
|
+
if any(ns in path_text for ns in BUILTIN_NAMESPACE_ROOTS):
|
|
147
|
+
return True
|
|
148
|
+
if file_path.name in NON_SOURCE_NAMES:
|
|
149
|
+
continue
|
|
150
|
+
suffix = file_path.suffix.lower()
|
|
151
|
+
if file_path.name == "package.json":
|
|
152
|
+
if self._declares_atproto_dependency(read_source(file_path)):
|
|
153
|
+
return True
|
|
154
|
+
continue
|
|
155
|
+
if suffix == ".json":
|
|
156
|
+
# Only lexicon documents count; other JSON isn't read.
|
|
157
|
+
if "lexicons" in relative.split("/")[:-1] and self._is_lexicon_document(read_source(file_path)):
|
|
158
|
+
return True
|
|
159
|
+
continue
|
|
160
|
+
if suffix not in CODE_SUFFIXES:
|
|
161
|
+
continue
|
|
162
|
+
content = read_source(file_path)
|
|
163
|
+
if not content:
|
|
164
|
+
continue
|
|
165
|
+
# A quoted NSID under a known authority, or an `/xrpc/<nsid>` URL.
|
|
166
|
+
if nsid_literal.search(content) or XRPC_LITERAL_PATTERN.search(content):
|
|
167
|
+
return True
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _declares_atproto_dependency(text: str | None) -> bool:
|
|
172
|
+
if not text:
|
|
173
|
+
return False
|
|
174
|
+
try:
|
|
175
|
+
data = json.loads(text)
|
|
176
|
+
except json.JSONDecodeError:
|
|
177
|
+
return False
|
|
178
|
+
if not isinstance(data, dict):
|
|
179
|
+
return False
|
|
180
|
+
for field in ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"):
|
|
181
|
+
deps = data.get(field)
|
|
182
|
+
if isinstance(deps, dict) and any(
|
|
183
|
+
isinstance(name, str) and (name.startswith("@atproto/") or name.startswith("@did-plc/"))
|
|
184
|
+
for name in deps
|
|
185
|
+
):
|
|
186
|
+
return True
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def _is_lexicon_document(text: str | None) -> bool:
|
|
191
|
+
if not text:
|
|
192
|
+
return False
|
|
193
|
+
try:
|
|
194
|
+
data = json.loads(text)
|
|
195
|
+
except json.JSONDecodeError:
|
|
196
|
+
return False
|
|
197
|
+
return (
|
|
198
|
+
isinstance(data, dict)
|
|
199
|
+
and isinstance(data.get("lexicon"), int)
|
|
200
|
+
and isinstance(data.get("id"), str)
|
|
201
|
+
and bool(NSID_PATTERN.match(data["id"]))
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _known_nsid_literal_pattern(roots) -> re.Pattern:
|
|
206
|
+
alternation = "|".join(re.escape(root) for root in sorted(set(roots), key=len, reverse=True))
|
|
207
|
+
return re.compile(rf"['\"`](?:{alternation})(?:\.[A-Za-z0-9-]+)+['\"`]")
|
|
208
|
+
|
|
209
|
+
def analyze(self, repo_path: str | Path) -> ScanResult:
|
|
210
|
+
root = Path(repo_path).resolve()
|
|
211
|
+
result = ScanResult(root=str(root))
|
|
212
|
+
if not root.exists() or not root.is_dir():
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
lexicon_files: list[tuple[Path, str]] = []
|
|
216
|
+
code_files: list[Path] = []
|
|
217
|
+
for file_path in iter_repo_files(root, suffixes=CODE_SUFFIXES, skip_dirs=SKIP_DIRS):
|
|
218
|
+
if file_path.name in NON_SOURCE_NAMES:
|
|
219
|
+
continue
|
|
220
|
+
if file_path.suffix.lower() != ".json":
|
|
221
|
+
code_files.append(file_path)
|
|
222
|
+
continue
|
|
223
|
+
# JSON is only read for lexicon documents (`{"lexicon": 1, "id": ...}`);
|
|
224
|
+
# tsconfig, fixtures and data files aren't protocol surface.
|
|
225
|
+
content = read_source(file_path)
|
|
226
|
+
if content is not None and self._is_lexicon_document(content):
|
|
227
|
+
lexicon_files.append((file_path, content))
|
|
228
|
+
|
|
229
|
+
# Lexicons first: they define each NSID's method type and teach the
|
|
230
|
+
# code pass which namespace authorities this repo uses.
|
|
231
|
+
lexicon_types: dict[str, str] = {}
|
|
232
|
+
namespace_roots: set[str] = set(BUILTIN_NAMESPACE_ROOTS)
|
|
233
|
+
for lexicon_path, content in lexicon_files:
|
|
234
|
+
result.files_scanned += 1
|
|
235
|
+
self._append_language(result, "json")
|
|
236
|
+
self._extract_lexicon_signals(content, rel(lexicon_path, root), result, lexicon_types, namespace_roots)
|
|
237
|
+
|
|
238
|
+
namespace_pattern = re.compile(
|
|
239
|
+
r"\b((?:"
|
|
240
|
+
+ "|".join(re.escape(r) for r in sorted(namespace_roots, key=len, reverse=True))
|
|
241
|
+
+ r")(?:\.[A-Za-z0-9_-]+)+)\b"
|
|
242
|
+
)
|
|
243
|
+
for file_path in code_files:
|
|
244
|
+
suffix = file_path.suffix.lower()
|
|
245
|
+
result.files_scanned += 1
|
|
246
|
+
self._append_language(result, "typescript" if suffix in {".ts", ".tsx"} else "javascript")
|
|
247
|
+
content = read_source(file_path)
|
|
248
|
+
if content is None:
|
|
249
|
+
continue
|
|
250
|
+
relative = rel(file_path, root)
|
|
251
|
+
|
|
252
|
+
self._extract_namespace_signals(content, relative, result, namespace_pattern)
|
|
253
|
+
self._extract_xrpc_literals(content, relative, result, lexicon_types)
|
|
254
|
+
self._extract_xrpc_handlers(content, relative, result, lexicon_types)
|
|
255
|
+
self._extract_protocol_hints(content, relative, result)
|
|
256
|
+
self._extract_event_stream_hints(content, relative, result)
|
|
257
|
+
self._extract_env_url_signals(content, relative, result)
|
|
258
|
+
self._extract_service_notes(relative, result)
|
|
259
|
+
self._extract_secret_hints(content, relative, result)
|
|
260
|
+
|
|
261
|
+
result.languages.sort()
|
|
262
|
+
return result
|
|
263
|
+
|
|
264
|
+
def _extract_lexicon_signals(
|
|
265
|
+
self,
|
|
266
|
+
content: str,
|
|
267
|
+
relative: str,
|
|
268
|
+
result: ScanResult,
|
|
269
|
+
lexicon_types: dict[str, str],
|
|
270
|
+
namespace_roots: set[str],
|
|
271
|
+
) -> None:
|
|
272
|
+
try:
|
|
273
|
+
data = json.loads(content)
|
|
274
|
+
except json.JSONDecodeError:
|
|
275
|
+
return
|
|
276
|
+
if not isinstance(data, dict):
|
|
277
|
+
return
|
|
278
|
+
|
|
279
|
+
id_match = LEXICON_ID_PATTERN.search(content)
|
|
280
|
+
id_offset = id_match.start() if id_match else 0
|
|
281
|
+
sub_match = LEXICON_SUBSCRIPTION_PATTERN.search(content)
|
|
282
|
+
sub_offset = sub_match.start() if sub_match else id_offset
|
|
283
|
+
|
|
284
|
+
lexicon_id = data.get("id")
|
|
285
|
+
if not isinstance(lexicon_id, str) or not lexicon_id:
|
|
286
|
+
return
|
|
287
|
+
namespace_root = self._namespace_root(lexicon_id)
|
|
288
|
+
if namespace_root:
|
|
289
|
+
namespace_roots.add(namespace_root)
|
|
290
|
+
self._append_protocol(result, f"atproto_namespace:{namespace_root}", relative, content, id_offset, 0.9)
|
|
291
|
+
self._append_protocol(result, f"atproto_lexicon:{lexicon_id}", relative, content, id_offset, 0.9)
|
|
292
|
+
|
|
293
|
+
# Only `defs.main` of type query/procedure/subscription is an
|
|
294
|
+
# endpoint; record, object and defs-only lexicons are schemas.
|
|
295
|
+
defs = data.get("defs")
|
|
296
|
+
main = defs.get("main") if isinstance(defs, dict) else None
|
|
297
|
+
def_type = str(main.get("type", "")).lower() if isinstance(main, dict) else ""
|
|
298
|
+
method = XRPC_METHODS.get(def_type)
|
|
299
|
+
if method is None:
|
|
300
|
+
return
|
|
301
|
+
lexicon_types[lexicon_id] = method
|
|
302
|
+
offset = sub_offset if def_type == "subscription" else id_offset
|
|
303
|
+
# A lexicon declares an endpoint; it doesn't serve one (#14). Repos
|
|
304
|
+
# that only consume the API (social-app) vendor hundreds of them. The
|
|
305
|
+
# route comes from the handler registration; the declaration stays a
|
|
306
|
+
# protocol hint, with the method in its evidence.
|
|
307
|
+
self._append_protocol(
|
|
308
|
+
result, f"atproto_xrpc_declared:{lexicon_id}", relative, content, offset, 0.9,
|
|
309
|
+
evidence=f"{method} /xrpc/{lexicon_id} declared by lexicon (no route unless a handler registers it)",
|
|
310
|
+
)
|
|
311
|
+
if def_type == "subscription":
|
|
312
|
+
self._append_protocol(
|
|
313
|
+
result, "atproto_event_stream:subscription_lexicon", relative, content, sub_offset, 0.9
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _extract_namespace_signals(
|
|
317
|
+
self, content: str, relative: str, result: ScanResult, namespace_pattern: re.Pattern
|
|
318
|
+
) -> None:
|
|
319
|
+
for match in namespace_pattern.finditer(content):
|
|
320
|
+
namespace = match.group(1)
|
|
321
|
+
namespace_root = self._namespace_root(namespace)
|
|
322
|
+
if namespace_root:
|
|
323
|
+
self._append_protocol(result, f"atproto_namespace:{namespace_root}", relative, content, match.start(), 0.8)
|
|
324
|
+
self._append_protocol(result, f"atproto_namespace_ref:{namespace}", relative, content, match.start(), 0.8)
|
|
325
|
+
|
|
326
|
+
def _extract_xrpc_literals(
|
|
327
|
+
self, content: str, relative: str, result: ScanResult, lexicon_types: dict[str, str]
|
|
328
|
+
) -> None:
|
|
329
|
+
for match in XRPC_LITERAL_PATTERN.finditer(content):
|
|
330
|
+
ns = match.group(1)
|
|
331
|
+
method = lexicon_types.get(ns, "ANY")
|
|
332
|
+
self._append_unique_route(result, f"/xrpc/{ns}", method, relative, line_of(content, match.start()))
|
|
333
|
+
self._append_protocol(result, f"atproto_xrpc_ref:{ns}", relative, content, match.start(), 0.8)
|
|
334
|
+
|
|
335
|
+
def _extract_xrpc_handlers(
|
|
336
|
+
self, content: str, relative: str, result: ScanResult, lexicon_types: dict[str, str]
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Server-side handler registrations and their per-endpoint auth.
|
|
339
|
+
|
|
340
|
+
`server.method(nsid, { auth: ctx.authVerifier.standard, handler })`
|
|
341
|
+
(and the generated `server.<nsid>({ auth, handler })` form) registers
|
|
342
|
+
an XRPC endpoint. The `auth:` verifier, if any, is recorded per
|
|
343
|
+
endpoint: an `atproto_auth:route_verifier:<nsid>` AuthHint when one is
|
|
344
|
+
set, an `atproto_route_auth:anonymous:<nsid>` ProtocolHint when there
|
|
345
|
+
is none (a bare handler function, a config without `auth:`) or the
|
|
346
|
+
verifier admits unauthenticated callers (`*Optional*`, `nullCreds`).
|
|
347
|
+
The anonymous marker is deliberately not an AuthHint, so core never
|
|
348
|
+
counts it as an auth control.
|
|
349
|
+
"""
|
|
350
|
+
calls = [(m, m.group(1)) for m in XRPC_METHOD_CALL_PATTERN.finditer(content)]
|
|
351
|
+
declarations = self._declarations(content) if "auth" in content else {}
|
|
352
|
+
calls += [(m, m.group(1).removesuffix(".main")) for m in XRPC_ADD_CALL_PATTERN.finditer(content)]
|
|
353
|
+
calls += [
|
|
354
|
+
(m, m.group(1))
|
|
355
|
+
for m in XRPC_GENERATED_CALL_PATTERN.finditer(content)
|
|
356
|
+
if m.group(1).split(".", 1)[0] != "method"
|
|
357
|
+
]
|
|
358
|
+
for match, nsid in sorted(calls, key=lambda item: item[0].start()):
|
|
359
|
+
offset = match.start()
|
|
360
|
+
line = line_of(content, offset)
|
|
361
|
+
method = lexicon_types.get(nsid, "ANY")
|
|
362
|
+
literal = self._object_literal_top_level(content, match.end())
|
|
363
|
+
verifier_match = AUTH_KEY_PATTERN.search(literal[1]) if literal is not None else None
|
|
364
|
+
verifier = verifier_match.group("verifier").strip() if verifier_match else None
|
|
365
|
+
# `{ auth, handler }` or `auth: someVerifier`: use the variable's
|
|
366
|
+
# value when the file declares it before the registration.
|
|
367
|
+
if literal is not None and verifier_match is None and AUTH_SHORTHAND_PATTERN.search(literal[1]):
|
|
368
|
+
name = "auth" if AUTH_SHORTHAND_NAME_PATTERN.search(literal[1]) else None
|
|
369
|
+
resolved = self._declared_value(declarations, name, offset) if name else None
|
|
370
|
+
if resolved:
|
|
371
|
+
verifier_match, verifier = True, resolved
|
|
372
|
+
elif verifier and BARE_NAME_PATTERN.match(verifier):
|
|
373
|
+
verifier = self._declared_value(declarations, verifier, offset) or verifier
|
|
374
|
+
guarded = bool(
|
|
375
|
+
literal is not None and verifier_match and verifier and not OPTIONAL_VERIFIER_PATTERN.search(verifier)
|
|
376
|
+
)
|
|
377
|
+
# The verifier can't be read: a config passed by name
|
|
378
|
+
# (`server.add(ns, config)`), or `{ auth, handler }` / `{ ...cfg }`
|
|
379
|
+
# without an explicit `auth:` (#14). A handler function has no
|
|
380
|
+
# verifier at all, so it is anonymous.
|
|
381
|
+
unreadable = (literal is None and not INLINE_FUNCTION_PATTERN.match(content, match.end())) or (
|
|
382
|
+
literal is not None and verifier_match is None and AUTH_SHORTHAND_PATTERN.search(literal[1])
|
|
383
|
+
)
|
|
384
|
+
if unreadable:
|
|
385
|
+
self._append_unique_route(
|
|
386
|
+
result, f"/xrpc/{nsid}", method, relative, line,
|
|
387
|
+
guard_evidence="auth verifier not visible at the registration",
|
|
388
|
+
)
|
|
389
|
+
continue
|
|
390
|
+
# Route.auth (AttackMap#256): a required verifier guards the
|
|
391
|
+
# endpoint; no verifier, or one that admits unauthenticated
|
|
392
|
+
# callers, leaves it reachable anonymously.
|
|
393
|
+
self._append_unique_route(
|
|
394
|
+
result, f"/xrpc/{nsid}", method, relative, line,
|
|
395
|
+
auth="required" if guarded else "anonymous",
|
|
396
|
+
guards=[f"auth: {verifier}"] if guarded else [],
|
|
397
|
+
guard_evidence=(f"auth: {verifier}" if verifier else "no auth verifier on handler"),
|
|
398
|
+
)
|
|
399
|
+
if guarded:
|
|
400
|
+
# Cite the `auth: <verifier>` line itself.
|
|
401
|
+
verifier_offset = literal[0] + verifier_match.start("verifier") if verifier_match is not True else offset
|
|
402
|
+
self._append_unique_auth(
|
|
403
|
+
result, f"atproto_auth:route_verifier:{nsid}", relative, content, verifier_offset
|
|
404
|
+
)
|
|
405
|
+
else:
|
|
406
|
+
self._append_protocol(
|
|
407
|
+
result, f"atproto_route_auth:anonymous:{nsid}", relative, content, offset, 0.7
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
@staticmethod
|
|
411
|
+
def _declarations(content: str) -> dict[str, tuple[list[int], list[str]]]:
|
|
412
|
+
"""`const|let|var <name> = …` in ``content``: name → (offsets, values), in
|
|
413
|
+
order. Built once per file, so many registrations stay linear."""
|
|
414
|
+
index: dict[str, tuple[list[int], list[str]]] = {}
|
|
415
|
+
for decl in CONST_DECL_PATTERN.finditer(content):
|
|
416
|
+
offsets, values = index.setdefault(decl.group(1), ([], []))
|
|
417
|
+
offsets.append(decl.start())
|
|
418
|
+
values.append(decl.group(2).strip())
|
|
419
|
+
return index
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
def _declared_value(declarations: dict[str, tuple[list[int], list[str]]], name: str, before: int) -> str | None:
|
|
423
|
+
"""The right-hand side of the last `const|let|var <name> = …` before ``before``."""
|
|
424
|
+
entry = declarations.get(name)
|
|
425
|
+
if entry is None:
|
|
426
|
+
return None
|
|
427
|
+
k = bisect.bisect_left(entry[0], before)
|
|
428
|
+
return entry[1][k - 1] if k else None
|
|
429
|
+
|
|
430
|
+
@staticmethod
|
|
431
|
+
def _object_literal_top_level(content: str, start: int, limit: int = 20000) -> tuple[int, str] | None:
|
|
432
|
+
"""The `{...}` literal at ``start`` with everything below its top level blanked.
|
|
433
|
+
|
|
434
|
+
Returns ``(offset, masked)`` where ``masked[i]`` corresponds to
|
|
435
|
+
``content[offset + i]`` (nested blocks become spaces, so offsets still
|
|
436
|
+
line up), or None when ``start`` isn't an object literal (e.g. a bare
|
|
437
|
+
handler function). Strings and template literals are skipped so braces
|
|
438
|
+
inside them don't unbalance the scan.
|
|
439
|
+
"""
|
|
440
|
+
index = start
|
|
441
|
+
while index < len(content) and content[index].isspace():
|
|
442
|
+
index += 1
|
|
443
|
+
if index >= len(content) or content[index] != "{":
|
|
444
|
+
return None
|
|
445
|
+
end = min(len(content), index + limit)
|
|
446
|
+
masked = list(content[index:end])
|
|
447
|
+
depth = 0
|
|
448
|
+
i = index
|
|
449
|
+
while i < end:
|
|
450
|
+
ch = content[i]
|
|
451
|
+
if ch in "'\"`":
|
|
452
|
+
close = i + 1
|
|
453
|
+
while close < end and content[close] != ch:
|
|
454
|
+
close += 2 if content[close] == "\\" else 1
|
|
455
|
+
if depth > 1:
|
|
456
|
+
for j in range(i, min(close + 1, end)):
|
|
457
|
+
masked[j - index] = " "
|
|
458
|
+
i = close + 1
|
|
459
|
+
continue
|
|
460
|
+
if ch in "{([":
|
|
461
|
+
depth += 1
|
|
462
|
+
elif ch in "})]":
|
|
463
|
+
depth -= 1
|
|
464
|
+
if depth == 0:
|
|
465
|
+
return index, "".join(masked[: i - index + 1])
|
|
466
|
+
if depth > 1 or (depth == 1 and ch in "})]"):
|
|
467
|
+
masked[i - index] = " " if ch != "\n" else "\n"
|
|
468
|
+
i += 1
|
|
469
|
+
return index, "".join(masked)
|
|
470
|
+
|
|
471
|
+
def _extract_protocol_hints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
472
|
+
for pattern, hint in PROTOCOL_HINT_PATTERNS:
|
|
473
|
+
match = pattern.search(content)
|
|
474
|
+
if match:
|
|
475
|
+
self._append_protocol(result, hint, relative, content, match.start(), 0.6)
|
|
476
|
+
for pattern, hint in AUTH_HINT_PATTERNS:
|
|
477
|
+
match = pattern.search(content)
|
|
478
|
+
if match:
|
|
479
|
+
self._append_unique_auth(result, hint, relative, content, match.start())
|
|
480
|
+
|
|
481
|
+
def _extract_event_stream_hints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
482
|
+
for pattern, hint in EVENT_STREAM_PATTERNS:
|
|
483
|
+
match = pattern.search(content)
|
|
484
|
+
if match:
|
|
485
|
+
self._append_protocol(result, hint, relative, content, match.start(), 0.7)
|
|
486
|
+
|
|
487
|
+
def _extract_env_url_signals(self, content: str, relative: str, result: ScanResult) -> None:
|
|
488
|
+
for match in ENV_URL_PATTERN.finditer(content):
|
|
489
|
+
env_name = match.group(1)
|
|
490
|
+
self._append_unique_external(result, f"env://{env_name}", relative, content, match.start())
|
|
491
|
+
service_target = self._service_target_from_env(env_name)
|
|
492
|
+
if service_target:
|
|
493
|
+
self._append_protocol(
|
|
494
|
+
result, f"atproto_service_edge:{service_target}", relative, content, match.start(), 0.5
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
def _extract_service_notes(self, relative: str, result: ScanResult) -> None:
|
|
498
|
+
normalized = relative.replace("\\", "/")
|
|
499
|
+
parts = normalized.split("/")
|
|
500
|
+
for root in ("services", "packages"):
|
|
501
|
+
if root in parts:
|
|
502
|
+
idx = parts.index(root)
|
|
503
|
+
if idx + 1 < len(parts):
|
|
504
|
+
# Derived from the file's location, not a source line: anchor at line 1.
|
|
505
|
+
self._append_protocol(
|
|
506
|
+
result,
|
|
507
|
+
f"atproto_service_note:{parts[idx + 1].lower()}",
|
|
508
|
+
relative,
|
|
509
|
+
None,
|
|
510
|
+
None,
|
|
511
|
+
0.6,
|
|
512
|
+
evidence=f"inferred from path {relative}",
|
|
513
|
+
)
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
def _extract_secret_hints(self, content: str, relative: str, result: ScanResult) -> None:
|
|
517
|
+
# Env-sourced secrets only: any quoted upper-case constant containing
|
|
518
|
+
# KEY/TOKEN (`'KEY'`, `"TOKEN"`, enum values) used to be reported.
|
|
519
|
+
for match in SECRET_ENV_PATTERN.finditer(content):
|
|
520
|
+
self._append_unique_secret(result, match.group(1) or match.group(2), relative, content, match.start())
|
|
521
|
+
|
|
522
|
+
@staticmethod
|
|
523
|
+
def _namespace_root(namespace: str) -> str | None:
|
|
524
|
+
"""The NSID's authority root: its first two segments (`sh.tangled`)."""
|
|
525
|
+
if not NSID_PATTERN.match(namespace):
|
|
526
|
+
return None
|
|
527
|
+
return ".".join(namespace.lower().split(".")[:2])
|
|
528
|
+
|
|
529
|
+
@staticmethod
|
|
530
|
+
def _service_target_from_env(env_name: str) -> str | None:
|
|
531
|
+
token = env_name.upper().removesuffix("_URL")
|
|
532
|
+
token = token.replace("__", "_").strip("_")
|
|
533
|
+
if not token:
|
|
534
|
+
return None
|
|
535
|
+
return token.lower().replace("_", "-")
|
|
536
|
+
|
|
537
|
+
@staticmethod
|
|
538
|
+
def _append_language(result: ScanResult, language: str) -> None:
|
|
539
|
+
if language not in result.languages:
|
|
540
|
+
result.languages.append(language)
|
|
541
|
+
|
|
542
|
+
@staticmethod
|
|
543
|
+
def _append_unique_route(
|
|
544
|
+
result: ScanResult,
|
|
545
|
+
path: str,
|
|
546
|
+
method: str,
|
|
547
|
+
file: str,
|
|
548
|
+
line: int,
|
|
549
|
+
*,
|
|
550
|
+
auth: str = "unknown",
|
|
551
|
+
guards: list[str] | None = None,
|
|
552
|
+
guard_evidence: str | None = None,
|
|
553
|
+
) -> None:
|
|
554
|
+
key = (path, method, file)
|
|
555
|
+
for item in result.routes:
|
|
556
|
+
if (item.path, item.method, item.file) == key:
|
|
557
|
+
# A handler registration resolves the auth of a route first
|
|
558
|
+
# seen as a bare `/xrpc/<nsid>` literal in the same file.
|
|
559
|
+
if getattr(item, "auth", "unknown") == "unknown" and auth != "unknown":
|
|
560
|
+
item.auth, item.guards, item.guard_evidence = auth, list(guards or []), guard_evidence
|
|
561
|
+
return
|
|
562
|
+
result.routes.append(
|
|
563
|
+
Route(path=path, method=method, file=file, line=line,
|
|
564
|
+
auth=auth, guards=list(guards or []), guard_evidence=guard_evidence)
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
@staticmethod
|
|
568
|
+
def _append_unique_external(result: ScanResult, target: str, file: str, content: str, offset: int) -> None:
|
|
569
|
+
key = (target, file)
|
|
570
|
+
if any((item.target, item.file) == key for item in result.external_calls):
|
|
571
|
+
return
|
|
572
|
+
line = line_of(content, offset)
|
|
573
|
+
result.external_calls.append(
|
|
574
|
+
ExternalCall(target=target, file=file, line=line, evidence_text=line_snippet(content, line) or target)
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
@staticmethod
|
|
578
|
+
def _append_unique_auth(result: ScanResult, hint: str, file: str, content: str, offset: int) -> None:
|
|
579
|
+
key = (hint, file)
|
|
580
|
+
if any((item.hint, item.file) == key for item in result.auth_hints):
|
|
581
|
+
return
|
|
582
|
+
line = line_of(content, offset)
|
|
583
|
+
result.auth_hints.append(
|
|
584
|
+
AuthHint(hint=hint, file=file, line=line, evidence_text=line_snippet(content, line) or hint)
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
@staticmethod
|
|
588
|
+
def _append_protocol(
|
|
589
|
+
result: ScanResult,
|
|
590
|
+
hint: str,
|
|
591
|
+
file: str,
|
|
592
|
+
content: str | None,
|
|
593
|
+
offset: int | None,
|
|
594
|
+
confidence: float,
|
|
595
|
+
*,
|
|
596
|
+
evidence: str | None = None,
|
|
597
|
+
) -> None:
|
|
598
|
+
"""Append a ProtocolHint once per (hint, file).
|
|
599
|
+
|
|
600
|
+
Located at ``offset`` when given; path-derived hints pass
|
|
601
|
+
``content=None`` and are anchored at line 1 with ``evidence``.
|
|
602
|
+
"""
|
|
603
|
+
if any((item.hint, item.file) == (hint, file) for item in result.protocol_hints):
|
|
604
|
+
return
|
|
605
|
+
if content is not None and offset is not None:
|
|
606
|
+
line = line_of(content, offset)
|
|
607
|
+
evidence_text = evidence or line_snippet(content, line) or hint
|
|
608
|
+
else:
|
|
609
|
+
line = 1
|
|
610
|
+
evidence_text = evidence or hint
|
|
611
|
+
result.protocol_hints.append(
|
|
612
|
+
ProtocolHint(hint=hint, file=file, line=line, evidence_text=evidence_text, confidence=confidence)
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
@staticmethod
|
|
616
|
+
def _append_unique_secret(result: ScanResult, name: str, file: str, content: str, offset: int) -> None:
|
|
617
|
+
key = (name, file)
|
|
618
|
+
if any((item.name, item.file) == key for item in result.secret_hints):
|
|
619
|
+
return
|
|
620
|
+
line = line_of(content, offset)
|
|
621
|
+
result.secret_hints.append(
|
|
622
|
+
SecretHint(name=name, file=file, line=line, evidence_text=line_snippet(content, line) or name)
|
|
623
|
+
)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: attackmap-analyzer-atproto
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Thin AT Protocol overlay analyzer plugin for AttackMap
|
|
5
|
+
Author: AttackMap Contributors
|
|
6
|
+
Author-email: Matthew Davis <mlaify@mlaify.io>
|
|
7
|
+
Maintainer-email: Matthew Davis <mlaify@mlaify.io>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Project-URL: Homepage, https://github.com/mlaify/attackmap-analyzer-atproto
|
|
10
|
+
Project-URL: Repository, https://github.com/mlaify/attackmap-analyzer-atproto
|
|
11
|
+
Project-URL: Issues, https://github.com/mlaify/attackmap-analyzer-atproto/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/mlaify/attackmap-analyzer-atproto/blob/main/CHANGELOG.md
|
|
13
|
+
Project-URL: AttackMap core, https://github.com/mlaify/AttackMap
|
|
14
|
+
Keywords: security,static-analysis,appsec,defensive-security,attackmap,attackmap-plugin
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: Information Technology
|
|
18
|
+
Classifier: Intended Audience :: System Administrators
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
25
|
+
Classifier: Topic :: Security
|
|
26
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.11
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Requires-Dist: pydantic>=2.7.0
|
|
32
|
+
Provides-Extra: core
|
|
33
|
+
Requires-Dist: attackmap>=0.6.0; extra == "core"
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: build>=1.2.0; extra == "dev"
|
|
37
|
+
Requires-Dist: attackmap>=0.6.0; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# AttackMap ATProto Analyzer
|
|
41
|
+
|
|
42
|
+
> [!IMPORTANT]
|
|
43
|
+
> **Active development, slow pace.** AttackMap is under active development, but
|
|
44
|
+
> progress may be slow until more contributors or co-maintainers join. Help is
|
|
45
|
+
> very welcome with the core engine, an analyzer, the macOS app, or the docs —
|
|
46
|
+
> see [CONTRIBUTING.md](CONTRIBUTING.md) or open an issue on
|
|
47
|
+
> [mlaify/AttackMap](https://github.com/mlaify/AttackMap/issues) to say hello.
|
|
48
|
+
> Security reports are still welcome at [security@mlaify.io](mailto:security@mlaify.io).
|
|
49
|
+
|
|
50
|
+
`attackmap-analyzer-atproto` is a thin protocol-aware overlay analyzer for AttackMap.
|
|
51
|
+
|
|
52
|
+
It is designed to enrich Node/TypeScript service scans (such as `node-service`) with
|
|
53
|
+
AT Protocol-specific exposure signals:
|
|
54
|
+
- protocol namespaces: `com.atproto.*`, `app.bsky.*`, `chat.bsky.*`, `tools.ozone.*`, plus any third-party authority declared by the repo's own lexicon `id`s (e.g. `sh.tangled.*`)
|
|
55
|
+
- lexicon-inferred XRPC endpoint surface. Only `defs.main` of type `query` (→ `GET`), `procedure` (→ `POST`) or `subscription` (→ `WS`) is an endpoint; `record`, `object` and defs-only lexicons are not.
|
|
56
|
+
- XRPC handler registrations (`server.method(nsid, …)`, generated `server.<nsid>({ auth, handler })`) with their per-endpoint auth:
|
|
57
|
+
- a configured `auth:` verifier is an `atproto_auth:route_verifier:<nsid>` AuthHint
|
|
58
|
+
- a handler with no verifier, or an optional one (`*Optional*`, `nullCreds`), is marked `atproto_route_auth:anonymous:<nsid>` (a ProtocolHint, so core never counts it as an auth control). Core's `Route` model has no auth field yet.
|
|
59
|
+
- protocol auth/signing/identity hints (`did:plc` / PLC directory, `@atproto/crypto` and signing/verification APIs; not the bare words "sign", "verify" or "plc")
|
|
60
|
+
- `process.env.*` secret references (not arbitrary quoted constants)
|
|
61
|
+
- event stream/subscription exposure hints
|
|
62
|
+
- service notes that complement service-level analyzers
|
|
63
|
+
|
|
64
|
+
This module is heuristic and intentionally lightweight.
|
|
65
|
+
|
|
66
|
+
`detect()` fires on a `lexicons/` directory, an `@atproto/*` (or `@did-plc/*`) dependency in any `package.json`, a lexicon document under a `lexicons/` path, a quoted NSID under a known authority, or an `/xrpc/<nsid>` URL in JS/TS source. Lockfiles (`package-lock.json`, `npm-shrinkwrap.json`) and AttackMap's own report files are never read. Other JSON files are only read to recognize lexicon documents.
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uv tool install attackmap --with attackmap-analyzer-atproto \
|
|
72
|
+
--index mlaify=https://pkg.mlaify.io/pypi/simple/ --index-strategy first-index
|
|
73
|
+
# or from source:
|
|
74
|
+
pip install git+https://github.com/mlaify/attackmap-analyzer-atproto.git
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
attackmap analyze /path/to/repo --module node-service --module atproto
|
|
81
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
attackmap_analyzer_atproto/__init__.py,sha256=ZS3jyYNlqIp2jQOV1kk4ACsk7KKN12yhnI2l8I_9lYk,69
|
|
2
|
+
attackmap_analyzer_atproto/analyzer.py,sha256=isQwpFMJwl0oJN0HSA28b9T5dVSoFGGT1S_Wt9aW33A,29088
|
|
3
|
+
attackmap_analyzer_atproto-0.2.0.dist-info/licenses/LICENSE,sha256=vtPQVGUyxq359j0JmD3_eJaXyHmaHyjzaMOtaLwJc88,1097
|
|
4
|
+
attackmap_analyzer_atproto-0.2.0.dist-info/METADATA,sha256=jiU9yTYI8gZ_ObGQ-CAa8Tew4UEw_CC6r0r81_bYk6c,4481
|
|
5
|
+
attackmap_analyzer_atproto-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
attackmap_analyzer_atproto-0.2.0.dist-info/entry_points.txt,sha256=J0Z8vVkIvntkns9iVnkyePFyzsorhFZzrilUNATjXWw,75
|
|
7
|
+
attackmap_analyzer_atproto-0.2.0.dist-info/top_level.txt,sha256=MfOAumzqUSeLu1xt53MWjrxgrrvwO3k1zTAu3gDDIwY,27
|
|
8
|
+
attackmap_analyzer_atproto-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Davis and AttackMap Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
attackmap_analyzer_atproto
|