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.
- attackmap_analyzer_swift/__init__.py +4 -0
- attackmap_analyzer_swift/analyzer.py +296 -0
- attackmap_analyzer_swift/apple_surface.py +765 -0
- attackmap_analyzer_swift/objc.py +545 -0
- attackmap_analyzer_swift/routes.py +1195 -0
- attackmap_analyzer_swift/sbom.py +58 -0
- attackmap_analyzer_swift/swiftsrc.py +239 -0
- attackmap_analyzer_swift-0.2.0.dist-info/METADATA +175 -0
- attackmap_analyzer_swift-0.2.0.dist-info/RECORD +13 -0
- attackmap_analyzer_swift-0.2.0.dist-info/WHEEL +5 -0
- attackmap_analyzer_swift-0.2.0.dist-info/entry_points.txt +3 -0
- attackmap_analyzer_swift-0.2.0.dist-info/licenses/LICENSE +21 -0
- attackmap_analyzer_swift-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|