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
@@ -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
+ }