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.
@@ -0,0 +1,3 @@
1
+ from .analyzer import AtprotoAnalyzer
2
+
3
+ __all__ = ["AtprotoAnalyzer"]
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [attackmap.analyzers]
2
+ atproto = attackmap_analyzer_atproto:AtprotoAnalyzer
@@ -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