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.
Files changed (93) hide show
  1. narvy/__init__.py +3 -0
  2. narvy/android/__init__.py +0 -0
  3. narvy/android/source_analyzer.py +268 -0
  4. narvy/android/split_bundle.py +475 -0
  5. narvy/android_rule_context.py +196 -0
  6. narvy/apk_memory_preflight.py +248 -0
  7. narvy/auth.py +40 -0
  8. narvy/ci_templates/bitbucket.yml +25 -0
  9. narvy/ci_templates/github.yml +60 -0
  10. narvy/ci_templates/gitlab.yml +32 -0
  11. narvy/cloud/__init__.py +0 -0
  12. narvy/cloud/aws_scan.py +1188 -0
  13. narvy/comment_filter.py +134 -0
  14. narvy/crypto_taint_lite.py +82 -0
  15. narvy/decompiler.py +337 -0
  16. narvy/doctor.py +244 -0
  17. narvy/host/__init__.py +0 -0
  18. narvy/host/audit.py +157 -0
  19. narvy/host/host_knowledge.py +982 -0
  20. narvy/host/lynis_bootstrap.py +400 -0
  21. narvy/host/lynis_parser.py +358 -0
  22. narvy/host/narvy_checks.py +789 -0
  23. narvy/host/report.py +69 -0
  24. narvy/host/ssh_exec.py +219 -0
  25. narvy/ios/__init__.py +0 -0
  26. narvy/ios/binary_analyzer.py +1201 -0
  27. narvy/ios/plist_checks.py +249 -0
  28. narvy/ios/source_analyzer.py +301 -0
  29. narvy/ios/third_party_filter.py +242 -0
  30. narvy/ios/trust_all_context.py +66 -0
  31. narvy/main.py +1983 -0
  32. narvy/native_hardening.py +503 -0
  33. narvy/reporter.py +151 -0
  34. narvy/rule_engine.py +57 -0
  35. narvy/rules/android/config.yml +77 -0
  36. narvy/rules/android/crypto.yml +34 -0
  37. narvy/rules/android/secrets.yml +161 -0
  38. narvy/rules/android/storage.yml +24 -0
  39. narvy/rules/android/webview.yml +23 -0
  40. narvy/rules/android.yml +219 -0
  41. narvy/rules/ios/objc/crypto.yml +56 -0
  42. narvy/rules/ios/objc/network.yml +67 -0
  43. narvy/rules/ios/objc/secrets.yml +79 -0
  44. narvy/rules/ios/objc/storage.yml +45 -0
  45. narvy/rules/ios/objc/webview.yml +45 -0
  46. narvy/rules/ios_swift.yml +327 -0
  47. narvy/rules/web/go.yml +2837 -0
  48. narvy/rules/web/java.yml +1576 -0
  49. narvy/rules/web/javascript.yml +3683 -0
  50. narvy/rules/web/kotlin.yml +413 -0
  51. narvy/rules/web/local/csharp_narvy/config.yml +61 -0
  52. narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
  53. narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
  54. narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
  55. narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
  56. narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
  57. narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
  58. narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
  59. narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
  60. narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
  61. narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
  62. narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
  63. narvy/rules/web/local/java_narvy/sqli.yml +235 -0
  64. narvy/rules/web/local/java_narvy/xxe.yml +212 -0
  65. narvy/rules/web/php.yml +1644 -0
  66. narvy/rules/web/python.yml +3967 -0
  67. narvy/rules/web/ruby.yml +703 -0
  68. narvy/rules/web/rust.yml +258 -0
  69. narvy/rules/web/secrets.yml +1420 -0
  70. narvy/rules/web/secrets_supplement.yml +383 -0
  71. narvy/sca/__init__.py +1 -0
  72. narvy/sca/android_deps.py +349 -0
  73. narvy/sca/ios_deps.py +578 -0
  74. narvy/sca/osv_client.py +617 -0
  75. narvy/sca/web_deps.py +955 -0
  76. narvy/scope_config.py +195 -0
  77. narvy/semgrep_engine.py +219 -0
  78. narvy/stack_protector_evidence.py +96 -0
  79. narvy/third_party_filter.py +211 -0
  80. narvy/uploader.py +92 -0
  81. narvy/weak_prng_context.py +270 -0
  82. narvy/web/__init__.py +0 -0
  83. narvy/web/nuclei_binary.py +105 -0
  84. narvy/web/scan_blocklist.py +96 -0
  85. narvy/web/scanner.py +842 -0
  86. narvy/web/source_analyzer.py +714 -0
  87. narvy/web/ssrf_guard.py +374 -0
  88. narvy_cli-1.0.0.dist-info/METADATA +165 -0
  89. narvy_cli-1.0.0.dist-info/RECORD +93 -0
  90. narvy_cli-1.0.0.dist-info/WHEEL +5 -0
  91. narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
  92. narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
  93. 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)