attackmap-analyzer-swift 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,4 @@
1
+ from .analyzer import SwiftAnalyzer
2
+ from .objc import ObjCAnalyzer
3
+
4
+ __all__ = ["ObjCAnalyzer", "SwiftAnalyzer"]
@@ -0,0 +1,296 @@
1
+ """Swift ecosystem analyzer for AttackMap.
2
+
3
+ - SwiftPM project detection and a dependency SBOM from ``Package.resolved``
4
+ (formats v1/v2/v3) and CocoaPods' ``Podfile.lock``.
5
+ - Server-side routes (#187): Vapor 4 and Hummingbird 1.x/2.x, with prefixes,
6
+ mounts and route-level auth (``routes.py``).
7
+ - The iOS/macOS app attack surface (#188, ``apple_surface.py``): App Transport
8
+ Security exceptions and custom URL schemes in ``Info.plist``, weakening
9
+ entitlements and universal links, secrets in ``.xcconfig`` files, the
10
+ Carthage SBOM, and in Swift code URL-scheme / universal-link / XPC entry
11
+ points, WKWebView script injection, secrets in UserDefaults or on the
12
+ pasteboard, unvalidated XPC peers and dynamic dispatch on built strings.
13
+ - Framework hints and ``main.swift`` / ``@main`` entrypoints.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import posixpath
20
+ import re
21
+ from pathlib import Path
22
+
23
+ from attackmap.sdk import (
24
+ DEFAULT_SKIP_DIRS,
25
+ AnalyzerMetadata,
26
+ DependencyHint,
27
+ EntrypointHint,
28
+ FrameworkHint,
29
+ iter_repo_files,
30
+ line_of,
31
+ read_source,
32
+ rel,
33
+ Route,
34
+ ScanResult,
35
+ )
36
+
37
+ from . import apple_surface
38
+ from .routes import extract_repo_routes
39
+ from .sbom import parse_podfile_lock
40
+
41
+ # Swift-specific additions to the shared skip list (which already covers
42
+ # build/, .git/, node_modules/, __pycache__/, ...). Matched against directory
43
+ # names *inside* the repo only.
44
+ SKIP_DIRS = DEFAULT_SKIP_DIRS | {
45
+ ".build", # SwiftPM build products
46
+ "DerivedData", # Xcode build products
47
+ "Pods", # CocoaPods vendored sources
48
+ "Carthage",
49
+ }
50
+
51
+ _MANIFEST_NAMES = {"Package.swift", "Package.resolved", "Podfile.lock"}
52
+ _XCODE_BUNDLE_SUFFIXES = {".xcodeproj", ".xcworkspace"}
53
+
54
+ # Known server-side frameworks worth surfacing when seen in Package.swift deps.
55
+ _FRAMEWORK_HINTS: dict[str, str] = {
56
+ "vapor": "Vapor (server-side Swift web framework)",
57
+ "hummingbird": "Hummingbird (server-side Swift web framework)",
58
+ "kitura": "Kitura (server-side Swift web framework)",
59
+ "perfect": "Perfect (server-side Swift web framework)",
60
+ }
61
+
62
+ # `.package(url: "https://github.com/vapor/vapor.git", ...)` and the newer
63
+ # `.package(name: "X", url: "...")` / path-based forms.
64
+ _PACKAGE_URL_RE = re.compile(r"\.package\s*\([^)]*?url\s*:\s*\"([^\"]+)\"", re.DOTALL)
65
+
66
+
67
+ def _identity_from_url(url: str) -> str:
68
+ """SwiftPM identity = the last path component of the repo URL, lowercased,
69
+ without a trailing ``.git`` (matches how Package.resolved v2/v3 records it)."""
70
+ tail = url.rstrip("/").rsplit("/", 1)[-1]
71
+ if tail.endswith(".git"):
72
+ tail = tail[:-4]
73
+ return tail.lower()
74
+
75
+
76
+ def _line_of(content: str, needle: str) -> int | None:
77
+ idx = content.find(needle)
78
+ if idx < 0:
79
+ return None
80
+ return line_of(content, idx)
81
+
82
+
83
+ _TEST_DIRS = {"tests", "test", "uitests", "spec", "specs", "fixtures", "__tests__"}
84
+
85
+
86
+ def _is_test_path(rel_path: str) -> bool:
87
+ """SwiftPM/Xcode test code: `Tests/`, `<Target>Tests/`, `FooTests.swift`."""
88
+ parts = rel_path.split("/")
89
+ if any(p.lower() in _TEST_DIRS or p.endswith("Tests") for p in parts[:-1]):
90
+ return True
91
+ return parts[-1].endswith(("Tests.swift", "Test.swift"))
92
+
93
+
94
+ def _in_xcode_bundle(path: Path, root: Path) -> bool:
95
+ """True if ``path`` sits inside a ``*.xcodeproj`` / ``*.xcworkspace`` bundle."""
96
+ return any(Path(part).suffix in _XCODE_BUNDLE_SUFFIXES for part in Path(rel(path, root)).parts[:-1])
97
+
98
+
99
+ class SwiftAnalyzer:
100
+ metadata = AnalyzerMetadata(
101
+ name="swift",
102
+ display_name="Swift Analyzer",
103
+ version="0.2.0",
104
+ description="Swift ecosystem analyzer: Vapor/Hummingbird routes with route-level auth, the iOS/macOS app attack surface (ATS, URL schemes, entitlements, WKWebView, UserDefaults, XPC), and a SwiftPM/CocoaPods/Carthage dependency SBOM.",
105
+ scope="Swift source trees and SwiftPM/Xcode projects: server-side Swift routes (Vapor, Hummingbird), iOS/macOS app projects (Info.plist, entitlements, xcconfig), and resolved Package.resolved / Podfile.lock / Cartfile.resolved dependencies for CVE/SBOM.",
106
+ targets=["swift", "swiftpm", "vapor", "hummingbird"],
107
+ languages=["swift"],
108
+ priority=20,
109
+ experimental=True, # Heuristic route and app-surface extraction.
110
+ enabled_by_default=False, # opt-in via `-m swift` while experimental (AttackMap#221)
111
+ )
112
+
113
+ @property
114
+ def name(self) -> str:
115
+ return self.metadata.name
116
+
117
+ # ---------- Public entry points ----------
118
+
119
+ def detect(self, repo_path: str | Path) -> bool:
120
+ root = Path(repo_path).resolve()
121
+ if not root.exists() or not root.is_dir():
122
+ return False
123
+ # The walker yields files only, so an Xcode project/workspace bundle is
124
+ # recognized by the files inside it (project.pbxproj, ...).
125
+ for path in iter_repo_files(root, skip_dirs=SKIP_DIRS):
126
+ if path.name in _MANIFEST_NAMES or path.suffix == ".swift":
127
+ return True
128
+ if _in_xcode_bundle(path, root):
129
+ return True
130
+ return False
131
+
132
+ def analyze(self, repo_path: str | Path) -> ScanResult:
133
+ root = Path(repo_path).resolve()
134
+ result = ScanResult(root=str(root))
135
+ if not root.exists() or not root.is_dir():
136
+ return result
137
+
138
+ # One walk; manifests are processed before Package.resolved because the
139
+ # resolved pins are marked direct against the manifests' dependencies.
140
+ manifests: list[Path] = []
141
+ resolved: list[Path] = []
142
+ pod_locks: list[Path] = []
143
+ sources: list[Path] = []
144
+ for path in iter_repo_files(root, suffixes={".swift"}, names=_MANIFEST_NAMES, skip_dirs=SKIP_DIRS):
145
+ if path.name == "Package.swift":
146
+ manifests.append(path)
147
+ elif path.name == "Package.resolved":
148
+ resolved.append(path)
149
+ elif path.name == "Podfile.lock":
150
+ pod_locks.append(path)
151
+ if path.suffix == ".swift":
152
+ sources.append(path)
153
+
154
+ direct = self._analyze_manifest(root, manifests, result)
155
+ self._analyze_resolved(root, resolved, result, direct)
156
+ self._analyze_podfile_locks(root, pod_locks, result)
157
+ self._analyze_entrypoints(root, sources, result)
158
+ self._analyze_routes(root, sources, manifests, result)
159
+ self._analyze_app_surface(root, sources, result)
160
+ return result
161
+
162
+ # ---------- iOS/macOS app surface (#188) ----------
163
+
164
+ def _analyze_app_surface(self, root: Path, sources: list[Path], result: ScanResult) -> None:
165
+ apple_surface.analyze_project_files(root, result, SKIP_DIRS, is_test=_is_test_path)
166
+ handlers: list[apple_surface.Handler] = []
167
+ for path in sources:
168
+ rel_path = rel(path, root)
169
+ if _is_test_path(rel_path):
170
+ continue
171
+ text = read_source(path)
172
+ if text is not None:
173
+ apple_surface.analyze_swift_source(rel_path, text, result, handlers)
174
+ apple_surface.report_url_schemes(result, handlers)
175
+ apple_surface.finalize_xpc(result)
176
+
177
+ # ---------- Routes (#187) ----------
178
+
179
+ def _analyze_routes(self, root: Path, sources: list[Path], manifests: list[Path], result: ScanResult) -> None:
180
+ files: list[tuple[str, str]] = []
181
+ for path in sources:
182
+ rel_path = rel(path, root)
183
+ if _is_test_path(rel_path):
184
+ continue # test apps register throwaway routes
185
+ text = read_source(path)
186
+ if text is not None:
187
+ files.append((rel_path, text))
188
+ package_dirs = [posixpath.dirname(rel(m, root)) for m in manifests]
189
+ for rel_path, hits in sorted(extract_repo_routes(files, package_dirs).items()):
190
+ for hit in hits:
191
+ result.routes.append(Route(
192
+ path=hit.path, method=hit.method, file=rel_path, line=hit.line,
193
+ auth=hit.auth, guards=hit.guards, guard_evidence=hit.evidence,
194
+ ))
195
+
196
+ # ---------- Podfile.lock (CocoaPods SBOM) ----------
197
+
198
+ def _analyze_podfile_locks(self, root: Path, locks: list[Path], result: ScanResult) -> None:
199
+ for lock in locks:
200
+ text = read_source(lock)
201
+ if text is None:
202
+ continue
203
+ rel_path = rel(lock, root)
204
+ for pod in parse_podfile_lock(text):
205
+ result.dependencies.append(DependencyHint(
206
+ name=pod.name, version=pod.version, ecosystem="cocoapods", file=rel_path,
207
+ line=pod.line, resolved=True, direct=pod.direct, source_analyzer="swift",
208
+ ))
209
+
210
+ # ---------- Package.swift (manifest) ----------
211
+
212
+ def _analyze_manifest(self, root: Path, manifests: list[Path], result: ScanResult) -> set[str]:
213
+ """Record SwiftPM + framework hints; return the set of *direct* dependency
214
+ identities declared in the manifest (used to mark resolved pins direct)."""
215
+ direct: set[str] = set()
216
+ for manifest in manifests:
217
+ text = read_source(manifest)
218
+ if text is None:
219
+ continue
220
+ rel_path = rel(manifest, root)
221
+ result.framework_hints.append(FrameworkHint(
222
+ hint="SwiftPM", file=rel_path, line=1,
223
+ evidence_text="Package.swift present — Swift Package Manager project."))
224
+ for m in _PACKAGE_URL_RE.finditer(text):
225
+ url = m.group(1)
226
+ identity = _identity_from_url(url)
227
+ direct.add(identity)
228
+ for key, label in _FRAMEWORK_HINTS.items():
229
+ if key in identity:
230
+ result.framework_hints.append(FrameworkHint(
231
+ hint=label, file=rel_path, line=_line_of(text, url) or 1,
232
+ evidence_text=f"Declared dependency: {url}"))
233
+ return direct
234
+
235
+ # ---------- Package.resolved (SBOM) ----------
236
+
237
+ def _analyze_resolved(self, root: Path, resolved_files: list[Path], result: ScanResult, direct: set[str]) -> None:
238
+ for resolved in resolved_files:
239
+ text = read_source(resolved)
240
+ if text is None:
241
+ continue
242
+ try:
243
+ data = json.loads(text)
244
+ except json.JSONDecodeError:
245
+ continue # a malformed pin file must not abort the scan
246
+ rel_path = rel(resolved, root)
247
+ for pin in _iter_pins(data):
248
+ identity = (pin.get("identity") or pin.get("package") or "").lower()
249
+ if not identity:
250
+ continue
251
+ state = pin.get("state") or {}
252
+ version = state.get("version") or "" # branch/revision pins have no version
253
+ location = pin.get("location") or pin.get("repositoryURL") or ""
254
+ result.dependencies.append(DependencyHint(
255
+ name=identity,
256
+ version=version,
257
+ ecosystem="swiftpm",
258
+ file=rel_path,
259
+ resolved=True,
260
+ direct=identity in direct,
261
+ evidence_text=location or None,
262
+ source_analyzer="swift",
263
+ ))
264
+
265
+ # ---------- Entrypoints ----------
266
+
267
+ def _analyze_entrypoints(self, root: Path, sources: list[Path], result: ScanResult) -> None:
268
+ for path in sources:
269
+ text = read_source(path)
270
+ if text is None:
271
+ continue
272
+ rel_path = rel(path, root)
273
+ if path.name == "main.swift":
274
+ result.entrypoint_hints.append(EntrypointHint(
275
+ hint="main.swift (top-level executable entrypoint)",
276
+ file=rel_path, line=1, evidence_text="Swift top-level main.swift"))
277
+ elif "@main" in text:
278
+ result.entrypoint_hints.append(EntrypointHint(
279
+ hint="@main attribute (executable entrypoint)",
280
+ file=rel_path, line=_line_of(text, "@main") or 1, evidence_text="@main"))
281
+
282
+
283
+ def _iter_pins(data: object):
284
+ """Yield pin dicts across Package.resolved formats: v1 nests them under
285
+ ``object.pins``; v2/v3 put ``pins`` at the top level."""
286
+ if not isinstance(data, dict):
287
+ return
288
+ pins = data.get("pins")
289
+ if pins is None:
290
+ obj = data.get("object")
291
+ if isinstance(obj, dict):
292
+ pins = obj.get("pins")
293
+ if isinstance(pins, list):
294
+ for pin in pins:
295
+ if isinstance(pin, dict):
296
+ yield pin