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,211 @@
1
+ """Decide whether a source file is the app's own code or third-party SDK code.
2
+ Prefers an own-package allowlist from the manifest, falling back to a denylist of known SDK namespaces."""
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from typing import Optional
8
+
9
+ from .scope_config import ScopeConfig, segment_prefix_match
10
+
11
+ # jadx's manifest output path depends on the input container type.
12
+ _MANIFEST_RELATIVE_PATHS = (
13
+ os.path.join("resources", "AndroidManifest.xml"), # plain .apk
14
+ os.path.join("resources", "base", "manifest", "AndroidManifest.xml"), # .aab
15
+ )
16
+
17
+
18
+ def _find_decompiled_manifest_path(decompiled_root: str) -> Optional[str]:
19
+ for rel in _MANIFEST_RELATIVE_PATHS:
20
+ candidate = os.path.join(decompiled_root, rel)
21
+ if os.path.isfile(candidate):
22
+ return candidate
23
+ return None
24
+
25
+
26
+ def parse_manifest_package_attr(manifest_content: str) -> Optional[str]:
27
+ m = re.search(r'package\s*=\s*"([a-zA-Z0-9_.]+)"', manifest_content)
28
+ return m.group(1) if m else None
29
+
30
+
31
+ def detect_own_package(decompiled_root: str) -> str | None:
32
+ manifest_path = _find_decompiled_manifest_path(decompiled_root)
33
+ if not manifest_path:
34
+ return None
35
+ try:
36
+ with open(manifest_path, "r", encoding="utf-8", errors="ignore") as f:
37
+ head = f.read(4096)
38
+ except OSError:
39
+ return None
40
+ return parse_manifest_package_attr(head)
41
+
42
+
43
+ _COMPONENT_TAG_RE = re.compile(
44
+ r'<(?:activity|service|receiver|provider|application)\b[^>]*?'
45
+ r'android:name\s*=\s*"([^"]+)"',
46
+ re.DOTALL,
47
+ )
48
+
49
+ # Too generic to trust as a one-segment namespace root, so 2 segments are used.
50
+ _GENERIC_TLD_SEGMENTS = {"com", "org", "io", "net", "edu", "gov", "co"}
51
+
52
+
53
+ def _root_is_known_third_party(candidate: str) -> bool:
54
+ cand_path = candidate.replace(".", "/") + "/"
55
+ for p in THIRD_PARTY_PREFIXES:
56
+ p_path = p.replace(".", "/")
57
+ if cand_path.startswith(p_path) or p_path.startswith(cand_path):
58
+ return True
59
+ return False
60
+
61
+
62
+ def extract_component_package_roots(manifest_content: str, root_pkg: Optional[str] = None) -> set[str]:
63
+ roots: set[str] = set()
64
+ for m in _COMPONENT_TAG_RE.finditer(manifest_content):
65
+ name = m.group(1)
66
+ if name.startswith("."):
67
+ if not root_pkg:
68
+ continue
69
+ name = root_pkg + name
70
+ segments = [s for s in name.rsplit(".", 1)[0].split(".") if s]
71
+ if not segments:
72
+ continue
73
+ if segments[0].lower() in _GENERIC_TLD_SEGMENTS and len(segments) >= 2:
74
+ candidate = ".".join(segments[:2])
75
+ else:
76
+ candidate = segments[0]
77
+ if _root_is_known_third_party(candidate):
78
+ continue
79
+ roots.add(candidate)
80
+ return roots
81
+
82
+
83
+ def get_own_package_roots(decompiled_root: str) -> set[str]:
84
+ """The manifest's applicationId plus the root of every declared component."""
85
+ roots: set[str] = set()
86
+ root_pkg = detect_own_package(decompiled_root)
87
+ if root_pkg:
88
+ roots.add(root_pkg)
89
+
90
+ manifest_path = _find_decompiled_manifest_path(decompiled_root)
91
+ if not manifest_path:
92
+ return roots
93
+ try:
94
+ with open(manifest_path, "r", encoding="utf-8", errors="ignore") as f:
95
+ content = f.read()
96
+ except OSError:
97
+ return roots
98
+
99
+ roots |= extract_component_package_roots(content, root_pkg)
100
+ return roots
101
+
102
+
103
+ # Anchors for a file's package path: jadx uses 'sources/', Gradle 'src/<variant>/java|kotlin/'.
104
+ JADX_SOURCE_MARKERS = (r"sources/",)
105
+ GRADLE_SOURCE_MARKERS = (r"src/[^/]+/java/", r"src/[^/]+/kotlin/")
106
+
107
+
108
+ def _rightmost_marker_match(fp: str, markers) -> Optional["re.Match"]:
109
+ best = None
110
+ for marker in markers:
111
+ for m in re.finditer(marker, fp):
112
+ if best is None or m.start() > best.start():
113
+ best = m
114
+ return best
115
+
116
+
117
+ def is_own_package_path(file_path: str, own_roots, markers=JADX_SOURCE_MARKERS) -> bool:
118
+ """True if this source file lives under any of own_roots (a string or set)."""
119
+ if isinstance(own_roots, str):
120
+ own_roots = {own_roots}
121
+ fp = file_path.replace("\\", "/").lower()
122
+ for root in own_roots:
123
+ root_path = re.escape(root.replace(".", "/").lower())
124
+ for marker in markers:
125
+ if re.search(marker + root_path + "/", fp):
126
+ return True
127
+ return False
128
+
129
+
130
+ # Dotted form, matched against the path with '/' as the separator.
131
+ THIRD_PARTY_PREFIXES = (
132
+ "org.bouncycastle.", "org.spongycastle.", "com.google.crypto.tink.", "org.conscrypt.",
133
+ "org.acra.", "io.sentry.", "com.bugsnag.", "com.crashlytics.", "io.fabric.",
134
+ "com.microsoft.appcenter.", "com.microsoft.azure.",
135
+ "com.google.android.", "com.google.firebase.", "com.google.gson.",
136
+ "com.google.protobuf.", "com.google.common.", "com.google.api.", "com.google.auth.",
137
+ "com.google.maps.",
138
+ # An own applicationId under com.android is added by the manifest read, not this denylist.
139
+ "com.android.",
140
+ "com.huawei.",
141
+ "androidx.", "android.support.", "android.arch.",
142
+ "kotlin.", "kotlinx.",
143
+ "org.jetbrains.",
144
+ "okhttp3.", "okio.", "retrofit2.", "com.squareup.",
145
+ "io.reactivex.", "rx.",
146
+ "com.facebook.",
147
+ "dagger.", "hilt.", "javax.inject.", "butterknife.",
148
+ "timber.log.", "org.slf4j.", "ch.qos.logback.", "org.apache.log4j.",
149
+ "com.bumptech.glide.", "com.airbnb.lottie.",
150
+ "org.apache.",
151
+ "com.amazonaws.", "com.newrelic.", "com.appsflyer.", "com.adjust.",
152
+ "com.segment.", "com.mixpanel.", "com.amplitude.", "io.netty.", "io.grpc.",
153
+ "com.fasterxml.", "org.json.", "io.flutter.", "com.flurry.",
154
+ "io.ktor.", "ktor.",
155
+ "com.liulishuo.",
156
+ )
157
+
158
+ _THIRD_PARTY_SLASHED = tuple(p.replace(".", "/") for p in THIRD_PARTY_PREFIXES)
159
+
160
+
161
+ def is_third_party_path(file_path: str, markers=JADX_SOURCE_MARKERS) -> bool:
162
+ """True if this file lives under a known third-party SDK namespace (marker stripped first so a 'kotlin' source-root doesn't collide with the 'kotlin.' prefix)."""
163
+ fp = file_path.replace("\\", "/").lower()
164
+ for marker in markers:
165
+ m = re.search(marker, fp)
166
+ if m:
167
+ fp = fp[m.end():]
168
+ break
169
+ for prefix in _THIRD_PARTY_SLASHED:
170
+ if f"/{prefix}" in fp or fp.startswith(prefix):
171
+ return True
172
+ return False
173
+
174
+
175
+ def _file_package_path(file_path: str, markers=JADX_SOURCE_MARKERS) -> Optional[str]:
176
+ """The file's full dotted package path, or None if it matches no marker."""
177
+ fp = file_path.replace("\\", "/").lower()
178
+ m = _rightmost_marker_match(fp, markers)
179
+ if m is None:
180
+ return None
181
+ segments = [s for s in fp[m.end():].split("/") if s][:-1]
182
+ if not segments:
183
+ return None
184
+ return ".".join(segments)
185
+
186
+
187
+ def check_android_override(file_path: str, config: Optional[ScopeConfig], markers=JADX_SOURCE_MARKERS) -> Optional[str]:
188
+ """Return 'own', 'vendor', or None for this file, per .narvy-scope.yml."""
189
+ if not config:
190
+ return None
191
+ pkg_path = _file_package_path(file_path, markers=markers)
192
+ if pkg_path is None:
193
+ return None
194
+ if any(segment_prefix_match(e, pkg_path) for e in config.own):
195
+ return "own"
196
+ if any(segment_prefix_match(e, pkg_path) for e in config.vendor):
197
+ return "vendor"
198
+ return None
199
+
200
+
201
+ def resolve_file_scope(file_path: str, own_roots, override_config: Optional[ScopeConfig] = None,
202
+ markers=JADX_SOURCE_MARKERS) -> bool:
203
+ """True to scan as first-party, False to skip as third-party. A .narvy-scope.yml entry wins over own_roots and the denylist; pass GRADLE_SOURCE_MARKERS for a Gradle checkout."""
204
+ override = check_android_override(file_path, override_config, markers=markers)
205
+ if override == "own":
206
+ return True
207
+ if override == "vendor":
208
+ return False
209
+ if own_roots:
210
+ return file_path.endswith('.xml') or is_own_package_path(file_path, own_roots, markers=markers)
211
+ return not is_third_party_path(file_path, markers=markers)
narvy/uploader.py ADDED
@@ -0,0 +1,92 @@
1
+ import os
2
+
3
+ import requests
4
+
5
+ # NARVY_URL overrides the default API endpoint.
6
+ API_BASE = (os.environ.get("NARVY_URL") or "https://narvy.io").rstrip("/")
7
+
8
+
9
+ def _network_error(exc):
10
+ """Return the (status, data) shape callers handle; status 0 never matches a real HTTP code."""
11
+ return 0, {
12
+ "error": (
13
+ f"could not reach the Narvy server at {API_BASE} ({exc}). "
14
+ "Check your internet connection, VPN/proxy or firewall, then retry."
15
+ )
16
+ }
17
+
18
+
19
+ def _json_or_error(r):
20
+ try:
21
+ return r.status_code, r.json()
22
+ except ValueError:
23
+ return r.status_code, {"error": f"non-JSON response from server (HTTP {r.status_code})"}
24
+
25
+
26
+ def whoami(token):
27
+ try:
28
+ r = requests.get(
29
+ f"{API_BASE}/api/v1/whoami",
30
+ headers={"X-API-Token": token},
31
+ timeout=15,
32
+ )
33
+ except requests.exceptions.RequestException as e:
34
+ return _network_error(e)
35
+ return _json_or_error(r)
36
+
37
+
38
+ def upload_scan(apk_path, token, platform="android", analysis_type="sast", confirm_warning=False):
39
+ # confirm_warning proceeds past the API's 409 malware-triage warning.
40
+ data = {"platform": platform, "type": analysis_type}
41
+ if confirm_warning:
42
+ data["confirm_warning"] = "true"
43
+ try:
44
+ with open(apk_path, "rb") as f:
45
+ r = requests.post(
46
+ f"{API_BASE}/api/v1/analyze",
47
+ headers={"X-API-Token": token},
48
+ data=data,
49
+ files={"input_file": f},
50
+ timeout=300,
51
+ )
52
+ except requests.exceptions.RequestException as e:
53
+ return _network_error(e)
54
+ return _json_or_error(r)
55
+
56
+
57
+ def ingest_results(token, app_name, platform, scan_mode, findings, version=None):
58
+ """POST this scan's findings to /api/v1/ingest (no binary leaves this machine)."""
59
+ body = {
60
+ "app_name": app_name,
61
+ "platform": platform,
62
+ "scan_mode": scan_mode,
63
+ "version": version,
64
+ "findings": findings,
65
+ }
66
+ try:
67
+ r = requests.post(
68
+ f"{API_BASE}/api/v1/ingest",
69
+ headers={"X-API-Token": token, "Content-Type": "application/json"},
70
+ json=body,
71
+ timeout=120,
72
+ )
73
+ except requests.exceptions.RequestException as e:
74
+ return _network_error(e)
75
+ return _json_or_error(r)
76
+
77
+
78
+ def list_scans(token, limit=20, offset=0, status=None):
79
+ """List recent scans on the caller's account via GET /api/v1/scans."""
80
+ params = {"limit": limit, "offset": offset}
81
+ if status:
82
+ params["status"] = status
83
+ try:
84
+ r = requests.get(
85
+ f"{API_BASE}/api/v1/scans",
86
+ headers={"X-API-Token": token},
87
+ params=params,
88
+ timeout=15,
89
+ )
90
+ except requests.exceptions.RequestException as e:
91
+ return _network_error(e)
92
+ return _json_or_error(r)
@@ -0,0 +1,270 @@
1
+ """Usage-context classifier for the `android-weak-prng` rule (CWE-338): re-grade every
2
+ match into security (HIGH), non_security (INFO) or unknown (LOW) from file evidence. Nothing is dropped."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import Any, Dict, List, Optional, Tuple
8
+
9
+ RULE_ID = "android-weak-prng"
10
+
11
+ SEVERITY_SECURITY = "HIGH"
12
+ SEVERITY_UNKNOWN = "LOW"
13
+ SEVERITY_NON_SECURITY = "INFO"
14
+
15
+ _FALLBACK_BACK = 6
16
+ _FALLBACK_FWD = 12
17
+ _MAX_BLOCK_LINES = 120
18
+
19
+ # Platform class names, so these survive minification.
20
+ _SEC_API_RE = re.compile(
21
+ r"\b(?:"
22
+ r"SecretKeySpec|IvParameterSpec|PBEKeySpec|GCMParameterSpec|"
23
+ r"KeyGenerator|KeyPairGenerator|KeyGenParameterSpec|SecureRandom|"
24
+ r"Cipher\s*\.\s*getInstance|Mac\s*\.\s*getInstance|"
25
+ r"MessageDigest\s*\.\s*getInstance|Signature\s*\.\s*getInstance|"
26
+ r"KeyStore\s*\.\s*getInstance|PublicKeyCredential"
27
+ r")\b"
28
+ )
29
+
30
+ # Raw byte output is almost always key/nonce/IV/salt material; survives obfuscation.
31
+ _NEXT_BYTES_RE = re.compile(r"\.\s*nextBytes\s*\(")
32
+
33
+ # Strong identifiers only; 'key'/'iv'/'mac'/'auth'/'crypto'/'uuid' are too ambiguous.
34
+ _SEC_TOKEN_RE = re.compile(
35
+ r"(?<![a-z])(?:"
36
+ r"token|nonce|salt|otp|passwd|password|passphrase|secret|"
37
+ r"apikey|api_key|privatekey|private_key|keymaterial|key_material|"
38
+ r"seedbytes|challenge|credential|csrf|xsrf|verifier|"
39
+ r"sessionkey|session_key|authcode|auth_code|encrypt|decrypt|signing"
40
+ r")(?![a-z])",
41
+ re.IGNORECASE,
42
+ )
43
+
44
+ # Package layout usually survives obfuscation, so this holds on minified code.
45
+ _SEC_PATH_RE = re.compile(
46
+ r"(?:^|/)(?:auth|authentication|authenticator|identity|crypto|"
47
+ r"cryptography|security|keystore|keychain|credentials?|login|signin|"
48
+ r"session|oauth|jwt|fido|webauthn|password)(?:s)?(?:/|$)",
49
+ re.IGNORECASE,
50
+ )
51
+
52
+ # Language-level sink shapes, so these survive obfuscation too.
53
+ _NONSEC_SINK_RE = re.compile(
54
+ r"(?:"
55
+ r"Thread\s*\.\s*sleep\s*\(|"
56
+ r"\.\s*postDelayed\s*\(|"
57
+ r"\.\s*setDuration\s*\(|"
58
+ r"\.\s*setStartDelay\s*\(|"
59
+ r"\.\s*setPeriodic\s*\(|"
60
+ r"\.\s*setInexactRepeating\s*\(|"
61
+ r"\.\s*scheduleAtFixedRate\s*\(|"
62
+ r"\.\s*scheduleWithFixedDelay\s*\(|"
63
+ r"ValueAnimator|ObjectAnimator|PropertyValuesHolder|AnimatorSet|"
64
+ r"\bPaint\b|\bCanvas\b|\.\s*setColor\s*\(|\.\s*setAlpha\s*\(|"
65
+ r"\bJobInfo\s*\.\s*Builder\b|\bAlarmManager\b|"
66
+ r"\bColor\s*\.\s*(?:HSVToColor|argb|rgb|parseColor)\s*\(|"
67
+ r"\.\s*setBackgroundColor\s*\("
68
+ r")"
69
+ )
70
+
71
+ _SAMPLING_CMP_RE = re.compile(
72
+ r"(?:Math\s*\.\s*random\s*\(\s*\)|\.\s*next(?:Int|Double|Float)\s*\([^;]*\))"
73
+ r"[^;]*?[<>]=?"
74
+ )
75
+
76
+ _NONSEC_PATH_RE = re.compile(
77
+ r"(?:metric|metrics|analytic|analytics|telemetry|tracking|clickstream|"
78
+ r"nexus|minerva|weblab|logging|logger|sampling|sample|experiment|canary|"
79
+ r"abtest|ab_test|animation|animator|particle|firefly|backoff|retry|"
80
+ r"throttle|ratelimiter|rate_limiter|jitter|splash|onboarding)",
81
+ re.IGNORECASE,
82
+ )
83
+
84
+ _NONSEC_TOKEN_RE = re.compile(
85
+ r"(?<![a-z])(?:"
86
+ r"jitter|backoff|back_off|retry|retries|throttle|samplerate|sample_rate|"
87
+ r"sampling|analytics|telemetry|clickstream|animator|animation|"
88
+ r"interpolator|dialup|bucket|placeholder|shuffle|avatar|confetti|"
89
+ r"filename|file_name|tmpfile|tmp_file|tempfile|temp_file|"
90
+ r"displayid|display_id|nickname"
91
+ r")(?![a-z])",
92
+ re.IGNORECASE,
93
+ )
94
+
95
+ _ASSIGN_RE = re.compile(
96
+ r"(?:^|[^\w.])(?:(?:private|public|protected|static|final|volatile|"
97
+ r"transient)\s+)*(?:java\.util\.)?Random\s+(\w+)\s*="
98
+ )
99
+ _FIELD_ASSIGN_RE = re.compile(r"this\s*\.\s*(\w+)\s*=\s*new\s+(?:java\.util\.)?Random\s*\(")
100
+
101
+
102
+ def _enclosing_block(lines: List[str], line_idx: int) -> Tuple[int, int]:
103
+ """Best-effort enclosing method body for a 0-based line index."""
104
+ start = max(0, line_idx - _FALLBACK_BACK)
105
+ cur_indent = len(lines[line_idx]) - len(lines[line_idx].lstrip())
106
+ for i in range(line_idx, max(-1, line_idx - _MAX_BLOCK_LINES), -1):
107
+ stripped = lines[i].strip()
108
+ if not stripped:
109
+ continue
110
+ indent = len(lines[i]) - len(lines[i].lstrip())
111
+ if indent < cur_indent and stripped.endswith("{"):
112
+ start = i
113
+ break
114
+
115
+ depth = 0
116
+ end = min(len(lines) - 1, line_idx + _FALLBACK_FWD)
117
+ opened = False
118
+ for i in range(start, min(len(lines), start + _MAX_BLOCK_LINES)):
119
+ depth += lines[i].count("{") - lines[i].count("}")
120
+ if lines[i].count("{"):
121
+ opened = True
122
+ if opened and depth <= 0 and i >= line_idx:
123
+ end = i
124
+ break
125
+ return start, max(end, line_idx)
126
+
127
+
128
+ def _assigned_name(line: str) -> Optional[str]:
129
+ m = _FIELD_ASSIGN_RE.search(line)
130
+ if m:
131
+ return m.group(1)
132
+ m = _ASSIGN_RE.search(line)
133
+ if m:
134
+ return m.group(1)
135
+ return None
136
+
137
+
138
+ def _usage_lines(lines: List[str], name: str, decl_idx: int) -> List[str]:
139
+ """Every line in the file that calls a method on `name`."""
140
+ use_re = re.compile(r"(?<![\w.])(?:this\s*\.\s*)?" + re.escape(name) + r"\s*\.\s*\w+\s*\(")
141
+ out = []
142
+ for i, ln in enumerate(lines):
143
+ if i == decl_idx:
144
+ continue
145
+ if use_re.search(ln):
146
+ out.append(ln)
147
+ if len(out) >= 40:
148
+ break
149
+ return out
150
+
151
+
152
+ def classify(content: str, line: int, file_path: str = "") -> Dict[str, Any]:
153
+ """Grade one weak-PRNG hit. `line` is 1-based, per semgrep."""
154
+ lines = content.split("\n")
155
+ idx = max(0, min(len(lines) - 1, line - 1))
156
+
157
+ start, end = _enclosing_block(lines, idx)
158
+ block = "\n".join(lines[start:end + 1])
159
+
160
+ name = _assigned_name(lines[idx])
161
+ uses = _usage_lines(lines, name, idx) if name else []
162
+ scan = block + "\n" + "\n".join(uses)
163
+
164
+ path_norm = (file_path or "").replace("\\", "/")
165
+
166
+ # Evidence precedence: what the value flows to outranks where the file lives.
167
+ if _NEXT_BYTES_RE.search(scan):
168
+ return _res("security", "random value read as raw bytes (nextBytes) - "
169
+ "key/nonce/credential material shape")
170
+ m = _SEC_API_RE.search(scan)
171
+ if m:
172
+ return _res("security", f"used in the same block as a crypto/security API ({m.group(0)})")
173
+ m = _SEC_TOKEN_RE.search(scan)
174
+ if m:
175
+ return _res("security", f"security-material identifier near the call site ('{m.group(0)}')")
176
+
177
+ m = _NONSEC_SINK_RE.search(scan)
178
+ if m:
179
+ return _res("non_security", f"value flows to a non-security sink ({m.group(0).strip()})")
180
+ if _SAMPLING_CMP_RE.search(scan):
181
+ return _res("non_security", "value compared against a rate threshold (sampling / coin flip)")
182
+
183
+ m = _SEC_PATH_RE.search(path_norm)
184
+ if m:
185
+ return _res("security", f"call site is inside a security component path ('{m.group(0).strip('/')}')")
186
+
187
+ m = _NONSEC_TOKEN_RE.search(scan)
188
+ if m:
189
+ return _res("non_security", f"non-security usage keyword near the call site ('{m.group(0)}')")
190
+ m = _NONSEC_PATH_RE.search(path_norm)
191
+ if m:
192
+ return _res("non_security", f"call site is inside a non-security component path ('{m.group(0)}')")
193
+
194
+ return _res("unknown", "no evidence found that the value is (or is not) used as security material")
195
+
196
+
197
+ def _res(context: str, evidence: str) -> Dict[str, Any]:
198
+ sev = {
199
+ "security": SEVERITY_SECURITY,
200
+ "non_security": SEVERITY_NON_SECURITY,
201
+ "unknown": SEVERITY_UNKNOWN,
202
+ }[context]
203
+ return {"context": context, "severity": sev, "evidence": evidence}
204
+
205
+
206
+ _MESSAGES = {
207
+ "security": (
208
+ "java.util.Random / Math.random() is not cryptographically secure, and "
209
+ "the value produced here reaches security-sensitive material. Use "
210
+ "java.security.SecureRandom."
211
+ ),
212
+ "non_security": (
213
+ "java.util.Random / Math.random() used in what looks like a "
214
+ "NON-security context. Kept for completeness at Info severity - "
215
+ "review only if this value ever becomes security-relevant. Use "
216
+ "SecureRandom for tokens, keys, nonces, salts and session ids."
217
+ ),
218
+ "unknown": (
219
+ "java.util.Random / Math.random() is not cryptographically secure. No "
220
+ "evidence was found that this value is used as security material, and "
221
+ "none that it isn't - graded Low pending review. Use SecureRandom for "
222
+ "tokens, keys, nonces, salts and session ids."
223
+ ),
224
+ }
225
+
226
+
227
+ def apply(findings: List[Dict[str, Any]], source_dir: str) -> List[Dict[str, Any]]:
228
+ """Re-grade every android-weak-prng finding in place (unreadable file keeps original severity)."""
229
+ import os
230
+
231
+ cache: Dict[str, Optional[str]] = {}
232
+ for f in findings:
233
+ if f.get("rule_id") != RULE_ID:
234
+ continue
235
+ rel = f.get("file_path") or ""
236
+ abs_path = os.path.join(source_dir, rel)
237
+ if abs_path not in cache:
238
+ try:
239
+ with open(abs_path, "r", encoding="utf-8", errors="ignore") as fh:
240
+ cache[abs_path] = fh.read()
241
+ except OSError:
242
+ cache[abs_path] = None
243
+ content = cache[abs_path]
244
+ if content is None:
245
+ f.setdefault("details", {})["prng_context"] = "unreadable"
246
+ continue
247
+
248
+ verdict = classify(content, int(f.get("line") or 1), rel)
249
+ f["original_severity"] = f.get("severity")
250
+ f["severity"] = verdict["severity"]
251
+ details = f.setdefault("details", {})
252
+ details["prng_context"] = verdict["context"]
253
+ details["prng_context_evidence"] = verdict["evidence"]
254
+ details["description"] = _MESSAGES[verdict["context"]]
255
+ details["recommendation"] = _MESSAGES[verdict["context"]]
256
+ f["name"] = _MESSAGES[verdict["context"]][:120]
257
+ return findings
258
+
259
+
260
+ def dedupe_by_location(findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
261
+ """Collapse identical (rule_id, file_path, line) findings."""
262
+ seen = set()
263
+ out = []
264
+ for f in findings:
265
+ key = (f.get("rule_id"), f.get("file_path"), f.get("line"))
266
+ if key in seen:
267
+ continue
268
+ seen.add(key)
269
+ out.append(f)
270
+ return out
narvy/web/__init__.py ADDED
File without changes
@@ -0,0 +1,105 @@
1
+ """Resolve a `nuclei` binary, downloading a pinned release if none on PATH."""
2
+
3
+ import logging
4
+ import os
5
+ import platform
6
+ import shutil
7
+ import stat
8
+ import subprocess
9
+ import zipfile
10
+
11
+ import requests
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Pinned by hand: a floating version would change scan behaviour between runs.
16
+ NUCLEI_VERSION = "3.5.1"
17
+ TOOLS_DIR = os.path.expanduser("~/.narvy/tools")
18
+ NUCLEI_HOME = os.path.join(TOOLS_DIR, f"nuclei-{NUCLEI_VERSION}")
19
+ _IS_WINDOWS = platform.system() == "Windows"
20
+ _IS_MAC = platform.system() == "Darwin"
21
+ NUCLEI_BIN = os.path.join(NUCLEI_HOME, "nuclei.exe" if _IS_WINDOWS else "nuclei")
22
+
23
+
24
+ def _release_asset_name() -> str:
25
+ """Release zip filename: nuclei_<version>_<os>_<arch>.zip (os linux/macOS/windows)."""
26
+ machine = platform.machine().lower()
27
+ if machine in ("x86_64", "amd64"):
28
+ arch = "amd64"
29
+ elif machine in ("aarch64", "arm64"):
30
+ arch = "arm64"
31
+ elif machine in ("armv7l", "armv6l", "arm"):
32
+ arch = "arm"
33
+ elif machine in ("i386", "i686", "x86"):
34
+ arch = "386"
35
+ else:
36
+ raise RuntimeError(
37
+ f"No known nuclei release for CPU architecture {machine!r}. "
38
+ "Install nuclei yourself and put it on PATH: "
39
+ "https://github.com/projectdiscovery/nuclei/releases"
40
+ )
41
+
42
+ if _IS_WINDOWS:
43
+ os_name = "windows"
44
+ elif _IS_MAC:
45
+ os_name = "macOS"
46
+ else:
47
+ os_name = "linux"
48
+
49
+ return f"nuclei_{NUCLEI_VERSION}_{os_name}_{arch}.zip"
50
+
51
+
52
+ def _download_nuclei():
53
+ os.makedirs(TOOLS_DIR, exist_ok=True)
54
+ asset_name = _release_asset_name()
55
+ url = (
56
+ f"https://github.com/projectdiscovery/nuclei/releases/download/"
57
+ f"v{NUCLEI_VERSION}/{asset_name}"
58
+ )
59
+ zip_path = os.path.join(TOOLS_DIR, asset_name)
60
+ logger.info(f"nuclei not found on PATH - downloading {url}")
61
+ resp = requests.get(url, stream=True, timeout=120, allow_redirects=True)
62
+ resp.raise_for_status()
63
+ with open(zip_path, "wb") as f:
64
+ for chunk in resp.iter_content(chunk_size=1 << 20):
65
+ f.write(chunk)
66
+ os.makedirs(NUCLEI_HOME, exist_ok=True)
67
+ with zipfile.ZipFile(zip_path) as z:
68
+ z.extractall(NUCLEI_HOME)
69
+ os.remove(zip_path)
70
+ if not os.path.exists(NUCLEI_BIN):
71
+ raise RuntimeError(
72
+ f"nuclei archive extracted to {NUCLEI_HOME} but no binary found "
73
+ f"at the expected path {NUCLEI_BIN} - unexpected archive layout."
74
+ )
75
+ if not _IS_WINDOWS:
76
+ st = os.stat(NUCLEI_BIN)
77
+ os.chmod(NUCLEI_BIN, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
78
+ logger.info(f"nuclei {NUCLEI_VERSION} installed to {NUCLEI_BIN}")
79
+
80
+
81
+ def resolve_nuclei_binary() -> str:
82
+ """Prefer nuclei on PATH, else download the pinned release to ~/.narvy/tools."""
83
+ on_path = shutil.which("nuclei")
84
+ if on_path:
85
+ return on_path
86
+ if os.path.exists(NUCLEI_BIN):
87
+ return NUCLEI_BIN
88
+ _download_nuclei()
89
+ return NUCLEI_BIN
90
+
91
+
92
+ def verify_nuclei(nuclei_bin: str) -> str:
93
+ try:
94
+ result = subprocess.run(
95
+ [nuclei_bin, "-version"], capture_output=True, text=True, timeout=15
96
+ )
97
+ except Exception as e:
98
+ raise RuntimeError(f"Could not run nuclei ({nuclei_bin}): {e}")
99
+ # nuclei prints its version banner to stderr and exits 0.
100
+ out = (result.stdout or "") + (result.stderr or "")
101
+ if result.returncode != 0 or not out.strip():
102
+ raise RuntimeError(
103
+ f"nuclei ({nuclei_bin}) did not run cleanly (exit {result.returncode}): {out[:300]}"
104
+ )
105
+ return out.strip()