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,242 @@
|
|
|
1
|
+
"""Third-party scoping for iOS: vendor-directory filtering in source mode, and a
|
|
2
|
+
three-tier classification of embedded frameworks (first-party/vendor/unconfirmed)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any, Dict, Optional, Set, Tuple
|
|
8
|
+
|
|
9
|
+
from ..scope_config import ScopeConfig, entry_matches, segment_prefix_match
|
|
10
|
+
|
|
11
|
+
# Matched as exact path segments, never substrings (so "VendoredAssets" is safe).
|
|
12
|
+
IOS_VENDOR_DIR_NAMES = frozenset({
|
|
13
|
+
"Pods",
|
|
14
|
+
"Carthage",
|
|
15
|
+
".swiftpm",
|
|
16
|
+
"Vendor",
|
|
17
|
+
"Vendored",
|
|
18
|
+
"DerivedData",
|
|
19
|
+
".build",
|
|
20
|
+
"ModuleCache.noindex",
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
# Matched with `startswith`: every entry must be specific enough that no
|
|
24
|
+
# first-party framework name can begin with it.
|
|
25
|
+
IOS_THIRD_PARTY_FRAMEWORK_PREFIXES = (
|
|
26
|
+
"SwiftUI", "Combine", "Foundation", "UIKit", "CoreFoundation",
|
|
27
|
+
"SwiftProtobuf",
|
|
28
|
+
"Firebase", "FBLPromises", "GoogleUtilities", "GoogleDataTransport",
|
|
29
|
+
"GoogleAppMeasurement", "GTMSessionFetcher", "GTMAppAuth", "GoogleSignIn",
|
|
30
|
+
"GoogleMaps", "GoogleMobileAds", "GoogleToolboxForMac", "nanopb",
|
|
31
|
+
"FBSDKCoreKit", "FBSDKLoginKit", "FBSDKShareKit", "FBSDKGamingServicesKit",
|
|
32
|
+
"FBAEMKit",
|
|
33
|
+
"Alamofire", "AFNetworking", "AlamofireImage",
|
|
34
|
+
"RxSwift", "RxCocoa", "RxRelay", "ReactiveSwift", "ReactiveCocoa",
|
|
35
|
+
"Combine",
|
|
36
|
+
"Realm", "RealmSwift", "SQLite",
|
|
37
|
+
"SDWebImage", "SDWebImageWebPCoder", "Kingfisher", "libwebp",
|
|
38
|
+
"Crashlytics", "Fabric", "Sentry", "Bugsnag", "Mixpanel", "Amplitude",
|
|
39
|
+
"Segment", "AppsFlyerLib", "Adjust", "Branch", "OneSignal",
|
|
40
|
+
"Stripe", "StripeCore", "StripeUICore", "StripePaymentsUI", "Braintree",
|
|
41
|
+
"PayPal",
|
|
42
|
+
"Swinject", "SnapKit", "Then", "SwiftyJSON", "KeychainAccess", "Alamofire",
|
|
43
|
+
"PromiseKit", "Moya", "Result",
|
|
44
|
+
"React", "hermes", "Flutter", "flutter", "Cordova",
|
|
45
|
+
"WebRTC", "GoogleWebRTC",
|
|
46
|
+
"OneSignal", "PushKit",
|
|
47
|
+
"Lottie",
|
|
48
|
+
"Parse", "Bolts",
|
|
49
|
+
"Flurry",
|
|
50
|
+
"HockeySDK", "BITHockeyKit", "Crashlytics",
|
|
51
|
+
"UrbanAirship", "MoPub", "Chartboost", "Vungle", "IronSource", "AppLovin",
|
|
52
|
+
"Tune", "Kochava", "Localytics", "Optimizely", "Appboy", "BrazeKit", "Braze",
|
|
53
|
+
# IQKeyboardManager ships as SPM sub-module frameworks sharing this prefix.
|
|
54
|
+
"IQ",
|
|
55
|
+
"MBProgressHUD", "Masonry", "ReachabilitySwift", "Reachability",
|
|
56
|
+
"TTTAttributedLabel", "MJRefresh", "YYModel", "YYCache", "YYImage",
|
|
57
|
+
"TalsecRuntime", "Talsec", "FreeRasp",
|
|
58
|
+
"GoogleCast",
|
|
59
|
+
"SCSDKCoreKit",
|
|
60
|
+
"SpotifyLogin",
|
|
61
|
+
"TwilioVoice",
|
|
62
|
+
"SSZipArchive",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Reverse-DNS roots take two segments when the leading one is a generic TLD.
|
|
67
|
+
_GENERIC_TLD_SEGMENTS = {"com", "org", "io", "net", "edu", "gov", "co"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _bundle_id_root(bundle_id: str) -> str:
|
|
71
|
+
segments = [s for s in bundle_id.split(".") if s]
|
|
72
|
+
if not segments:
|
|
73
|
+
return ""
|
|
74
|
+
if segments[0].lower() in _GENERIC_TLD_SEGMENTS and len(segments) >= 2:
|
|
75
|
+
return ".".join(segments[:2]).lower()
|
|
76
|
+
return segments[0].lower()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def bundle_ids_share_reverse_dns_root(bundle_id_a: Optional[str],
|
|
80
|
+
bundle_id_b: Optional[str]) -> bool:
|
|
81
|
+
if not bundle_id_a or not bundle_id_b:
|
|
82
|
+
return False
|
|
83
|
+
root_a, root_b = _bundle_id_root(bundle_id_a), _bundle_id_root(bundle_id_b)
|
|
84
|
+
return bool(root_a) and root_a == root_b
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Case-insensitive, checked before the first-party root check. Never "org.cocoapods."
|
|
88
|
+
# (stamped on first-party pods too); vendors need their SDK sub-namespace.
|
|
89
|
+
IOS_THIRD_PARTY_BUNDLE_ID_PREFIXES = (
|
|
90
|
+
"com.lynxsft.",
|
|
91
|
+
"com.facebook.sdk.",
|
|
92
|
+
"io.sentry.",
|
|
93
|
+
"io.realm.",
|
|
94
|
+
"com.onesignal.",
|
|
95
|
+
"com.braze.",
|
|
96
|
+
"com.bolts.",
|
|
97
|
+
"com.adjust.sdk.",
|
|
98
|
+
"com.applovin.",
|
|
99
|
+
"kingfisher.",
|
|
100
|
+
"com.airbnb.lottie.",
|
|
101
|
+
"com.giphy.",
|
|
102
|
+
"com.google.ads.",
|
|
103
|
+
"com.google.cast",
|
|
104
|
+
"com.fourthline.",
|
|
105
|
+
"authada.",
|
|
106
|
+
"com.plaid.",
|
|
107
|
+
"com.snapchat.",
|
|
108
|
+
"com.spotify.sdk.",
|
|
109
|
+
"com.twilio.",
|
|
110
|
+
"com.ziparchive.",
|
|
111
|
+
"com.sardine.",
|
|
112
|
+
"com.prove.sdk.",
|
|
113
|
+
"com.riskified.",
|
|
114
|
+
)
|
|
115
|
+
_IOS_THIRD_PARTY_BUNDLE_ID_PREFIXES_LOWER = tuple(
|
|
116
|
+
p.lower() for p in IOS_THIRD_PARTY_BUNDLE_ID_PREFIXES)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def is_ios_vendor_dir_path(rel_path: str) -> bool:
|
|
120
|
+
parts = rel_path.replace("\\", "/").split("/")
|
|
121
|
+
return any(part in IOS_VENDOR_DIR_NAMES for part in parts)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
_PODFILE_LOCK_POD_RE = re.compile(r'^\s*-\s*([A-Za-z0-9_./+-]+?)(?:\s*\(|\s*$)', re.MULTILINE)
|
|
125
|
+
|
|
126
|
+
# CocoaPods indents installed pods with exactly 2 spaces, constraints with 4+.
|
|
127
|
+
_PODFILE_LOCK_TOP_POD_RE = re.compile(r'^ - ([A-Za-z0-9_./+-]+?)(?:\s+\(([^)]+)\))?:?\s*$')
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _read_podfile_lock_pods_section(repo_root: str) -> Optional[str]:
|
|
131
|
+
lock_path = os.path.join(repo_root, "Podfile.lock")
|
|
132
|
+
if not os.path.isfile(lock_path):
|
|
133
|
+
return None
|
|
134
|
+
try:
|
|
135
|
+
with open(lock_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
136
|
+
content = f.read()
|
|
137
|
+
except OSError:
|
|
138
|
+
return None
|
|
139
|
+
m = re.search(r'^PODS:\s*\n(.*?)(?:\n[A-Z]+:|\Z)', content, re.DOTALL | re.MULTILINE)
|
|
140
|
+
return m.group(1) if m else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_vendored_pod_names(repo_root: str) -> Set[str]:
|
|
144
|
+
section = _read_podfile_lock_pods_section(repo_root)
|
|
145
|
+
if section is None:
|
|
146
|
+
return set()
|
|
147
|
+
pods = set()
|
|
148
|
+
for line in section.splitlines():
|
|
149
|
+
pm = _PODFILE_LOCK_POD_RE.match(line)
|
|
150
|
+
if pm:
|
|
151
|
+
pods.add(pm.group(1).split("/", 1)[0])
|
|
152
|
+
return pods
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_vendored_pods_with_versions(repo_root: str) -> Dict[str, str]:
|
|
156
|
+
"""Top-level pod name to locked installed version, empty if unparseable."""
|
|
157
|
+
section = _read_podfile_lock_pods_section(repo_root)
|
|
158
|
+
if section is None:
|
|
159
|
+
return {}
|
|
160
|
+
pods: Dict[str, str] = {}
|
|
161
|
+
for line in section.splitlines():
|
|
162
|
+
pm = _PODFILE_LOCK_TOP_POD_RE.match(line)
|
|
163
|
+
if not pm:
|
|
164
|
+
continue
|
|
165
|
+
name = pm.group(1).split("/", 1)[0]
|
|
166
|
+
version = pm.group(2)
|
|
167
|
+
if name and version and name not in pods:
|
|
168
|
+
pods[name] = version
|
|
169
|
+
return pods
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_own_bundle_id(plist: Dict[str, Any]) -> Optional[str]:
|
|
173
|
+
bid = plist.get("CFBundleIdentifier")
|
|
174
|
+
return bid if isinstance(bid, str) and bid else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def is_third_party_framework_name(name: str) -> bool:
|
|
178
|
+
return any(name == p or name.startswith(p) for p in IOS_THIRD_PARTY_FRAMEWORK_PREFIXES)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
TIER_FIRST_PARTY = "first_party"
|
|
182
|
+
TIER_THIRD_PARTY = "third_party"
|
|
183
|
+
TIER_UNCONFIRMED = "unconfirmed"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def classify_ios_framework(framework_name: str,
|
|
187
|
+
framework_bundle_id: Optional[str],
|
|
188
|
+
app_bundle_id: Optional[str]) -> Tuple[str, str]:
|
|
189
|
+
"""(tier, reason) for one embedded framework, reason safe to show to a user."""
|
|
190
|
+
# Vendor check first: a bare dylib has no bundle id and would fall to first-party.
|
|
191
|
+
bundle_id_lower = framework_bundle_id.lower() if framework_bundle_id else None
|
|
192
|
+
|
|
193
|
+
bundle_hit = bundle_id_lower is not None and any(
|
|
194
|
+
bundle_id_lower.startswith(p) for p in _IOS_THIRD_PARTY_BUNDLE_ID_PREFIXES_LOWER)
|
|
195
|
+
name_hit = is_third_party_framework_name(framework_name)
|
|
196
|
+
if bundle_hit or name_hit:
|
|
197
|
+
signals = []
|
|
198
|
+
if bundle_hit:
|
|
199
|
+
signals.append(f"bundle id {framework_bundle_id!r} matches curated vendor list")
|
|
200
|
+
if name_hit:
|
|
201
|
+
signals.append("framework name matches curated vendor name list")
|
|
202
|
+
return TIER_THIRD_PARTY, "confirmed external vendor SDK (" + "; ".join(signals) + ")"
|
|
203
|
+
|
|
204
|
+
if framework_bundle_id is None:
|
|
205
|
+
return TIER_FIRST_PARTY, (
|
|
206
|
+
"no separate bundle id could be read (statically linked framework or "
|
|
207
|
+
"unreadable/absent Info.plist) - cannot isolate as third-party, scanning "
|
|
208
|
+
"as the app's own code rather than risking a false exclusion")
|
|
209
|
+
|
|
210
|
+
if bundle_ids_share_reverse_dns_root(framework_bundle_id, app_bundle_id):
|
|
211
|
+
return TIER_FIRST_PARTY, (
|
|
212
|
+
f"bundle id {framework_bundle_id!r} shares its reverse-DNS root with the "
|
|
213
|
+
f"app's own bundle id {app_bundle_id!r}")
|
|
214
|
+
|
|
215
|
+
return TIER_UNCONFIRMED, (
|
|
216
|
+
f"bundle id {framework_bundle_id!r} does not share a reverse-DNS root with the "
|
|
217
|
+
f"app's own bundle id {app_bundle_id!r}, and is not on the curated third-party "
|
|
218
|
+
f"vendor list")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def check_ios_override(framework_name: str,
|
|
222
|
+
framework_bundle_id: Optional[str],
|
|
223
|
+
config: Optional[ScopeConfig]) -> Optional[str]:
|
|
224
|
+
"""'own', 'vendor' or None from a user-declared .narvy-scope.yml ('own' wins)."""
|
|
225
|
+
if not config:
|
|
226
|
+
return None
|
|
227
|
+
name_lower = framework_name.lower()
|
|
228
|
+
bid_lower = framework_bundle_id.lower() if framework_bundle_id else None
|
|
229
|
+
|
|
230
|
+
def _hits(patterns) -> bool:
|
|
231
|
+
for entry in patterns:
|
|
232
|
+
if entry_matches(entry, name_lower):
|
|
233
|
+
return True
|
|
234
|
+
if bid_lower and segment_prefix_match(entry, bid_lower):
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
if _hits(config.own):
|
|
239
|
+
return "own"
|
|
240
|
+
if _hits(config.vendor):
|
|
241
|
+
return "vendor"
|
|
242
|
+
return None
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Context gate for the `ios-swift-trust-all-certs` rule: detect an explicit
|
|
2
|
+
per-request opt-in trust flag guarding the trust-all finding so the caller can
|
|
3
|
+
downgrade rather than drop it."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
# Identifier substrings meaning the user opted in to relaxing cert validation.
|
|
10
|
+
_OPT_IN_TRUST_TOKENS = (
|
|
11
|
+
"allowuntrusted",
|
|
12
|
+
"allowinvalidcert",
|
|
13
|
+
"allowinvalidcertificate",
|
|
14
|
+
"allowselfsigned",
|
|
15
|
+
"allowbadcert",
|
|
16
|
+
"allowanycert",
|
|
17
|
+
"allowanyhttpscert",
|
|
18
|
+
"trustallcert",
|
|
19
|
+
"trustanycert",
|
|
20
|
+
"trustall",
|
|
21
|
+
"allowinsecure",
|
|
22
|
+
"disablecertvalidation",
|
|
23
|
+
"disablecertificatevalidation",
|
|
24
|
+
"disablesslvalidation",
|
|
25
|
+
"skipcertvalidation",
|
|
26
|
+
"skiptlsvalidation",
|
|
27
|
+
"skipservertrust",
|
|
28
|
+
"ignorecert",
|
|
29
|
+
"ignoressl",
|
|
30
|
+
"insecureskipverify",
|
|
31
|
+
"acceptselfsigned",
|
|
32
|
+
"acceptanycert",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
36
|
+
|
|
37
|
+
_MAX_LOOKBACK_LINES = 80
|
|
38
|
+
|
|
39
|
+
_FUNC_START_RE = re.compile(r"\b(func|init)\b")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _window_bounds(lines: list, finding_idx: int) -> tuple:
|
|
43
|
+
"""Return (start, end) idx of the enclosing-function window, falling back to a fixed look-back."""
|
|
44
|
+
start = max(0, finding_idx - _MAX_LOOKBACK_LINES)
|
|
45
|
+
for i in range(finding_idx, start - 1, -1):
|
|
46
|
+
if _FUNC_START_RE.search(lines[i]):
|
|
47
|
+
return i, finding_idx
|
|
48
|
+
return start, finding_idx
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def opt_in_trust_flag(content: str, line_no: int) -> Optional[str]:
|
|
52
|
+
"""Return the opt-in trust flag guarding the trust-all finding at `line_no`, else None."""
|
|
53
|
+
if not content or line_no < 1:
|
|
54
|
+
return None
|
|
55
|
+
lines = content.splitlines()
|
|
56
|
+
if line_no > len(lines):
|
|
57
|
+
return None
|
|
58
|
+
finding_idx = line_no - 1
|
|
59
|
+
start, end = _window_bounds(lines, finding_idx)
|
|
60
|
+
|
|
61
|
+
for i in range(start, end + 1):
|
|
62
|
+
for tok in _IDENT_RE.findall(lines[i]):
|
|
63
|
+
low = tok.lower()
|
|
64
|
+
if any(key in low for key in _OPT_IN_TRUST_TOKENS):
|
|
65
|
+
return tok
|
|
66
|
+
return None
|