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
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Info.plist and *.entitlements checks for iOS source-mode SAST: ATS, custom
|
|
2
|
+
URL schemes and Universal Links (parsed with plistlib, not the source engines)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import glob
|
|
6
|
+
import os
|
|
7
|
+
import plistlib
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from .third_party_filter import is_ios_vendor_dir_path
|
|
11
|
+
|
|
12
|
+
_SYSTEM_URL_SCHEMES = {
|
|
13
|
+
"http", "https", "mailto", "tel", "sms", "facetime", "facetime-audio",
|
|
14
|
+
"itms-apps", "itms-appss",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
_WEAK_TLS_VERSIONS = {"TLSv1.0", "TLSv1.1"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _rule_def(rule_id: str, name: str, severity: str, cwe: str, masvs: str,
|
|
21
|
+
description: str, recommendation: str) -> Dict[str, Any]:
|
|
22
|
+
return {
|
|
23
|
+
"id": rule_id,
|
|
24
|
+
"name": name,
|
|
25
|
+
"severity": severity,
|
|
26
|
+
"details": {
|
|
27
|
+
"cwe": cwe,
|
|
28
|
+
"masvs": masvs,
|
|
29
|
+
"description": description,
|
|
30
|
+
"recommendation": recommendation,
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_RULE_DEFS: Dict[str, Dict[str, Any]] = {
|
|
36
|
+
d["id"]: d for d in [
|
|
37
|
+
_rule_def(
|
|
38
|
+
"IOS-PLIST-ATS-001", "ATS Allows Arbitrary Loads (HTTP Everywhere)",
|
|
39
|
+
"HIGH", "CWE-319", "MSTG-NETWORK-1",
|
|
40
|
+
"NSAllowsArbitraryLoads is true in NSAppTransportSecurity, disabling App Transport Security app-wide. The app can connect over insecure HTTP and with weak/legacy TLS settings to any host.",
|
|
41
|
+
"Remove NSAllowsArbitraryLoads (or set it to false) and use NSExceptionDomains for the specific hosts that genuinely need an exception, with the narrowest exception flags possible.",
|
|
42
|
+
),
|
|
43
|
+
_rule_def(
|
|
44
|
+
"IOS-PLIST-ATS-002", "ATS Allows Arbitrary Loads in Web Content",
|
|
45
|
+
"MEDIUM", "CWE-319", "MSTG-NETWORK-1",
|
|
46
|
+
"NSAllowsArbitraryLoadsInWebContent is true, letting WKWebView/UIWebView load insecure HTTP sub-resources even though top-level ATS is otherwise enforced. Enables mixed-content attacks against content rendered in the app's own WebViews.",
|
|
47
|
+
"Set NSAllowsArbitraryLoadsInWebContent to false unless the app must render arbitrary third-party web content that cannot be upgraded to HTTPS.",
|
|
48
|
+
),
|
|
49
|
+
_rule_def(
|
|
50
|
+
"IOS-PLIST-ATS-003", "ATS Exception Domain Allows Insecure HTTP",
|
|
51
|
+
"MEDIUM", "CWE-319", "MSTG-NETWORK-1",
|
|
52
|
+
"An NSExceptionDomains entry sets NSExceptionAllowsInsecureHTTPLoads to true for a specific domain, permitting cleartext HTTP traffic to that host.",
|
|
53
|
+
"Use HTTPS for this domain and remove the exception. If the domain genuinely cannot support TLS, treat all traffic to it as untrusted/interceptable.",
|
|
54
|
+
),
|
|
55
|
+
_rule_def(
|
|
56
|
+
"IOS-PLIST-ATS-004", "ATS Exception Domain Allows Weak TLS Version",
|
|
57
|
+
"HIGH", "CWE-326", "MSTG-NETWORK-2",
|
|
58
|
+
"An NSExceptionDomains entry sets NSExceptionMinimumTLSVersion to TLSv1.0 or TLSv1.1, both deprecated and vulnerable to known protocol-level attacks (e.g. POODLE, BEAST).",
|
|
59
|
+
"Set NSExceptionMinimumTLSVersion to TLSv1.2 or higher, or remove the exception entirely if the server supports modern TLS.",
|
|
60
|
+
),
|
|
61
|
+
_rule_def(
|
|
62
|
+
"IOS-PLIST-URL-001", "Custom URL Scheme Declared",
|
|
63
|
+
"LOW", "CWE-284", "MSTG-PLATFORM-3",
|
|
64
|
+
"The app registers one or more custom (non-system) URL schemes via CFBundleURLTypes. Any other installed app can invoke these schemes, making the handler part of the app's attack surface (deep link hijacking / spoofed invocation) - this is an attack-surface note, not by itself a proven vulnerability. See the app's URL/deep-link handler source for whether incoming URLs are validated.",
|
|
65
|
+
"Validate the source of incoming URLs where possible (options[.sourceApplication] on iOS <9, or treat all incoming custom-scheme URLs as untrusted input). Prefer Universal Links for anything security-sensitive.",
|
|
66
|
+
),
|
|
67
|
+
_rule_def(
|
|
68
|
+
"IOS-PLIST-URL-002", "Custom URL Schemes Without Universal Links",
|
|
69
|
+
"MEDIUM", "CWE-284", "MSTG-PLATFORM-3",
|
|
70
|
+
"The app declares custom URL scheme(s) via CFBundleURLTypes but its entitlements do not declare a corresponding applinks: Universal Link domain. Universal Links are strictly more secure for deep linking (they require the destination domain to serve a signed apple-app-site-association file, so only the verified domain owner can trigger them - unlike a custom scheme, which any app can register).",
|
|
71
|
+
"Implement Universal Links (com.apple.developer.associated-domains with an applinks: entry) for any deep-link flow that carries sensitive actions (login, payment, account changes), and treat the custom scheme as a legacy/best-effort fallback only.",
|
|
72
|
+
),
|
|
73
|
+
_rule_def(
|
|
74
|
+
"IOS-PLIST-URL-003", "Wildcard Universal Link Domain",
|
|
75
|
+
"MEDIUM", "CWE-284", "MSTG-PLATFORM-3",
|
|
76
|
+
"An applinks: entry in com.apple.developer.associated-domains uses a wildcard domain (*.example.com). Any subdomain - including ones the app owner does not control or has forgotten about - can trigger Universal Link handling in the app.",
|
|
77
|
+
"Use specific, fully-qualified domains instead of a wildcard. If a wildcard is genuinely required, validate the host explicitly in the Universal Link handler before acting on it.",
|
|
78
|
+
),
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_rule_defs() -> List[Dict[str, Any]]:
|
|
84
|
+
"""All rule definitions this module can emit, for SARIF."""
|
|
85
|
+
return list(_RULE_DEFS.values())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _find_own_info_plists(source_dir: str) -> List[str]:
|
|
89
|
+
"""Info.plist files under source_dir, excluding vendored and build trees."""
|
|
90
|
+
results = []
|
|
91
|
+
for path in glob.iglob(os.path.join(source_dir, "**", "Info.plist"), recursive=True):
|
|
92
|
+
rel = os.path.relpath(path, source_dir)
|
|
93
|
+
if is_ios_vendor_dir_path(rel):
|
|
94
|
+
continue
|
|
95
|
+
results.append(path)
|
|
96
|
+
return results
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _find_entitlements_for(info_plist_path: str, source_dir: str,
|
|
100
|
+
_all_entitlements_cache: List[str]) -> Optional[str]:
|
|
101
|
+
"""Best-effort entitlements file for an Info.plist (same directory first), or None."""
|
|
102
|
+
same_dir = os.path.dirname(info_plist_path)
|
|
103
|
+
local_matches = glob.glob(os.path.join(same_dir, "*.entitlements"))
|
|
104
|
+
if local_matches:
|
|
105
|
+
return local_matches[0]
|
|
106
|
+
return _all_entitlements_cache[0] if _all_entitlements_cache else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _all_entitlements(source_dir: str) -> List[str]:
|
|
110
|
+
results = []
|
|
111
|
+
for path in glob.iglob(os.path.join(source_dir, "**", "*.entitlements"), recursive=True):
|
|
112
|
+
rel = os.path.relpath(path, source_dir)
|
|
113
|
+
if is_ios_vendor_dir_path(rel):
|
|
114
|
+
continue
|
|
115
|
+
results.append(path)
|
|
116
|
+
return results
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load_plist(path: str) -> Optional[Dict[str, Any]]:
|
|
120
|
+
try:
|
|
121
|
+
with open(path, "rb") as f:
|
|
122
|
+
return plistlib.load(f)
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _check_ats(ats: Dict[str, Any], rel_path: str) -> List[Dict[str, Any]]:
|
|
128
|
+
findings = []
|
|
129
|
+
if not ats:
|
|
130
|
+
# No ATS block means the iOS default (strict, HTTPS-only) applies.
|
|
131
|
+
return findings
|
|
132
|
+
|
|
133
|
+
if ats.get("NSAllowsArbitraryLoads"):
|
|
134
|
+
findings.append(_make_finding("IOS-PLIST-ATS-001", rel_path, 1))
|
|
135
|
+
|
|
136
|
+
if ats.get("NSAllowsArbitraryLoadsInWebContent"):
|
|
137
|
+
findings.append(_make_finding("IOS-PLIST-ATS-002", rel_path, 1))
|
|
138
|
+
|
|
139
|
+
exception_domains = ats.get("NSExceptionDomains", {}) or {}
|
|
140
|
+
if isinstance(exception_domains, dict):
|
|
141
|
+
for domain, config in exception_domains.items():
|
|
142
|
+
if not isinstance(config, dict):
|
|
143
|
+
continue
|
|
144
|
+
if config.get("NSExceptionAllowsInsecureHTTPLoads"):
|
|
145
|
+
findings.append(_make_finding(
|
|
146
|
+
"IOS-PLIST-ATS-003", rel_path, 1,
|
|
147
|
+
extra_desc=f' Domain: "{domain}".'))
|
|
148
|
+
if config.get("NSExceptionMinimumTLSVersion") in _WEAK_TLS_VERSIONS:
|
|
149
|
+
tls = config.get("NSExceptionMinimumTLSVersion")
|
|
150
|
+
findings.append(_make_finding(
|
|
151
|
+
"IOS-PLIST-ATS-004", rel_path, 1,
|
|
152
|
+
extra_desc=f' Domain: "{domain}", version: {tls}.'))
|
|
153
|
+
return findings
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _declared_url_schemes(plist_content: Dict[str, Any]) -> List[str]:
|
|
157
|
+
schemes = []
|
|
158
|
+
for url_type in (plist_content.get("CFBundleURLTypes") or []):
|
|
159
|
+
if not isinstance(url_type, dict):
|
|
160
|
+
continue
|
|
161
|
+
for scheme in (url_type.get("CFBundleURLSchemes") or []):
|
|
162
|
+
if isinstance(scheme, str) and scheme.strip():
|
|
163
|
+
schemes.append(scheme.strip())
|
|
164
|
+
return schemes
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _check_url_schemes_and_universal_links(
|
|
168
|
+
plist_content: Dict[str, Any], rel_plist_path: str,
|
|
169
|
+
entitlements: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
170
|
+
findings = []
|
|
171
|
+
schemes = _declared_url_schemes(plist_content)
|
|
172
|
+
custom_schemes = [s for s in schemes if s.lower() not in _SYSTEM_URL_SCHEMES]
|
|
173
|
+
if not custom_schemes:
|
|
174
|
+
return findings
|
|
175
|
+
|
|
176
|
+
findings.append(_make_finding(
|
|
177
|
+
"IOS-PLIST-URL-001", rel_plist_path, 1,
|
|
178
|
+
extra_desc=f' Declared scheme(s): {", ".join(custom_schemes)}.'))
|
|
179
|
+
|
|
180
|
+
if entitlements is None:
|
|
181
|
+
# Universal Links live in entitlements only; without one they can't be ruled out.
|
|
182
|
+
return findings
|
|
183
|
+
|
|
184
|
+
domains = entitlements.get("com.apple.developer.associated-domains", []) or []
|
|
185
|
+
applinks_domains = [
|
|
186
|
+
d[len("applinks:"):] for d in domains
|
|
187
|
+
if isinstance(d, str) and d.startswith("applinks:")
|
|
188
|
+
]
|
|
189
|
+
if not applinks_domains:
|
|
190
|
+
findings.append(_make_finding(
|
|
191
|
+
"IOS-PLIST-URL-002", rel_plist_path, 1,
|
|
192
|
+
extra_desc=f' Declared scheme(s): {", ".join(custom_schemes)}.'))
|
|
193
|
+
|
|
194
|
+
for domain in applinks_domains:
|
|
195
|
+
if domain.startswith("*."):
|
|
196
|
+
findings.append(_make_finding(
|
|
197
|
+
"IOS-PLIST-URL-003", rel_plist_path, 1,
|
|
198
|
+
extra_desc=f' Domain: "{domain}".'))
|
|
199
|
+
|
|
200
|
+
return findings
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _make_finding(rule_id: str, file_path: str, line: int,
|
|
204
|
+
extra_desc: str = "") -> Dict[str, Any]:
|
|
205
|
+
rule = _RULE_DEFS[rule_id]
|
|
206
|
+
details = dict(rule["details"])
|
|
207
|
+
if extra_desc:
|
|
208
|
+
details["description"] = details["description"] + extra_desc
|
|
209
|
+
return {
|
|
210
|
+
"rule_id": rule_id,
|
|
211
|
+
"file_path": file_path,
|
|
212
|
+
"name": rule["name"],
|
|
213
|
+
"severity": rule["severity"],
|
|
214
|
+
"line": line,
|
|
215
|
+
"engine": "plist",
|
|
216
|
+
"details": details,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def analyze_plist_and_entitlements(source_dir: str) -> Tuple[List[Dict[str, Any]], List[str], int]:
|
|
221
|
+
"""Scan every non-vendored Info.plist. Returns (findings, notes, count)."""
|
|
222
|
+
findings: List[Dict[str, Any]] = []
|
|
223
|
+
notes: List[str] = []
|
|
224
|
+
|
|
225
|
+
info_plists = _find_own_info_plists(source_dir)
|
|
226
|
+
if not info_plists:
|
|
227
|
+
return findings, notes, 0
|
|
228
|
+
|
|
229
|
+
entitlements_files = _all_entitlements(source_dir)
|
|
230
|
+
if not entitlements_files:
|
|
231
|
+
notes.append(
|
|
232
|
+
"No *.entitlements file found in the repo - Universal Links "
|
|
233
|
+
"checks were skipped for all Info.plist targets (cannot prove "
|
|
234
|
+
"Universal Links are absent without the real entitlements)."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
for plist_path in info_plists:
|
|
238
|
+
rel_plist = os.path.relpath(plist_path, source_dir)
|
|
239
|
+
plist_content = _load_plist(plist_path)
|
|
240
|
+
if plist_content is None:
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
findings.extend(_check_ats(plist_content.get("NSAppTransportSecurity", {}) or {}, rel_plist))
|
|
244
|
+
|
|
245
|
+
entitlements_path = _find_entitlements_for(plist_path, source_dir, entitlements_files)
|
|
246
|
+
entitlements = _load_plist(entitlements_path) if entitlements_path else None
|
|
247
|
+
findings.extend(_check_url_schemes_and_universal_links(plist_content, rel_plist, entitlements))
|
|
248
|
+
|
|
249
|
+
return findings, notes, len(info_plists)
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""iOS source-mode (Swift/ObjC) SAST entry point: ObjC regex pass, Swift Semgrep
|
|
2
|
+
pass, plist/entitlements pass. Public API: analyze_source()."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from narvy.rule_engine import load_rules_from_dir, run_rules_on_file
|
|
13
|
+
from narvy import semgrep_engine
|
|
14
|
+
from narvy import comment_filter
|
|
15
|
+
from narvy.ios.third_party_filter import is_ios_vendor_dir_path, IOS_VENDOR_DIR_NAMES
|
|
16
|
+
from narvy.ios import plist_checks
|
|
17
|
+
from narvy.ios import trust_all_context
|
|
18
|
+
|
|
19
|
+
_HERE = os.path.dirname(__file__)
|
|
20
|
+
OBJC_RULES_DIR = os.path.join(_HERE, "..", "rules", "ios", "objc")
|
|
21
|
+
SWIFT_SEMGREP_RULES_PATH = os.path.join(_HERE, "..", "rules", "ios_swift.yml")
|
|
22
|
+
|
|
23
|
+
_OBJC_EXTENSIONS = (".m", ".mm", ".h")
|
|
24
|
+
_SWIFT_EXTENSION = ".swift"
|
|
25
|
+
|
|
26
|
+
# Skips the npm tree a React Native project keeps beside its Xcode project.
|
|
27
|
+
_EXTRA_SKIP_DIR_NAMES = frozenset({"node_modules", ".git"})
|
|
28
|
+
|
|
29
|
+
# Suffix match: Xcode names test folders <TargetName>Tests / <TargetName>UITests.
|
|
30
|
+
_TEST_DIR_SUFFIXES = ("Tests", "UITests")
|
|
31
|
+
|
|
32
|
+
_SEMGREP_SEVERITY_MAP = {
|
|
33
|
+
"ERROR": "CRITICAL",
|
|
34
|
+
"WARNING": "HIGH",
|
|
35
|
+
"INFO": "MEDIUM",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_test_dir_name(name: str) -> bool:
|
|
40
|
+
return name.endswith(_TEST_DIR_SUFFIXES)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_extra_skip_path(rel_path: str) -> bool:
|
|
44
|
+
parts = rel_path.replace("\\", "/").split("/")
|
|
45
|
+
return any(part in _EXTRA_SKIP_DIR_NAMES or _is_test_dir_name(part) for part in parts)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_skipped_path(rel_path: str) -> bool:
|
|
49
|
+
return is_ios_vendor_dir_path(rel_path) or _is_extra_skip_path(rel_path)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _detect_react_native(source_dir: str) -> bool:
|
|
53
|
+
"""Structural signal that this is (at least partly) a React Native app."""
|
|
54
|
+
if os.path.isdir(os.path.join(source_dir, "node_modules", "react-native")):
|
|
55
|
+
return True
|
|
56
|
+
for fname in ("Podfile", "Podfile.lock"):
|
|
57
|
+
fpath = os.path.join(source_dir, fname)
|
|
58
|
+
if os.path.isfile(fpath):
|
|
59
|
+
try:
|
|
60
|
+
with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
|
|
61
|
+
content = f.read()
|
|
62
|
+
except OSError:
|
|
63
|
+
continue
|
|
64
|
+
if "React-Core" in content or "react-native" in content.lower():
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _collect_source_files(source_dir: str) -> Tuple[List[str], List[str], int]:
|
|
70
|
+
"""Return (swift_files, objc_files, skipped_third_party_count)."""
|
|
71
|
+
swift_files: List[str] = []
|
|
72
|
+
objc_files: List[str] = []
|
|
73
|
+
skipped = 0
|
|
74
|
+
|
|
75
|
+
for root, _dirs, files in os.walk(source_dir):
|
|
76
|
+
for fname in files:
|
|
77
|
+
fpath = os.path.join(root, fname)
|
|
78
|
+
rel_path = os.path.relpath(fpath, source_dir)
|
|
79
|
+
is_swift = fname.endswith(_SWIFT_EXTENSION)
|
|
80
|
+
is_objc = fname.endswith(_OBJC_EXTENSIONS)
|
|
81
|
+
if not (is_swift or is_objc):
|
|
82
|
+
continue
|
|
83
|
+
if _is_skipped_path(rel_path):
|
|
84
|
+
skipped += 1
|
|
85
|
+
continue
|
|
86
|
+
if is_swift:
|
|
87
|
+
swift_files.append(fpath)
|
|
88
|
+
else:
|
|
89
|
+
objc_files.append(fpath)
|
|
90
|
+
|
|
91
|
+
return swift_files, objc_files, skipped
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _load_swift_semgrep_rule_defs() -> List[Dict[str, Any]]:
|
|
95
|
+
"""Synthesize a rule-def dict per rule id in ios_swift.yml, for SARIF."""
|
|
96
|
+
try:
|
|
97
|
+
with open(SWIFT_SEMGREP_RULES_PATH, "r") as f:
|
|
98
|
+
data = yaml.safe_load(f)
|
|
99
|
+
except OSError:
|
|
100
|
+
return []
|
|
101
|
+
|
|
102
|
+
defs = []
|
|
103
|
+
for rule in (data or {}).get("rules", []):
|
|
104
|
+
meta = rule.get("metadata", {}) or {}
|
|
105
|
+
defs.append({
|
|
106
|
+
"id": rule["id"],
|
|
107
|
+
"name": rule.get("message", rule["id"])[:120],
|
|
108
|
+
"severity": _SEMGREP_SEVERITY_MAP.get(rule.get("severity", "INFO"), "MEDIUM"),
|
|
109
|
+
"details": {
|
|
110
|
+
"cwe": meta.get("cwe", ""),
|
|
111
|
+
"masvs": meta.get("masvs", ""),
|
|
112
|
+
"description": rule.get("message", ""),
|
|
113
|
+
"recommendation": rule.get("message", ""),
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
return defs
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _run_swift_semgrep(source_dir: str,
|
|
120
|
+
timeout: Optional[int] = None) -> Optional[List[Dict[str, Any]]]:
|
|
121
|
+
"""Run Semgrep against ios_swift.yml over source_dir."""
|
|
122
|
+
if not semgrep_engine.is_available():
|
|
123
|
+
semgrep_engine._record_run('unavailable')
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
file_count = semgrep_engine.count_source_files(source_dir)
|
|
127
|
+
if timeout is None:
|
|
128
|
+
timeout = semgrep_engine.compute_semgrep_timeout(source_dir)
|
|
129
|
+
|
|
130
|
+
# --exclude args must be BARE names: a '/' anchors the pattern to the scan root.
|
|
131
|
+
exclude_args: List[str] = []
|
|
132
|
+
for name in sorted(IOS_VENDOR_DIR_NAMES | _EXTRA_SKIP_DIR_NAMES):
|
|
133
|
+
exclude_args += ["--exclude", name]
|
|
134
|
+
for suffix in _TEST_DIR_SUFFIXES:
|
|
135
|
+
exclude_args += ["--exclude", f"*{suffix}"]
|
|
136
|
+
|
|
137
|
+
cmd = (
|
|
138
|
+
["semgrep", "--config", SWIFT_SEMGREP_RULES_PATH, source_dir,
|
|
139
|
+
"--include", "*.swift",
|
|
140
|
+
"--json", "--quiet", "--no-git-ignore",
|
|
141
|
+
"--timeout", "60"]
|
|
142
|
+
+ exclude_args
|
|
143
|
+
)
|
|
144
|
+
try:
|
|
145
|
+
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
146
|
+
except subprocess.TimeoutExpired:
|
|
147
|
+
semgrep_engine._record_run('timeout', timeout_s=timeout,
|
|
148
|
+
file_count=file_count)
|
|
149
|
+
return []
|
|
150
|
+
|
|
151
|
+
if not proc.stdout:
|
|
152
|
+
semgrep_engine._record_run('ok', timeout_s=timeout,
|
|
153
|
+
file_count=file_count)
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
data = json.loads(proc.stdout)
|
|
158
|
+
except json.JSONDecodeError:
|
|
159
|
+
semgrep_engine._record_run(
|
|
160
|
+
'error', timeout_s=timeout, file_count=file_count,
|
|
161
|
+
message="semgrep output was not valid JSON")
|
|
162
|
+
return []
|
|
163
|
+
|
|
164
|
+
semgrep_engine._record_run('ok', timeout_s=timeout, file_count=file_count)
|
|
165
|
+
|
|
166
|
+
# Per-file (content, comment-mask) cache: read each file at most once.
|
|
167
|
+
file_cache: Dict[str, Tuple[str, Optional[List[bool]]]] = {}
|
|
168
|
+
|
|
169
|
+
def _get_file(abs_path: str) -> Tuple[str, Optional[List[bool]]]:
|
|
170
|
+
if abs_path not in file_cache:
|
|
171
|
+
try:
|
|
172
|
+
with open(abs_path, "r", encoding="utf-8", errors="ignore") as fh:
|
|
173
|
+
content = fh.read()
|
|
174
|
+
except OSError:
|
|
175
|
+
file_cache[abs_path] = ("", None)
|
|
176
|
+
return file_cache[abs_path]
|
|
177
|
+
mask = None
|
|
178
|
+
if "//" in content or "/*" in content:
|
|
179
|
+
mask = comment_filter.build_comment_mask(content)
|
|
180
|
+
file_cache[abs_path] = (content, mask)
|
|
181
|
+
return file_cache[abs_path]
|
|
182
|
+
|
|
183
|
+
findings = []
|
|
184
|
+
for r in data.get("results", []):
|
|
185
|
+
abs_path = r.get("path", "")
|
|
186
|
+
rel_path = os.path.relpath(abs_path, source_dir)
|
|
187
|
+
extra = r.get("extra", {})
|
|
188
|
+
meta = extra.get("metadata", {})
|
|
189
|
+
# Semgrep prefixes local-config ids with a dotted namespace; ours have no dots.
|
|
190
|
+
check_id = r.get("check_id", "semgrep-finding").rsplit(".", 1)[-1]
|
|
191
|
+
line_no = r.get("start", {}).get("line", 1)
|
|
192
|
+
col_no = r.get("start", {}).get("col", 1)
|
|
193
|
+
|
|
194
|
+
content, mask = _get_file(abs_path)
|
|
195
|
+
|
|
196
|
+
# Drop regex-rule hits that sit inside a comment (AST rules never do).
|
|
197
|
+
if mask is not None and comment_filter.line_col_in_comment(
|
|
198
|
+
content, line_no, col_no, mask
|
|
199
|
+
):
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
severity = _SEMGREP_SEVERITY_MAP.get(extra.get("severity", "INFO"), "MEDIUM")
|
|
203
|
+
message = extra.get("message", "")
|
|
204
|
+
|
|
205
|
+
# Downgrade trust-all-certs when guarded by a per-request opt-in flag.
|
|
206
|
+
if check_id == "ios-swift-trust-all-certs" and content:
|
|
207
|
+
flag = trust_all_context.opt_in_trust_flag(content, line_no)
|
|
208
|
+
if flag:
|
|
209
|
+
severity = "MEDIUM"
|
|
210
|
+
message = (
|
|
211
|
+
message.rstrip()
|
|
212
|
+
+ f" [Narvy: this path is reachable only when the opt-in "
|
|
213
|
+
f"flag `{flag}` is set (per-request, not unconditional), so "
|
|
214
|
+
f"severity is lowered - verify that flag is never enabled "
|
|
215
|
+
f"in production builds.]"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
findings.append({
|
|
219
|
+
"rule_id": check_id,
|
|
220
|
+
"file_path": rel_path,
|
|
221
|
+
"name": message[:120] if message else extra.get("message", r.get("check_id", ""))[:120],
|
|
222
|
+
"severity": severity,
|
|
223
|
+
"line": line_no,
|
|
224
|
+
"details": {
|
|
225
|
+
"cwe": meta.get("cwe", ""),
|
|
226
|
+
"masvs": meta.get("masvs", ""),
|
|
227
|
+
"description": message,
|
|
228
|
+
"recommendation": message,
|
|
229
|
+
},
|
|
230
|
+
"engine": "semgrep",
|
|
231
|
+
})
|
|
232
|
+
return findings
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def analyze_source(source_dir: str) -> Dict[str, Any]:
|
|
236
|
+
"""Scan a checked-out Swift/ObjC repo (read-only); returns the findings dict."""
|
|
237
|
+
if not source_dir or not os.path.isdir(source_dir):
|
|
238
|
+
return {
|
|
239
|
+
"ok": False,
|
|
240
|
+
"error": f"Not a directory: {source_dir}",
|
|
241
|
+
"findings": [],
|
|
242
|
+
"rule_defs": [],
|
|
243
|
+
"swift_files_scanned": 0,
|
|
244
|
+
"objc_files_scanned": 0,
|
|
245
|
+
"skipped_third_party": 0,
|
|
246
|
+
"notes": [],
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
notes: List[str] = []
|
|
250
|
+
all_findings: List[Dict[str, Any]] = []
|
|
251
|
+
|
|
252
|
+
objc_rules = load_rules_from_dir(OBJC_RULES_DIR)
|
|
253
|
+
swift_semgrep_rule_defs = _load_swift_semgrep_rule_defs()
|
|
254
|
+
|
|
255
|
+
swift_files, objc_files, skipped_third_party = _collect_source_files(source_dir)
|
|
256
|
+
|
|
257
|
+
if _detect_react_native(source_dir):
|
|
258
|
+
notes.append(
|
|
259
|
+
"This looks like a React Native app (node_modules/react-native "
|
|
260
|
+
"or a Podfile referencing React-Core was found). This scanner "
|
|
261
|
+
"only covers the native Swift/Objective-C shell - the app's "
|
|
262
|
+
"JS/TS business logic is out of scope and was not analyzed."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
for file_path in objc_files:
|
|
266
|
+
rel_path = os.path.relpath(file_path, source_dir)
|
|
267
|
+
findings_in_file = run_rules_on_file(file_path, objc_rules)
|
|
268
|
+
for finding in findings_in_file:
|
|
269
|
+
finding["file_path"] = rel_path
|
|
270
|
+
finding["engine"] = "regex"
|
|
271
|
+
all_findings.extend(findings_in_file)
|
|
272
|
+
|
|
273
|
+
if semgrep_engine.is_available():
|
|
274
|
+
swift_findings = _run_swift_semgrep(source_dir)
|
|
275
|
+
if swift_findings:
|
|
276
|
+
all_findings.extend(swift_findings)
|
|
277
|
+
_sg_note = semgrep_engine.degraded_coverage_note()
|
|
278
|
+
if _sg_note:
|
|
279
|
+
notes.append(_sg_note)
|
|
280
|
+
else:
|
|
281
|
+
notes.append(
|
|
282
|
+
"semgrep not found - install with `pip install semgrep` for "
|
|
283
|
+
"Swift analysis. ObjC regex rules still ran."
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
plist_findings, plist_notes, _plist_count = plist_checks.analyze_plist_and_entitlements(source_dir)
|
|
287
|
+
all_findings.extend(plist_findings)
|
|
288
|
+
notes.extend(plist_notes)
|
|
289
|
+
|
|
290
|
+
rule_defs = list(objc_rules) + swift_semgrep_rule_defs + plist_checks.get_rule_defs()
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
"ok": True,
|
|
294
|
+
"error": None,
|
|
295
|
+
"findings": all_findings,
|
|
296
|
+
"rule_defs": rule_defs,
|
|
297
|
+
"swift_files_scanned": len(swift_files),
|
|
298
|
+
"objc_files_scanned": len(objc_files),
|
|
299
|
+
"skipped_third_party": skipped_third_party,
|
|
300
|
+
"notes": notes,
|
|
301
|
+
}
|