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
narvy/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Android source-repo (Gradle/Java/Kotlin) SAST entry point.
|
|
2
|
+
|
|
3
|
+
Public API: analyze_source(source_dir, override_config=None) -> dict.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
10
|
+
|
|
11
|
+
from narvy.rule_engine import load_rules_from_dir, run_rules_on_file
|
|
12
|
+
from narvy import semgrep_engine
|
|
13
|
+
from narvy import crypto_taint_lite
|
|
14
|
+
from narvy.scope_config import ScopeConfig
|
|
15
|
+
from narvy.third_party_filter import (
|
|
16
|
+
GRADLE_SOURCE_MARKERS,
|
|
17
|
+
extract_component_package_roots,
|
|
18
|
+
parse_manifest_package_attr,
|
|
19
|
+
resolve_file_scope,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_HERE = os.path.dirname(__file__)
|
|
23
|
+
ANDROID_RULES_DIR = os.path.join(_HERE, "..", "rules", "android")
|
|
24
|
+
|
|
25
|
+
_MANIFEST_FILENAME = "AndroidManifest.xml"
|
|
26
|
+
_GRADLE_BUILD_FILENAMES = ("build.gradle.kts", "build.gradle")
|
|
27
|
+
_SRC_CODE_EXTS = (".java", ".kt")
|
|
28
|
+
|
|
29
|
+
# Build output, tool caches and VCS dirs, pruned from every walk here.
|
|
30
|
+
_SKIP_DIR_NAMES = frozenset({
|
|
31
|
+
"build", ".gradle", ".idea", ".git", "out", ".cxx", "node_modules",
|
|
32
|
+
".vscode", ".vs", "captures",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
_NAMESPACE_RE = re.compile(r'\bnamespace\s*=?\s*["\']([\w.]+)["\']')
|
|
36
|
+
_APPLICATION_ID_RE = re.compile(r'\bapplicationId\s*=?\s*["\']([\w.]+)["\']')
|
|
37
|
+
|
|
38
|
+
# A module may use either source-root name, or both.
|
|
39
|
+
_SRC_LEAF_DIRS = ("java", "kotlin")
|
|
40
|
+
_MAX_INFER_DEPTH = 12
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _should_prune_dir(name: str) -> bool:
|
|
44
|
+
return name in _SKIP_DIR_NAMES or name.startswith(".")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find_android_manifests(source_dir: str) -> List[str]:
|
|
48
|
+
manifests = []
|
|
49
|
+
for root, dirs, files in os.walk(source_dir):
|
|
50
|
+
dirs[:] = [d for d in dirs if not _should_prune_dir(d)]
|
|
51
|
+
if _MANIFEST_FILENAME in files:
|
|
52
|
+
manifests.append(os.path.join(root, _MANIFEST_FILENAME))
|
|
53
|
+
return manifests
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _module_dir_for_manifest(manifest_path: str) -> Optional[str]:
|
|
57
|
+
"""The module dir for a `<module>/src/<variant>/AndroidManifest.xml` path."""
|
|
58
|
+
main_dir = os.path.dirname(manifest_path)
|
|
59
|
+
src_dir = os.path.dirname(main_dir)
|
|
60
|
+
if os.path.basename(src_dir) == "src":
|
|
61
|
+
return os.path.dirname(src_dir)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_gradle_namespace_and_app_id(module_dir: str) -> Tuple[Optional[str], Optional[str]]:
|
|
66
|
+
"""Read `namespace` and `applicationId` from a module's build.gradle(.kts) (regex; computed values yield None)."""
|
|
67
|
+
namespace, app_id = None, None
|
|
68
|
+
for fname in _GRADLE_BUILD_FILENAMES:
|
|
69
|
+
fpath = os.path.join(module_dir, fname)
|
|
70
|
+
if not os.path.isfile(fpath):
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
with open(fpath, "r", encoding="utf-8", errors="ignore") as f:
|
|
74
|
+
content = f.read()
|
|
75
|
+
except OSError:
|
|
76
|
+
continue
|
|
77
|
+
if namespace is None:
|
|
78
|
+
m = _NAMESPACE_RE.search(content)
|
|
79
|
+
if m:
|
|
80
|
+
namespace = m.group(1)
|
|
81
|
+
if app_id is None:
|
|
82
|
+
m = _APPLICATION_ID_RE.search(content)
|
|
83
|
+
if m:
|
|
84
|
+
app_id = m.group(1)
|
|
85
|
+
return namespace, app_id
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _infer_root_packages_from_dir(main_dir: str) -> set:
|
|
89
|
+
"""Infer a module's top package from its source dir chain of single-child directories."""
|
|
90
|
+
roots = set()
|
|
91
|
+
for leaf in _SRC_LEAF_DIRS:
|
|
92
|
+
src_root = os.path.join(main_dir, leaf)
|
|
93
|
+
if not os.path.isdir(src_root):
|
|
94
|
+
continue
|
|
95
|
+
segments: List[str] = []
|
|
96
|
+
current = src_root
|
|
97
|
+
for _ in range(_MAX_INFER_DEPTH):
|
|
98
|
+
try:
|
|
99
|
+
entries = os.listdir(current)
|
|
100
|
+
except OSError:
|
|
101
|
+
break
|
|
102
|
+
subdirs = [e for e in entries if not e.startswith(".")
|
|
103
|
+
and os.path.isdir(os.path.join(current, e))]
|
|
104
|
+
has_files = any(not e.startswith(".") and os.path.isfile(os.path.join(current, e))
|
|
105
|
+
for e in entries)
|
|
106
|
+
if has_files or len(subdirs) != 1:
|
|
107
|
+
break
|
|
108
|
+
segments.append(subdirs[0])
|
|
109
|
+
current = os.path.join(current, subdirs[0])
|
|
110
|
+
if segments:
|
|
111
|
+
roots.add(".".join(segments))
|
|
112
|
+
return roots
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def get_own_package_roots_gradle(manifest_paths: List[str]) -> set:
|
|
116
|
+
all_roots = set()
|
|
117
|
+
for manifest_path in manifest_paths:
|
|
118
|
+
main_dir = os.path.dirname(manifest_path)
|
|
119
|
+
try:
|
|
120
|
+
with open(manifest_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
121
|
+
content = f.read()
|
|
122
|
+
except OSError:
|
|
123
|
+
content = ""
|
|
124
|
+
|
|
125
|
+
module_dir = _module_dir_for_manifest(manifest_path)
|
|
126
|
+
namespace, app_id = (None, None)
|
|
127
|
+
if module_dir:
|
|
128
|
+
namespace, app_id = _read_gradle_namespace_and_app_id(module_dir)
|
|
129
|
+
manifest_pkg = parse_manifest_package_attr(content)
|
|
130
|
+
dir_roots = _infer_root_packages_from_dir(main_dir)
|
|
131
|
+
|
|
132
|
+
# Anchor for a component's relative android:name=".Foo".
|
|
133
|
+
anchor = namespace or manifest_pkg or (sorted(dir_roots)[0] if dir_roots else None)
|
|
134
|
+
|
|
135
|
+
module_roots = set(dir_roots)
|
|
136
|
+
for candidate in (namespace, manifest_pkg, app_id):
|
|
137
|
+
if candidate:
|
|
138
|
+
module_roots.add(candidate)
|
|
139
|
+
module_roots |= extract_component_package_roots(content, anchor)
|
|
140
|
+
|
|
141
|
+
all_roots |= module_roots
|
|
142
|
+
return all_roots
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _collect_source_files(source_dir: str) -> Tuple[List[str], List[str]]:
|
|
146
|
+
code_files: List[str] = []
|
|
147
|
+
xml_files: List[str] = []
|
|
148
|
+
for root, dirs, files in os.walk(source_dir):
|
|
149
|
+
dirs[:] = [d for d in dirs if not _should_prune_dir(d)]
|
|
150
|
+
for fname in files:
|
|
151
|
+
if fname.endswith(_SRC_CODE_EXTS):
|
|
152
|
+
code_files.append(os.path.join(root, fname))
|
|
153
|
+
elif fname.endswith(".xml"):
|
|
154
|
+
xml_files.append(os.path.join(root, fname))
|
|
155
|
+
return code_files, xml_files
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# The '*' matches any source-set variant, not just 'main'.
|
|
159
|
+
_SEMGREP_INCLUDE_PREFIXES = ["src/*/java/", "src/*/kotlin/"]
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def analyze_source(source_dir: str, override_config: Optional[ScopeConfig] = None) -> Dict[str, Any]:
|
|
163
|
+
"""Run Android source-mode SAST against a Gradle/Java/Kotlin checkout. Read-only."""
|
|
164
|
+
empty_stats = {
|
|
165
|
+
"manifests_found": 0, "own_roots": [], "code_files_scanned": 0,
|
|
166
|
+
"xml_files_scanned": 0, "skipped_third_party": 0,
|
|
167
|
+
}
|
|
168
|
+
if not source_dir or not os.path.isdir(source_dir):
|
|
169
|
+
return {
|
|
170
|
+
"ok": False,
|
|
171
|
+
"error": f"Not a directory: {source_dir}",
|
|
172
|
+
"findings": [],
|
|
173
|
+
"rule_defs": [],
|
|
174
|
+
"notes": [],
|
|
175
|
+
**empty_stats,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
notes: List[str] = []
|
|
179
|
+
all_findings: List[Dict[str, Any]] = []
|
|
180
|
+
|
|
181
|
+
manifest_paths = sorted(find_android_manifests(source_dir))
|
|
182
|
+
own_roots = get_own_package_roots_gradle(manifest_paths)
|
|
183
|
+
|
|
184
|
+
if not manifest_paths:
|
|
185
|
+
notes.append(
|
|
186
|
+
"No AndroidManifest.xml found anywhere in this tree - this looked "
|
|
187
|
+
"like an Android Gradle project (an Android Gradle plugin id / "
|
|
188
|
+
"`android { }` block / AndroidX flag was found) but no module's "
|
|
189
|
+
"own package could be confirmed. "
|
|
190
|
+
"Falling back to the known-SDK denylist instead of an own-package "
|
|
191
|
+
"allowlist, which is less precise (may include some third-party "
|
|
192
|
+
"code, should not miss any of your own)."
|
|
193
|
+
)
|
|
194
|
+
elif not own_roots:
|
|
195
|
+
notes.append(
|
|
196
|
+
f"Found {len(manifest_paths)} AndroidManifest.xml file(s) but "
|
|
197
|
+
"could not determine any module's own package (no `namespace` in "
|
|
198
|
+
"build.gradle, no manifest package= attribute, no source files "
|
|
199
|
+
"under src/main/java or src/main/kotlin). Falling back to the "
|
|
200
|
+
"known-SDK denylist instead of an own-package allowlist."
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
code_files, xml_files = _collect_source_files(source_dir)
|
|
204
|
+
all_source_files = code_files + xml_files
|
|
205
|
+
|
|
206
|
+
source_files = [
|
|
207
|
+
f for f in all_source_files
|
|
208
|
+
if resolve_file_scope(f, own_roots, override_config, markers=GRADLE_SOURCE_MARKERS)
|
|
209
|
+
]
|
|
210
|
+
skipped_third_party = len(all_source_files) - len(source_files)
|
|
211
|
+
|
|
212
|
+
rules = load_rules_from_dir(ANDROID_RULES_DIR)
|
|
213
|
+
code_files_scanned = 0
|
|
214
|
+
xml_files_scanned = 0
|
|
215
|
+
for file_path in source_files:
|
|
216
|
+
rel_path = os.path.relpath(file_path, source_dir)
|
|
217
|
+
findings_in_file = run_rules_on_file(file_path, rules)
|
|
218
|
+
for finding in findings_in_file:
|
|
219
|
+
finding["file_path"] = rel_path
|
|
220
|
+
finding["engine"] = "regex"
|
|
221
|
+
all_findings.extend(findings_in_file)
|
|
222
|
+
|
|
223
|
+
if file_path.endswith(_SRC_CODE_EXTS):
|
|
224
|
+
code_files_scanned += 1
|
|
225
|
+
try:
|
|
226
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as fh:
|
|
227
|
+
taint_findings = crypto_taint_lite.check_file(fh.read())
|
|
228
|
+
except OSError:
|
|
229
|
+
taint_findings = []
|
|
230
|
+
for finding in taint_findings:
|
|
231
|
+
finding["file_path"] = rel_path
|
|
232
|
+
all_findings.extend(taint_findings)
|
|
233
|
+
else:
|
|
234
|
+
xml_files_scanned += 1
|
|
235
|
+
|
|
236
|
+
semgrep_rule_defs: List[Dict[str, Any]] = []
|
|
237
|
+
if semgrep_engine.is_available():
|
|
238
|
+
semgrep_findings = semgrep_engine.run_semgrep(
|
|
239
|
+
source_dir, own_roots=own_roots, override_config=override_config,
|
|
240
|
+
include_path_prefixes=_SEMGREP_INCLUDE_PREFIXES,
|
|
241
|
+
)
|
|
242
|
+
if semgrep_findings:
|
|
243
|
+
all_findings.extend(semgrep_findings)
|
|
244
|
+
# A timed-out semgrep pass returns [], which must not read as clean.
|
|
245
|
+
_sg_note = semgrep_engine.degraded_coverage_note()
|
|
246
|
+
if _sg_note:
|
|
247
|
+
notes.append(_sg_note)
|
|
248
|
+
semgrep_rule_defs = semgrep_engine.load_rule_defs()
|
|
249
|
+
else:
|
|
250
|
+
notes.append(
|
|
251
|
+
"semgrep not found - install with `pip install semgrep` for "
|
|
252
|
+
"deeper structural detection. Regex rules still ran."
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
rule_defs = list(rules) + semgrep_rule_defs
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
"ok": True,
|
|
259
|
+
"error": None,
|
|
260
|
+
"findings": all_findings,
|
|
261
|
+
"rule_defs": rule_defs,
|
|
262
|
+
"manifests_found": len(manifest_paths),
|
|
263
|
+
"own_roots": sorted(own_roots),
|
|
264
|
+
"code_files_scanned": code_files_scanned,
|
|
265
|
+
"xml_files_scanned": xml_files_scanned,
|
|
266
|
+
"skipped_third_party": skipped_third_party,
|
|
267
|
+
"notes": notes,
|
|
268
|
+
}
|