narvy-cli 1.0.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.
- narvy/__init__.py +3 -0
- narvy/android/__init__.py +0 -0
- narvy/android/source_analyzer.py +268 -0
- narvy/android/split_bundle.py +475 -0
- narvy/android_rule_context.py +196 -0
- narvy/apk_memory_preflight.py +248 -0
- narvy/auth.py +40 -0
- narvy/ci_templates/bitbucket.yml +25 -0
- narvy/ci_templates/github.yml +60 -0
- narvy/ci_templates/gitlab.yml +32 -0
- narvy/cloud/__init__.py +0 -0
- narvy/cloud/aws_scan.py +1188 -0
- narvy/comment_filter.py +134 -0
- narvy/crypto_taint_lite.py +82 -0
- narvy/decompiler.py +337 -0
- narvy/doctor.py +244 -0
- narvy/host/__init__.py +0 -0
- narvy/host/audit.py +157 -0
- narvy/host/host_knowledge.py +982 -0
- narvy/host/lynis_bootstrap.py +400 -0
- narvy/host/lynis_parser.py +358 -0
- narvy/host/narvy_checks.py +789 -0
- narvy/host/report.py +69 -0
- narvy/host/ssh_exec.py +219 -0
- narvy/ios/__init__.py +0 -0
- narvy/ios/binary_analyzer.py +1201 -0
- narvy/ios/plist_checks.py +249 -0
- narvy/ios/source_analyzer.py +301 -0
- narvy/ios/third_party_filter.py +242 -0
- narvy/ios/trust_all_context.py +66 -0
- narvy/main.py +1983 -0
- narvy/native_hardening.py +503 -0
- narvy/reporter.py +151 -0
- narvy/rule_engine.py +57 -0
- narvy/rules/android/config.yml +77 -0
- narvy/rules/android/crypto.yml +34 -0
- narvy/rules/android/secrets.yml +161 -0
- narvy/rules/android/storage.yml +24 -0
- narvy/rules/android/webview.yml +23 -0
- narvy/rules/android.yml +219 -0
- narvy/rules/ios/objc/crypto.yml +56 -0
- narvy/rules/ios/objc/network.yml +67 -0
- narvy/rules/ios/objc/secrets.yml +79 -0
- narvy/rules/ios/objc/storage.yml +45 -0
- narvy/rules/ios/objc/webview.yml +45 -0
- narvy/rules/ios_swift.yml +327 -0
- narvy/rules/web/go.yml +2837 -0
- narvy/rules/web/java.yml +1576 -0
- narvy/rules/web/javascript.yml +3683 -0
- narvy/rules/web/kotlin.yml +413 -0
- narvy/rules/web/local/csharp_narvy/config.yml +61 -0
- narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
- narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
- narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
- narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
- narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
- narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
- narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
- narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
- narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
- narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
- narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
- narvy/rules/web/local/java_narvy/sqli.yml +235 -0
- narvy/rules/web/local/java_narvy/xxe.yml +212 -0
- narvy/rules/web/php.yml +1644 -0
- narvy/rules/web/python.yml +3967 -0
- narvy/rules/web/ruby.yml +703 -0
- narvy/rules/web/rust.yml +258 -0
- narvy/rules/web/secrets.yml +1420 -0
- narvy/rules/web/secrets_supplement.yml +383 -0
- narvy/sca/__init__.py +1 -0
- narvy/sca/android_deps.py +349 -0
- narvy/sca/ios_deps.py +578 -0
- narvy/sca/osv_client.py +617 -0
- narvy/sca/web_deps.py +955 -0
- narvy/scope_config.py +195 -0
- narvy/semgrep_engine.py +219 -0
- narvy/stack_protector_evidence.py +96 -0
- narvy/third_party_filter.py +211 -0
- narvy/uploader.py +92 -0
- narvy/weak_prng_context.py +270 -0
- narvy/web/__init__.py +0 -0
- narvy/web/nuclei_binary.py +105 -0
- narvy/web/scan_blocklist.py +96 -0
- narvy/web/scanner.py +842 -0
- narvy/web/source_analyzer.py +714 -0
- narvy/web/ssrf_guard.py +374 -0
- narvy_cli-1.0.0.dist-info/METADATA +165 -0
- narvy_cli-1.0.0.dist-info/RECORD +93 -0
- narvy_cli-1.0.0.dist-info/WHEEL +5 -0
- narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
- narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
narvy/sca/ios_deps.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""iOS dependency CVE scanning over Podfile.lock, Package.resolved and embedded
|
|
2
|
+
frameworks, via OSV.dev, an offline fallback list and GitHub Advisory."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import plistlib
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
import zipfile
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from .osv_client import OSVClient, get_default_client
|
|
22
|
+
from ..ios.third_party_filter import (
|
|
23
|
+
get_vendored_pods_with_versions,
|
|
24
|
+
IOS_THIRD_PARTY_FRAMEWORK_PREFIXES,
|
|
25
|
+
is_third_party_framework_name,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Zero-network fallback. Every entry must be a real published CVE or GHSA id.
|
|
32
|
+
KNOWN_VULNERABLE_IOS_LIBS: Dict[str, List[Dict[str, Any]]] = {
|
|
33
|
+
"AFNetworking": [
|
|
34
|
+
{
|
|
35
|
+
"less_than": "2.6.0",
|
|
36
|
+
"cve": "CVE-2016-4817",
|
|
37
|
+
"severity": "HIGH",
|
|
38
|
+
"title": "AFNetworking SSL certificate validation bypass",
|
|
39
|
+
"description": "AFNetworking before 2.6.0 does not properly validate SSL/TLS "
|
|
40
|
+
"certificates in certain configurations, allowing man-in-the-middle "
|
|
41
|
+
"attacks against the app's network traffic.",
|
|
42
|
+
"fixed_version": "2.6.0",
|
|
43
|
+
"cwe": "CWE-295",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
"Alamofire": [
|
|
47
|
+
{
|
|
48
|
+
"less_than": "4.7.2",
|
|
49
|
+
"cve": "CVE-2018-1000814",
|
|
50
|
+
"severity": "HIGH",
|
|
51
|
+
"title": "Alamofire certificate-pinning bypass",
|
|
52
|
+
"description": "Alamofire before 4.7.2 contains a certificate-pinning bypass that "
|
|
53
|
+
"can allow a man-in-the-middle attacker to intercept TLS traffic "
|
|
54
|
+
"the app believed was pinned.",
|
|
55
|
+
"fixed_version": "4.7.2",
|
|
56
|
+
"cwe": "CWE-295",
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
"SDWebImage": [
|
|
60
|
+
{
|
|
61
|
+
"less_than": "5.0.0",
|
|
62
|
+
"cve": "CVE-2019-10752",
|
|
63
|
+
"severity": "HIGH",
|
|
64
|
+
"title": "SDWebImage out-of-bounds read via crafted image",
|
|
65
|
+
"description": "SDWebImage before 5.0.0 is vulnerable to an out-of-bounds read "
|
|
66
|
+
"when decoding a maliciously crafted image, which can crash the "
|
|
67
|
+
"app (denial of service) and, depending on memory layout, "
|
|
68
|
+
"potentially leak adjacent memory contents.",
|
|
69
|
+
"fixed_version": "5.0.0",
|
|
70
|
+
"cwe": "CWE-125",
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_GH_API_URL = "https://api.github.com/advisories"
|
|
76
|
+
_GH_CACHE_DIR = Path.home() / ".narvy" / "github_advisory_cache"
|
|
77
|
+
_GH_CACHE_TTL_SECONDS = 24 * 3600
|
|
78
|
+
|
|
79
|
+
# GitHub's unauthenticated quota is per-IP, so cap advisory queries per scan.
|
|
80
|
+
_GH_MAX_QUERIES_PER_SCAN = 25
|
|
81
|
+
_GH_MIN_REMAINING_BUDGET = 5
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _GitHubAdvisoryBudget:
|
|
85
|
+
"""Tracks GitHub's unauthenticated rate-limit budget for one scan."""
|
|
86
|
+
|
|
87
|
+
def __init__(self):
|
|
88
|
+
self.queries_made = 0
|
|
89
|
+
self.remaining: Optional[int] = None
|
|
90
|
+
self.exhausted = False
|
|
91
|
+
|
|
92
|
+
def can_query(self) -> bool:
|
|
93
|
+
if self.exhausted:
|
|
94
|
+
return False
|
|
95
|
+
if self.queries_made >= _GH_MAX_QUERIES_PER_SCAN:
|
|
96
|
+
return False
|
|
97
|
+
if self.remaining is not None and self.remaining <= _GH_MIN_REMAINING_BUDGET:
|
|
98
|
+
return False
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
def record_response(self, resp: requests.Response):
|
|
102
|
+
self.queries_made += 1
|
|
103
|
+
remaining_hdr = resp.headers.get("X-RateLimit-Remaining")
|
|
104
|
+
if remaining_hdr is not None:
|
|
105
|
+
try:
|
|
106
|
+
self.remaining = int(remaining_hdr)
|
|
107
|
+
except ValueError:
|
|
108
|
+
pass
|
|
109
|
+
if resp.status_code == 403 and (self.remaining == 0 or "rate limit" in resp.text.lower()):
|
|
110
|
+
self.exhausted = True
|
|
111
|
+
logger.warning(
|
|
112
|
+
"[SCA] GitHub Advisory API rate limit hit (60/hr unauthenticated) - "
|
|
113
|
+
"stopping further GitHub queries for this scan, continuing with OSV "
|
|
114
|
+
"+ local curated DB only."
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _gh_cache_path(key: str) -> Path:
|
|
119
|
+
_GH_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
return _GH_CACHE_DIR / f"{hashlib.md5(key.encode()).hexdigest()}.json"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _gh_cache_get(key: str) -> Optional[List[Dict[str, Any]]]:
|
|
124
|
+
path = _gh_cache_path(key)
|
|
125
|
+
if not path.exists():
|
|
126
|
+
return None
|
|
127
|
+
try:
|
|
128
|
+
if (time.time() - path.stat().st_mtime) > _GH_CACHE_TTL_SECONDS:
|
|
129
|
+
return None
|
|
130
|
+
with open(path, "r") as f:
|
|
131
|
+
return json.load(f)
|
|
132
|
+
except (OSError, json.JSONDecodeError):
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _gh_cache_put(key: str, data: List[Dict[str, Any]]):
|
|
137
|
+
try:
|
|
138
|
+
with open(_gh_cache_path(key), "w") as f:
|
|
139
|
+
json.dump(data, f)
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _query_github_advisories_swift(library_name: str, budget: _GitHubAdvisoryBudget) -> List[Dict[str, Any]]:
|
|
145
|
+
"""Cache-first, budget-gated GitHub Advisory lookup for the Swift ecosystem."""
|
|
146
|
+
cache_key = f"swift:{library_name.lower()}"
|
|
147
|
+
cached = _gh_cache_get(cache_key)
|
|
148
|
+
if cached is not None:
|
|
149
|
+
return cached
|
|
150
|
+
|
|
151
|
+
if not budget.can_query():
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
resp = requests.get(
|
|
156
|
+
_GH_API_URL,
|
|
157
|
+
headers={"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"},
|
|
158
|
+
params={"ecosystem": "swift", "affects": library_name, "per_page": 50},
|
|
159
|
+
timeout=15,
|
|
160
|
+
)
|
|
161
|
+
budget.record_response(resp)
|
|
162
|
+
if resp.status_code == 200:
|
|
163
|
+
advisories = resp.json() or []
|
|
164
|
+
_gh_cache_put(cache_key, advisories)
|
|
165
|
+
return advisories
|
|
166
|
+
elif resp.status_code == 404:
|
|
167
|
+
_gh_cache_put(cache_key, [])
|
|
168
|
+
return []
|
|
169
|
+
else:
|
|
170
|
+
logger.debug(f"[SCA] GitHub Advisory API returned {resp.status_code} for {library_name}")
|
|
171
|
+
return []
|
|
172
|
+
except requests.exceptions.RequestException as e:
|
|
173
|
+
logger.debug(f"[SCA] GitHub Advisory query failed for {library_name}: {e}")
|
|
174
|
+
return []
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _cvss_from_vector(vector: str) -> float:
|
|
178
|
+
if not vector or "CVSS:3" not in vector:
|
|
179
|
+
return 0.0
|
|
180
|
+
metrics = {}
|
|
181
|
+
for part in vector.replace('CVSS:3.1/', '').replace('CVSS:3.0/', '').split('/'):
|
|
182
|
+
if ':' in part:
|
|
183
|
+
k, v = part.split(':', 1)
|
|
184
|
+
metrics[k] = v
|
|
185
|
+
av = {'N': 0.85, 'A': 0.62, 'L': 0.55, 'P': 0.20}.get(metrics.get('AV', ''), 0.85)
|
|
186
|
+
ac = {'L': 0.77, 'H': 0.44}.get(metrics.get('AC', ''), 0.77)
|
|
187
|
+
scope_changed = metrics.get('S', 'U') == 'C'
|
|
188
|
+
pr_map = {'N': 0.85, 'L': 0.68, 'H': 0.50} if scope_changed else {'N': 0.85, 'L': 0.62, 'H': 0.27}
|
|
189
|
+
pr = pr_map.get(metrics.get('PR', ''), 0.85)
|
|
190
|
+
ui = {'N': 0.85, 'R': 0.62}.get(metrics.get('UI', ''), 0.85)
|
|
191
|
+
c = {'H': 0.56, 'L': 0.22, 'N': 0.0}.get(metrics.get('C', ''), 0.0)
|
|
192
|
+
i = {'H': 0.56, 'L': 0.22, 'N': 0.0}.get(metrics.get('I', ''), 0.0)
|
|
193
|
+
a = {'H': 0.56, 'L': 0.22, 'N': 0.0}.get(metrics.get('A', ''), 0.0)
|
|
194
|
+
exploitability = 8.22 * av * ac * pr * ui
|
|
195
|
+
iss = 1.0 - ((1.0 - c) * (1.0 - i) * (1.0 - a))
|
|
196
|
+
if iss <= 0:
|
|
197
|
+
return 0.0
|
|
198
|
+
impact = (7.52 * (iss - 0.029) - 3.25 * ((iss - 0.02) ** 15)) if scope_changed else (6.42 * iss)
|
|
199
|
+
if impact <= 0:
|
|
200
|
+
return 0.0
|
|
201
|
+
if scope_changed:
|
|
202
|
+
return min(math.ceil(min(1.08 * (impact + exploitability), 10.0) * 10) / 10, 10.0)
|
|
203
|
+
return min(math.ceil(min(impact + exploitability, 10.0) * 10) / 10, 10.0)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _score_to_severity(score: float) -> str:
|
|
207
|
+
if score >= 9.0:
|
|
208
|
+
return 'CRITICAL'
|
|
209
|
+
if score >= 7.0:
|
|
210
|
+
return 'HIGH'
|
|
211
|
+
if score >= 4.0:
|
|
212
|
+
return 'MEDIUM'
|
|
213
|
+
if score > 0:
|
|
214
|
+
return 'LOW'
|
|
215
|
+
return 'MEDIUM'
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _parse_github_advisory(advisory: Dict[str, Any], dep_name: str, dep_version: str) -> Optional[Dict[str, Any]]:
|
|
219
|
+
"""One GitHub advisory in OSVFinding.to_dict() shape, or None if it doesn't name this package."""
|
|
220
|
+
matched = False
|
|
221
|
+
fixed_version = None
|
|
222
|
+
for vuln in advisory.get('vulnerabilities', []) or []:
|
|
223
|
+
pkg = vuln.get('package', {}) or {}
|
|
224
|
+
if pkg.get('ecosystem', '').lower() != 'swift':
|
|
225
|
+
continue
|
|
226
|
+
if pkg.get('name', '').lower() != dep_name.lower():
|
|
227
|
+
continue
|
|
228
|
+
matched = True
|
|
229
|
+
fpv = vuln.get('first_patched_version') or {}
|
|
230
|
+
if fpv.get('identifier'):
|
|
231
|
+
fixed_version = fpv['identifier']
|
|
232
|
+
if not matched:
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
cve_id = advisory.get('cve_id') or advisory.get('ghsa_id') or 'UNKNOWN'
|
|
236
|
+
cvss = advisory.get('cvss') or {}
|
|
237
|
+
vector = cvss.get('vector_string', '')
|
|
238
|
+
score = _cvss_from_vector(vector) if vector else 0.0
|
|
239
|
+
severity = _score_to_severity(score) if score > 0 else (advisory.get('severity') or 'medium').upper()
|
|
240
|
+
if severity not in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
|
|
241
|
+
severity = "MEDIUM"
|
|
242
|
+
|
|
243
|
+
cwes = advisory.get('cwes') or []
|
|
244
|
+
cwe = cwes[0].get('cwe_id', 'CWE-1104') if cwes else 'CWE-1104'
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
"cve": cve_id,
|
|
248
|
+
"osv_id": advisory.get('ghsa_id', ''),
|
|
249
|
+
"severity": severity,
|
|
250
|
+
"cvss_score": score,
|
|
251
|
+
"summary": advisory.get('summary', '') or '',
|
|
252
|
+
"details": (advisory.get('description') or '')[:500],
|
|
253
|
+
"fixed_version": fixed_version,
|
|
254
|
+
"references": [r for r in (advisory.get('references') or []) if isinstance(r, str)][:5],
|
|
255
|
+
"cwe": cwe,
|
|
256
|
+
"source": "GitHub Advisory Database",
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# Pod name to OSV "SwiftURL" package name (bare `github.com/<owner>/<repo>`) for
|
|
261
|
+
# Podfile.lock projects with no source URL. A wrong entry queries the wrong repo.
|
|
262
|
+
_POD_NAME_TO_GITHUB_REPO: Dict[str, str] = {
|
|
263
|
+
"AFNetworking": "github.com/AFNetworking/AFNetworking",
|
|
264
|
+
"Alamofire": "github.com/Alamofire/Alamofire",
|
|
265
|
+
"SDWebImage": "github.com/SDWebImage/SDWebImage",
|
|
266
|
+
"Realm": "github.com/realm/realm-swift",
|
|
267
|
+
"RealmSwift": "github.com/realm/realm-swift",
|
|
268
|
+
"Kingfisher": "github.com/onevcat/Kingfisher",
|
|
269
|
+
"SnapKit": "github.com/SnapKit/SnapKit",
|
|
270
|
+
"SwiftyJSON": "github.com/SwiftyJSON/SwiftyJSON",
|
|
271
|
+
"Firebase": "github.com/firebase/firebase-ios-sdk",
|
|
272
|
+
"RxSwift": "github.com/ReactiveX/RxSwift",
|
|
273
|
+
"RxCocoa": "github.com/ReactiveX/RxSwift",
|
|
274
|
+
"Moya": "github.com/Moya/Moya",
|
|
275
|
+
"PromiseKit": "github.com/mxcl/PromiseKit",
|
|
276
|
+
"CryptoSwift": "github.com/krzyzanowskim/CryptoSwift",
|
|
277
|
+
"KeychainSwift": "github.com/evgenyneu/keychain-swift",
|
|
278
|
+
"Sparkle": "github.com/sparkle-project/Sparkle",
|
|
279
|
+
"ZIPFoundation": "github.com/weichsel/ZIPFoundation",
|
|
280
|
+
"lottie-ios": "github.com/airbnb/lottie-ios",
|
|
281
|
+
"SwiftLint": "github.com/realm/SwiftLint",
|
|
282
|
+
"Starscream": "github.com/daltoniam/Starscream",
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _normalize_repo_url_to_swifturl_name(url: str) -> Optional[str]:
|
|
287
|
+
"""An HTTPS or SSH repo URL as OSV's SwiftURL package-name format."""
|
|
288
|
+
if not url:
|
|
289
|
+
return None
|
|
290
|
+
u = url.strip()
|
|
291
|
+
u = re.sub(r'^(https?://|ssh://)', '', u)
|
|
292
|
+
u = re.sub(r'^git@', '', u)
|
|
293
|
+
if re.match(r'^[^/]+:[^/]', u):
|
|
294
|
+
u = u.replace(':', '/', 1)
|
|
295
|
+
u = u.rstrip('/')
|
|
296
|
+
if u.endswith('.git'):
|
|
297
|
+
u = u[:-4]
|
|
298
|
+
return u or None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True)
|
|
302
|
+
class _IOSDep:
|
|
303
|
+
name: str
|
|
304
|
+
version: str
|
|
305
|
+
source: str
|
|
306
|
+
osv_package_name: Optional[str] = None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def extract_source_dependencies(source_dir: str) -> List[_IOSDep]:
|
|
310
|
+
"""Podfile.lock + Package.resolved, for the `ios-source` scan mode."""
|
|
311
|
+
deps: List[_IOSDep] = []
|
|
312
|
+
|
|
313
|
+
pods = get_vendored_pods_with_versions(source_dir)
|
|
314
|
+
for name, version in pods.items():
|
|
315
|
+
osv_name = _POD_NAME_TO_GITHUB_REPO.get(name)
|
|
316
|
+
deps.append(_IOSDep(name=name, version=version, source="podfile.lock", osv_package_name=osv_name))
|
|
317
|
+
|
|
318
|
+
for resolved_path in _find_package_resolved_files(source_dir):
|
|
319
|
+
deps.extend(_parse_package_resolved(resolved_path))
|
|
320
|
+
|
|
321
|
+
return deps
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
_PKG_RESOLVED_MAX_DEPTH = 8
|
|
325
|
+
_VENDOR_DIR_SKIP = {"Pods", "Carthage", "DerivedData", ".build", "ModuleCache.noindex", ".git", "node_modules"}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _find_package_resolved_files(source_dir: str) -> List[str]:
|
|
329
|
+
found = []
|
|
330
|
+
base_depth = source_dir.rstrip(os.sep).count(os.sep)
|
|
331
|
+
for root, dirs, files in os.walk(source_dir):
|
|
332
|
+
depth = root.rstrip(os.sep).count(os.sep) - base_depth
|
|
333
|
+
if depth >= _PKG_RESOLVED_MAX_DEPTH:
|
|
334
|
+
dirs[:] = []
|
|
335
|
+
continue
|
|
336
|
+
dirs[:] = [d for d in dirs if d not in _VENDOR_DIR_SKIP]
|
|
337
|
+
if "Package.resolved" in files:
|
|
338
|
+
found.append(os.path.join(root, "Package.resolved"))
|
|
339
|
+
return found
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _parse_package_resolved(path: str) -> List[_IOSDep]:
|
|
343
|
+
"""Dependencies from a Package.resolved, in either SwiftPM lockfile schema."""
|
|
344
|
+
deps = []
|
|
345
|
+
try:
|
|
346
|
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
|
347
|
+
data = json.load(f)
|
|
348
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
349
|
+
logger.debug(f"[SCA] Could not parse {path}: {e}")
|
|
350
|
+
return deps
|
|
351
|
+
|
|
352
|
+
pins = data.get("pins")
|
|
353
|
+
if pins is None:
|
|
354
|
+
pins = (data.get("object") or {}).get("pins", [])
|
|
355
|
+
|
|
356
|
+
for pin in pins or []:
|
|
357
|
+
name = pin.get("identity") or pin.get("package")
|
|
358
|
+
state = pin.get("state") or {}
|
|
359
|
+
version = state.get("version")
|
|
360
|
+
location = pin.get("location") or pin.get("repositoryURL") or ""
|
|
361
|
+
if name and version:
|
|
362
|
+
osv_name = _normalize_repo_url_to_swifturl_name(location) or _POD_NAME_TO_GITHUB_REPO.get(name)
|
|
363
|
+
deps.append(_IOSDep(name=name, version=version, source="package.resolved", osv_package_name=osv_name))
|
|
364
|
+
return deps
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def extract_binary_dependencies(ipa_path: str) -> List[_IOSDep]:
|
|
368
|
+
"""Embedded Frameworks/*/Info.plist, scoped to known third-party SDK names."""
|
|
369
|
+
deps: List[_IOSDep] = []
|
|
370
|
+
try:
|
|
371
|
+
with zipfile.ZipFile(ipa_path, "r") as ipa:
|
|
372
|
+
framework_plists = [
|
|
373
|
+
n for n in ipa.namelist()
|
|
374
|
+
if "Frameworks/" in n and n.endswith("/Info.plist")
|
|
375
|
+
]
|
|
376
|
+
for plist_path in framework_plists:
|
|
377
|
+
parts = plist_path.split("/")
|
|
378
|
+
framework_folder = next((p for p in parts if p.endswith(".framework")), None)
|
|
379
|
+
if not framework_folder:
|
|
380
|
+
continue
|
|
381
|
+
framework_name = framework_folder[: -len(".framework")]
|
|
382
|
+
if not is_third_party_framework_name(framework_name):
|
|
383
|
+
continue
|
|
384
|
+
try:
|
|
385
|
+
plist_dict = plistlib.loads(ipa.read(plist_path))
|
|
386
|
+
except Exception:
|
|
387
|
+
continue
|
|
388
|
+
version = (plist_dict.get("CFBundleShortVersionString")
|
|
389
|
+
or plist_dict.get("CFBundleVersion"))
|
|
390
|
+
if not version:
|
|
391
|
+
continue
|
|
392
|
+
osv_name = _POD_NAME_TO_GITHUB_REPO.get(framework_name)
|
|
393
|
+
deps.append(_IOSDep(name=framework_name, version=str(version),
|
|
394
|
+
source="framework", osv_package_name=osv_name))
|
|
395
|
+
except (zipfile.BadZipFile, OSError) as e:
|
|
396
|
+
logger.error(f"[SCA] Error reading {ipa_path}: {e}")
|
|
397
|
+
return deps
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _severity_style_default(sev: str) -> str:
|
|
401
|
+
sev = (sev or "MEDIUM").upper()
|
|
402
|
+
return sev if sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW") else "MEDIUM"
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _finding_from_cve(dep: _IOSDep, cve_dict: Dict[str, Any], source_label: str) -> Dict[str, Any]:
|
|
406
|
+
cve_id = cve_dict.get("cve") or cve_dict.get("osv_id") or "UNKNOWN"
|
|
407
|
+
severity = _severity_style_default(cve_dict.get("severity", "MEDIUM"))
|
|
408
|
+
fixed_version = cve_dict.get("fixed_version")
|
|
409
|
+
summary = cve_dict.get("summary") or (cve_dict.get("details") or "")[:400] or "No summary available."
|
|
410
|
+
|
|
411
|
+
description = (
|
|
412
|
+
f"{dep.name}@{dep.version} is affected by {cve_id} "
|
|
413
|
+
f"(detected via {dep.source}, matched by {source_label}). {summary}"
|
|
414
|
+
)
|
|
415
|
+
recommendation = (
|
|
416
|
+
f"Update {dep.name} to version {fixed_version} or later."
|
|
417
|
+
if fixed_version
|
|
418
|
+
else f"No fixed version is listed for {dep.name} yet. Check the framework's "
|
|
419
|
+
f"release notes / consider an alternative library."
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
rule_id = f"SCA-{cve_id}"
|
|
423
|
+
return {
|
|
424
|
+
"rule_id": rule_id,
|
|
425
|
+
"file_path": dep.name,
|
|
426
|
+
"name": f"{cve_id}: {dep.name}@{dep.version} (vulnerable dependency)",
|
|
427
|
+
"severity": severity,
|
|
428
|
+
"details": {
|
|
429
|
+
"description": description,
|
|
430
|
+
"recommendation": recommendation,
|
|
431
|
+
"cwe": cve_dict.get("cwe", "CWE-1104"),
|
|
432
|
+
"masvs": "MSTG-CODE-5",
|
|
433
|
+
},
|
|
434
|
+
"line": 1,
|
|
435
|
+
"engine": "sca",
|
|
436
|
+
"sca": {
|
|
437
|
+
"package": dep.name,
|
|
438
|
+
"version": dep.version,
|
|
439
|
+
"detected_via": dep.source,
|
|
440
|
+
"fixed_version": fixed_version,
|
|
441
|
+
"references": cve_dict.get("references", []),
|
|
442
|
+
"ecosystem": "SwiftURL" if dep.osv_package_name else "swift",
|
|
443
|
+
"cve_source": source_label,
|
|
444
|
+
},
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _rule_def_from_finding(finding: Dict[str, Any]) -> Dict[str, Any]:
|
|
449
|
+
return {
|
|
450
|
+
"id": finding["rule_id"],
|
|
451
|
+
"name": finding["name"],
|
|
452
|
+
"severity": finding["severity"],
|
|
453
|
+
"masvs": finding["details"]["masvs"],
|
|
454
|
+
"details": dict(finding["details"]),
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _check_local_curated_db(dep: _IOSDep) -> List[Dict[str, Any]]:
|
|
459
|
+
entries = KNOWN_VULNERABLE_IOS_LIBS.get(dep.name)
|
|
460
|
+
if not entries:
|
|
461
|
+
return []
|
|
462
|
+
hits = []
|
|
463
|
+
raw_parts = re.findall(r'\d+', dep.version)[:3]
|
|
464
|
+
if not raw_parts:
|
|
465
|
+
return []
|
|
466
|
+
try:
|
|
467
|
+
dep_parts = [int(x) for x in raw_parts]
|
|
468
|
+
except ValueError:
|
|
469
|
+
return []
|
|
470
|
+
while len(dep_parts) < 3:
|
|
471
|
+
dep_parts.append(0)
|
|
472
|
+
for entry in entries:
|
|
473
|
+
target = [int(x) for x in re.findall(r'\d+', entry["less_than"])[:3]]
|
|
474
|
+
while len(target) < 3:
|
|
475
|
+
target.append(0)
|
|
476
|
+
if dep_parts < target:
|
|
477
|
+
hits.append({
|
|
478
|
+
"cve": entry["cve"],
|
|
479
|
+
"severity": entry["severity"],
|
|
480
|
+
"summary": entry["title"],
|
|
481
|
+
"details": entry["description"],
|
|
482
|
+
"fixed_version": entry["fixed_version"],
|
|
483
|
+
"cwe": entry["cwe"],
|
|
484
|
+
"references": [],
|
|
485
|
+
})
|
|
486
|
+
return hits
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _check_all_sources(
|
|
490
|
+
dep: _IOSDep,
|
|
491
|
+
osv_client: OSVClient,
|
|
492
|
+
gh_budget: _GitHubAdvisoryBudget,
|
|
493
|
+
use_github: bool,
|
|
494
|
+
) -> List[Tuple[Dict[str, Any], str]]:
|
|
495
|
+
"""(cve_dict, source_label) pairs deduplicated by CVE id across all sources."""
|
|
496
|
+
if not dep.version or dep.version.lower() == "unknown":
|
|
497
|
+
return []
|
|
498
|
+
|
|
499
|
+
seen_cves: set = set()
|
|
500
|
+
results: List[Tuple[Dict[str, Any], str]] = []
|
|
501
|
+
|
|
502
|
+
# OSV first: no rate ceiling. Needs a SwiftURL repo path, not a library name.
|
|
503
|
+
if dep.osv_package_name:
|
|
504
|
+
for f in osv_client.query_dict(dep.osv_package_name, dep.version, ecosystem="SwiftURL"):
|
|
505
|
+
cve_id = f.get("cve") or f.get("osv_id")
|
|
506
|
+
if cve_id and cve_id not in seen_cves:
|
|
507
|
+
seen_cves.add(cve_id)
|
|
508
|
+
results.append((f, "OSV.dev"))
|
|
509
|
+
|
|
510
|
+
for f in _check_local_curated_db(dep):
|
|
511
|
+
cve_id = f.get("cve")
|
|
512
|
+
if cve_id and cve_id not in seen_cves:
|
|
513
|
+
seen_cves.add(cve_id)
|
|
514
|
+
results.append((f, "curated local DB"))
|
|
515
|
+
|
|
516
|
+
if use_github:
|
|
517
|
+
advisories = _query_github_advisories_swift(dep.name, gh_budget)
|
|
518
|
+
for adv in advisories:
|
|
519
|
+
f = _parse_github_advisory(adv, dep.name, dep.version)
|
|
520
|
+
if not f:
|
|
521
|
+
continue
|
|
522
|
+
cve_id = f.get("cve")
|
|
523
|
+
if cve_id and cve_id not in seen_cves:
|
|
524
|
+
seen_cves.add(cve_id)
|
|
525
|
+
results.append((f, "GitHub Advisory Database"))
|
|
526
|
+
|
|
527
|
+
return results
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _scan_deps(deps: List[_IOSDep], osv_client: Optional[OSVClient] = None) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]:
|
|
531
|
+
client = osv_client or get_default_client()
|
|
532
|
+
gh_budget = _GitHubAdvisoryBudget()
|
|
533
|
+
|
|
534
|
+
seen_keys: set = set()
|
|
535
|
+
unique_deps: List[_IOSDep] = []
|
|
536
|
+
for d in deps:
|
|
537
|
+
key = (d.name, d.version)
|
|
538
|
+
if key not in seen_keys:
|
|
539
|
+
seen_keys.add(key)
|
|
540
|
+
unique_deps.append(d)
|
|
541
|
+
|
|
542
|
+
findings: List[Dict[str, Any]] = []
|
|
543
|
+
rule_defs: List[Dict[str, Any]] = []
|
|
544
|
+
vulnerable_names: set = set()
|
|
545
|
+
gh_used_for = 0
|
|
546
|
+
|
|
547
|
+
for dep in unique_deps:
|
|
548
|
+
use_github = gh_used_for < _GH_MAX_QUERIES_PER_SCAN
|
|
549
|
+
cve_hits = _check_all_sources(dep, client, gh_budget, use_github)
|
|
550
|
+
if use_github:
|
|
551
|
+
gh_used_for += 1
|
|
552
|
+
for cve_dict, source_label in cve_hits:
|
|
553
|
+
finding = _finding_from_cve(dep, cve_dict, source_label)
|
|
554
|
+
findings.append(finding)
|
|
555
|
+
rule_defs.append(_rule_def_from_finding(finding))
|
|
556
|
+
vulnerable_names.add(dep.name)
|
|
557
|
+
|
|
558
|
+
stats = {
|
|
559
|
+
"total_dependencies_detected": len(deps),
|
|
560
|
+
"unique_dependencies_checked": len(unique_deps),
|
|
561
|
+
"cves_found": len(findings),
|
|
562
|
+
"vulnerable_dependencies": len(vulnerable_names),
|
|
563
|
+
"github_queries_made": gh_budget.queries_made,
|
|
564
|
+
"github_rate_limited": gh_budget.exhausted,
|
|
565
|
+
}
|
|
566
|
+
return findings, rule_defs, stats
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def scan_source(source_dir: str, osv_client: Optional[OSVClient] = None) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]:
|
|
570
|
+
"""Entry point for `ios-source` scan mode."""
|
|
571
|
+
deps = extract_source_dependencies(source_dir)
|
|
572
|
+
return _scan_deps(deps, osv_client)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def scan_binary(ipa_path: str, osv_client: Optional[OSVClient] = None) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]:
|
|
576
|
+
"""Entry point for `ios-binary` scan mode."""
|
|
577
|
+
deps = extract_binary_dependencies(ipa_path)
|
|
578
|
+
return _scan_deps(deps, osv_client)
|