narvy-cli 1.1.1__tar.gz → 1.1.3__tar.gz

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_cli-1.1.1 → narvy_cli-1.1.3}/CHANGELOG.md +84 -0
  2. {narvy_cli-1.1.1/narvy_cli.egg-info → narvy_cli-1.1.3}/PKG-INFO +79 -4
  3. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/README.md +78 -3
  4. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/__init__.py +1 -1
  5. narvy_cli-1.1.3/narvy/__main__.py +30 -0
  6. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/apk_memory_preflight.py +78 -16
  7. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/decompiler.py +48 -15
  8. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/doctor.py +1 -1
  9. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/host/lynis_bootstrap.py +1 -1
  10. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/host/ssh_exec.py +2 -2
  11. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/ios/binary_analyzer.py +2 -2
  12. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/main.py +21 -3
  13. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/native_hardening.py +1 -1
  14. narvy_cli-1.1.3/narvy/pathhint.py +98 -0
  15. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/proc.py +4 -0
  16. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/web/go.yml +132 -50
  17. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/web/java.yml +20 -0
  18. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/web/javascript.yml +597 -270
  19. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/web/php.yml +108 -32
  20. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/web/python.yml +108 -25
  21. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/web/ruby.yml +89 -77
  22. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/web/secrets.yml +6 -4
  23. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/sca/web_deps.py +98 -16
  24. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/semgrep_engine.py +112 -22
  25. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/web/nuclei_binary.py +1 -1
  26. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/web/scanner.py +1 -1
  27. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/web/source_analyzer.py +74 -13
  28. {narvy_cli-1.1.1 → narvy_cli-1.1.3/narvy_cli.egg-info}/PKG-INFO +79 -4
  29. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy_cli.egg-info/SOURCES.txt +2 -0
  30. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/LICENSE +0 -0
  31. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/MANIFEST.in +0 -0
  32. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/SECURITY.md +0 -0
  33. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/android/__init__.py +0 -0
  34. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/android/source_analyzer.py +0 -0
  35. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/android/split_bundle.py +0 -0
  36. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/android_rule_context.py +0 -0
  37. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/auth.py +0 -0
  38. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/ci_templates/bitbucket.yml +0 -0
  39. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/ci_templates/github.yml +0 -0
  40. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/ci_templates/gitlab.yml +0 -0
  41. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/cloud/__init__.py +0 -0
  42. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/cloud/aws_scan.py +0 -0
  43. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/comment_filter.py +0 -0
  44. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/crypto_taint_lite.py +0 -0
  45. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/host/__init__.py +0 -0
  46. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/host/audit.py +0 -0
  47. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/host/host_knowledge.py +0 -0
  48. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/host/lynis_parser.py +0 -0
  49. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/host/narvy_checks.py +0 -0
  50. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/host/report.py +0 -0
  51. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/ios/__init__.py +0 -0
  52. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/ios/plist_checks.py +0 -0
  53. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/ios/source_analyzer.py +0 -0
  54. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/ios/third_party_filter.py +0 -0
  55. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/ios/trust_all_context.py +0 -0
  56. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/own_reports.py +0 -0
  57. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/reporter.py +0 -0
  58. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rule_engine.py +0 -0
  59. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/android/config.yml +0 -0
  60. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/android/crypto.yml +0 -0
  61. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/android/secrets.yml +0 -0
  62. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/android/storage.yml +0 -0
  63. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/android/webview.yml +0 -0
  64. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/android.yml +0 -0
  65. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/ios/objc/crypto.yml +0 -0
  66. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/ios/objc/network.yml +0 -0
  67. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/ios/objc/secrets.yml +0 -0
  68. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/ios/objc/storage.yml +0 -0
  69. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/ios/objc/webview.yml +0 -0
  70. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/ios_swift.yml +0 -0
  71. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/web/kotlin.yml +0 -0
  72. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/web/rust.yml +0 -0
  73. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/rules/web/secrets_supplement.yml +0 -0
  74. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/sca/__init__.py +0 -0
  75. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/sca/android_deps.py +0 -0
  76. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/sca/ios_deps.py +0 -0
  77. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/sca/osv_client.py +0 -0
  78. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/scope_config.py +0 -0
  79. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/stack_protector_evidence.py +0 -0
  80. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/telemetry.py +0 -0
  81. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/third_party_filter.py +0 -0
  82. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/uploader.py +0 -0
  83. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/weak_prng_context.py +0 -0
  84. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/web/__init__.py +0 -0
  85. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/web/scan_blocklist.py +0 -0
  86. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy/web/ssrf_guard.py +0 -0
  87. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy_cli.egg-info/dependency_links.txt +0 -0
  88. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy_cli.egg-info/entry_points.txt +0 -0
  89. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy_cli.egg-info/requires.txt +0 -0
  90. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/narvy_cli.egg-info/top_level.txt +0 -0
  91. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/pyproject.toml +0 -0
  92. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/setup.cfg +0 -0
  93. {narvy_cli-1.1.1 → narvy_cli-1.1.3}/setup.py +0 -0
@@ -5,6 +5,90 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.3]
9
+
10
+ ### Fixed
11
+
12
+ - APK scans were refused before decompiling on machines with less free
13
+ memory than `--max-mem` needs (about 1.3x the heap plus 1 GB), even for
14
+ small apps. On an 8 GB laptop at the default `--max-mem 4g` that was
15
+ nearly every app, and on macOS the free-memory reading was low by
16
+ several GB. `--max-mem` is a ceiling: when it does not fit but a smaller
17
+ heap still covers the app's estimated need by 1.5x, jadx now runs with
18
+ that smaller heap and the scan says so. A machine that cannot hold even
19
+ that is still refused, with the same advice as before.
20
+ - macOS: available memory comes from the kernel's own figure
21
+ (`kern.memorystatus_level`, what `memory_pressure` reports), which counts
22
+ memory the compressor can reclaim. The `vm_stat` page count is the
23
+ fallback and now includes speculative pages.
24
+ - APK scans: a structural (Semgrep) pass that failed was reported as
25
+ complete with 0 findings when semgrep exited with an error but still
26
+ printed JSON. It is now reported as incomplete with the error, after one
27
+ retry with a single worker. A run whose path filters matched none of the
28
+ app's own source files is reported the same way instead of as a clean
29
+ pass. Rule ids no longer depend on where the package is installed.
30
+
31
+ ### Changed
32
+
33
+ - Platform CI: the check removes the user Scripts/bin directory from PATH
34
+ before running scans (hosted runners ship with it on PATH, a new user's
35
+ shell does not), and prints the APK scan output on success so the
36
+ structural-pass status is in the log.
37
+
38
+ ## [1.1.2]
39
+
40
+ ### Added
41
+
42
+ - `python -m narvy` (`py -m narvy` on Windows) runs the CLI. It works when
43
+ pip put the `narvy` command in a directory that is not on PATH, the
44
+ default for a user install on Windows and macOS. Run that way, Narvy
45
+ prints once the command that adds the directory to PATH (PowerShell on
46
+ Windows, your shell rc file elsewhere). It never changes PATH itself;
47
+ `NARVY_NO_PATH_HINT=1` turns the hint off.
48
+ - README: install steps for Windows, macOS (Homebrew
49
+ `externally-managed-environment`) and Linux, with pipx first.
50
+
51
+ ### Fixed
52
+
53
+ - A user install run as `python -m narvy` (or by the launcher's full path)
54
+ did not find semgrep in the same off-PATH directory, skipped the code pass
55
+ and reported only dependency CVEs. semgrep is now looked up there and its
56
+ directory is put on the PATH it runs with (it starts `pysemgrep` through
57
+ PATH).
58
+ - macOS: when no Java 11+ was installed, the downloaded JRE was never found
59
+ ("no bin/ dir found inside"), so APK scans failed on every such Mac. The
60
+ macOS layout (`Contents/Home/bin`) is handled, Apple Silicon and ARM Linux
61
+ get the native aarch64 JRE instead of x64, and a cached JRE that does not
62
+ run is downloaded again.
63
+ - Windows: jadx failed to start when its path contained a space (a user name
64
+ like `Jean Dupont`, or `C:\Program Files`), because `cmd /c` stripped the
65
+ wrong quotes. The jadx command line now goes through `cmd /d /s /c` as one
66
+ quoted string.
67
+ - Windows: tool output (semgrep, jadx, nuclei, ssh) is decoded as UTF-8
68
+ instead of the console code page, semgrep runs in Python UTF-8 mode, and
69
+ output redirected to a file or pipe replaces a character the code page
70
+ cannot encode instead of stopping the scan with `UnicodeEncodeError`.
71
+ - A file still locked in a temporary directory (antivirus, a slow child
72
+ process) no longer fails the scan at cleanup.
73
+ - SCA: only one version of each package was checked. A lockfile that holds
74
+ `minimist 1.2.8` and a nested `minimist 0.0.8` now checks both. A version
75
+ taken from a manifest range is still dropped when a lockfile resolves that
76
+ package.
77
+ - yarn.lock (berry): the package comes from the `resolution:` line, so
78
+ aliases (`string-width-cjs@npm:string-width@...`) and `patch:` entries
79
+ name the real package, and `__metadata`, workspace, git and local entries
80
+ are no longer sent to OSV as packages.
81
+ - semgrep with more than one worker drops taint-mode results at random with
82
+ no error. Every rule pack that holds a taint rule now runs in its own
83
+ single-worker semgrep process (packs still run side by side). Files where
84
+ a rule hit the per-file timeout are named in the scan notes.
85
+
86
+ ### Changed
87
+
88
+ - Community rule precision round (PHP, JavaScript, Go, Java, Python, Ruby):
89
+ fewer findings on code that is not reachable by an attacker. Informational
90
+ rules are lowered to LOW with the reason.
91
+
8
92
  ## [1.1.1]
9
93
 
10
94
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narvy-cli
3
- Version: 1.1.1
3
+ Version: 1.1.3
4
4
  Summary: Free, local SAST scanner for Android, iOS, and polyglot web/backend source - plus passive web, host-audit, and cloud-posture scanning.
5
5
  Author: Narvy
6
6
  License: Apache-2.0
@@ -63,11 +63,83 @@ exactly in [Telemetry](#telemetry), which you can turn off.
63
63
  ## Install
64
64
 
65
65
  ```bash
66
- pip install narvy-cli
66
+ pipx install narvy-cli # recommended, puts `narvy` on PATH (run `pipx ensurepath` once)
67
+ pip install narvy-cli # or plain pip
67
68
  ```
68
69
 
69
- Requires a Java 11+ JRE on your `PATH` for Android scanning (jadx itself is
70
- auto-downloaded on first scan into `~/.narvy/tools`). Everything else
70
+ If your shell says `narvy: command not found` / `The term 'narvy' is not
71
+ recognized`, pip installed the command into a directory that is not on your
72
+ PATH. `python -m narvy` always works (`py -m narvy` on Windows), and prints
73
+ once the command that adds that directory to PATH. Narvy never edits PATH
74
+ itself.
75
+
76
+ ### Windows
77
+
78
+ Recommended, with [pipx](https://pipx.pypa.io):
79
+
80
+ ```powershell
81
+ py -m pip install --user pipx
82
+ py -m pipx ensurepath # then close and reopen the terminal
83
+ pipx install narvy-cli
84
+ narvy scan app.apk
85
+ ```
86
+
87
+ Without pipx:
88
+
89
+ ```powershell
90
+ py -m pip install narvy-cli
91
+ py -m narvy scan app.apk
92
+ ```
93
+
94
+ To use the short `narvy` command after a plain `pip install`, add pip's
95
+ Scripts directory to PATH. For the current PowerShell window only
96
+ (Python 3.14 from python.org; change `Python314` to your version):
97
+
98
+ ```powershell
99
+ $env:Path += ";$env:APPDATA\Python\Python314\Scripts"
100
+ ```
101
+
102
+ To make it permanent for your user account, then open a new terminal:
103
+
104
+ ```powershell
105
+ [Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path", "User") + ";$env:APPDATA\Python\Python314\Scripts", "User")
106
+ ```
107
+
108
+ The exact directory is in pip's `WARNING: The script narvy.exe is installed
109
+ in ... which is not on PATH` line, and in the hint `py -m narvy` prints. The
110
+ Microsoft Store Python uses a different one (under
111
+ `%LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.x_...\LocalCache\local-packages`).
112
+ Windows on ARM: semgrep has no Windows ARM64 build, use x64 Python.
113
+
114
+ ### macOS
115
+
116
+ Homebrew's Python refuses `pip install` outside a virtualenv
117
+ (`error: externally-managed-environment`). Use pipx:
118
+
119
+ ```bash
120
+ brew install pipx && pipx ensurepath # then open a new terminal
121
+ pipx install narvy-cli
122
+ ```
123
+
124
+ or a virtualenv (`python3 -m venv ~/.venvs/narvy && ~/.venvs/narvy/bin/pip install narvy-cli`).
125
+ A `pip install --user` puts `narvy` in `~/Library/Python/3.x/bin`, which is
126
+ not on PATH: add it with
127
+ `echo 'export PATH="$HOME/Library/Python/3.14/bin:$PATH"' >> ~/.zshrc`, or run
128
+ `python3 -m narvy`.
129
+
130
+ ### Linux
131
+
132
+ pipx (`sudo apt install pipx` / `dnf install pipx`, then `pipx ensurepath`) is
133
+ the simplest. Debian 12+ and Ubuntu 23.04+ refuse a system-wide `pip install`
134
+ the same way Homebrew does. A `pip install --user` puts `narvy` in
135
+ `~/.local/bin`; if that is not on PATH, add
136
+ `export PATH="$HOME/.local/bin:$PATH"` to `~/.bashrc`, or run
137
+ `python3 -m narvy`.
138
+
139
+ ### Requirements
140
+
141
+ Android scans need Java 11+. If none is found, a JRE (and jadx) is
142
+ downloaded into `~/.narvy/tools` on the first APK scan. Everything else
71
143
  is pure Python plus a couple of optional extras:
72
144
 
73
145
  ```bash
@@ -215,6 +287,9 @@ number of checks it did not run. It never hides a finding it did compute.
215
287
  in its own output when it applies.
216
288
  - Only one architecture's native libraries are checked in a multi-ABI
217
289
  APK/split bundle.
290
+ - Windows: a decompiled file whose full path passes 260 characters is only
291
+ read when Windows long-path support is enabled
292
+ (`LongPathsEnabled`); a very deep package tree in `%TEMP%` can hit it.
218
293
 
219
294
  See [SETUP.md](SETUP.md) for detailed install/troubleshooting steps and the
220
295
  exact commands this README's examples were run against.
@@ -30,11 +30,83 @@ exactly in [Telemetry](#telemetry), which you can turn off.
30
30
  ## Install
31
31
 
32
32
  ```bash
33
- pip install narvy-cli
33
+ pipx install narvy-cli # recommended, puts `narvy` on PATH (run `pipx ensurepath` once)
34
+ pip install narvy-cli # or plain pip
34
35
  ```
35
36
 
36
- Requires a Java 11+ JRE on your `PATH` for Android scanning (jadx itself is
37
- auto-downloaded on first scan into `~/.narvy/tools`). Everything else
37
+ If your shell says `narvy: command not found` / `The term 'narvy' is not
38
+ recognized`, pip installed the command into a directory that is not on your
39
+ PATH. `python -m narvy` always works (`py -m narvy` on Windows), and prints
40
+ once the command that adds that directory to PATH. Narvy never edits PATH
41
+ itself.
42
+
43
+ ### Windows
44
+
45
+ Recommended, with [pipx](https://pipx.pypa.io):
46
+
47
+ ```powershell
48
+ py -m pip install --user pipx
49
+ py -m pipx ensurepath # then close and reopen the terminal
50
+ pipx install narvy-cli
51
+ narvy scan app.apk
52
+ ```
53
+
54
+ Without pipx:
55
+
56
+ ```powershell
57
+ py -m pip install narvy-cli
58
+ py -m narvy scan app.apk
59
+ ```
60
+
61
+ To use the short `narvy` command after a plain `pip install`, add pip's
62
+ Scripts directory to PATH. For the current PowerShell window only
63
+ (Python 3.14 from python.org; change `Python314` to your version):
64
+
65
+ ```powershell
66
+ $env:Path += ";$env:APPDATA\Python\Python314\Scripts"
67
+ ```
68
+
69
+ To make it permanent for your user account, then open a new terminal:
70
+
71
+ ```powershell
72
+ [Environment]::SetEnvironmentVariable("Path", [Environment]::GetEnvironmentVariable("Path", "User") + ";$env:APPDATA\Python\Python314\Scripts", "User")
73
+ ```
74
+
75
+ The exact directory is in pip's `WARNING: The script narvy.exe is installed
76
+ in ... which is not on PATH` line, and in the hint `py -m narvy` prints. The
77
+ Microsoft Store Python uses a different one (under
78
+ `%LOCALAPPDATA%\Packages\PythonSoftwareFoundation.Python.3.x_...\LocalCache\local-packages`).
79
+ Windows on ARM: semgrep has no Windows ARM64 build, use x64 Python.
80
+
81
+ ### macOS
82
+
83
+ Homebrew's Python refuses `pip install` outside a virtualenv
84
+ (`error: externally-managed-environment`). Use pipx:
85
+
86
+ ```bash
87
+ brew install pipx && pipx ensurepath # then open a new terminal
88
+ pipx install narvy-cli
89
+ ```
90
+
91
+ or a virtualenv (`python3 -m venv ~/.venvs/narvy && ~/.venvs/narvy/bin/pip install narvy-cli`).
92
+ A `pip install --user` puts `narvy` in `~/Library/Python/3.x/bin`, which is
93
+ not on PATH: add it with
94
+ `echo 'export PATH="$HOME/Library/Python/3.14/bin:$PATH"' >> ~/.zshrc`, or run
95
+ `python3 -m narvy`.
96
+
97
+ ### Linux
98
+
99
+ pipx (`sudo apt install pipx` / `dnf install pipx`, then `pipx ensurepath`) is
100
+ the simplest. Debian 12+ and Ubuntu 23.04+ refuse a system-wide `pip install`
101
+ the same way Homebrew does. A `pip install --user` puts `narvy` in
102
+ `~/.local/bin`; if that is not on PATH, add
103
+ `export PATH="$HOME/.local/bin:$PATH"` to `~/.bashrc`, or run
104
+ `python3 -m narvy`.
105
+
106
+ ### Requirements
107
+
108
+ Android scans need Java 11+. If none is found, a JRE (and jadx) is
109
+ downloaded into `~/.narvy/tools` on the first APK scan. Everything else
38
110
  is pure Python plus a couple of optional extras:
39
111
 
40
112
  ```bash
@@ -182,6 +254,9 @@ number of checks it did not run. It never hides a finding it did compute.
182
254
  in its own output when it applies.
183
255
  - Only one architecture's native libraries are checked in a multi-ABI
184
256
  APK/split bundle.
257
+ - Windows: a decompiled file whose full path passes 260 characters is only
258
+ read when Windows long-path support is enabled
259
+ (`LongPathsEnabled`); a very deep package tree in `%TEMP%` can hit it.
185
260
 
186
261
  See [SETUP.md](SETUP.md) for detailed install/troubleshooting steps and the
187
262
  exact commands this README's examples were run against.
@@ -1,3 +1,3 @@
1
1
  """Narvy CLI - free, local SAST for Android, iOS and polyglot source."""
2
2
 
3
- __version__ = "1.1.1"
3
+ __version__ = "1.1.3"
@@ -0,0 +1,30 @@
1
+ """`python -m narvy` (or `py -m narvy` on Windows) runs the CLI.
2
+
3
+ Works even when pip put the `narvy` command in a Scripts/bin directory that is
4
+ not on PATH, which is the default for a user install on Windows and macOS.
5
+ """
6
+ import os
7
+ import sys
8
+
9
+
10
+ def _prog() -> str:
11
+ """Name shown in usage lines: the interpreter the user actually typed, plus -m narvy."""
12
+ argv0 = (getattr(sys, "orig_argv", None) or [""])[0]
13
+ name = os.path.basename(argv0)
14
+ if name.lower().endswith(".exe"):
15
+ name = name[:-4]
16
+ if not name.lower().startswith(("python", "py")):
17
+ name = "python"
18
+ return f"{name} -m narvy"
19
+
20
+
21
+ def main() -> None:
22
+ from .pathhint import maybe_print_path_hint
23
+ from .main import cli
24
+
25
+ maybe_print_path_hint()
26
+ cli(prog=_prog())
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
@@ -24,6 +24,13 @@ JVM_RSS_OVERHEAD = 1.30
24
24
 
25
25
  RAM_SAFETY_RESERVE_MB = 1024
26
26
 
27
+ # When --max-mem does not fit in free RAM but a smaller heap still leaves this
28
+ # much headroom over the estimate, jadx runs with that smaller -Xmx instead of
29
+ # the scan being refused. -Xmx bounds the JVM, so the smaller heap is what keeps
30
+ # memory in check. Measured: four real apps (6.5k to 60k classes) decompiled
31
+ # fully at 1.5x the estimate, peak RSS about 1.2x that heap.
32
+ AUTO_LOWER_MARGIN = 1.5
33
+
27
34
 
28
35
  class DexStats(NamedTuple):
29
36
  methods: int
@@ -41,6 +48,8 @@ class PreflightVerdict(NamedTuple):
41
48
  max_mem_mb: int
42
49
  available_mb: Optional[int]
43
50
  stats: DexStats
51
+ # -Xmx value to run jadx with when it differs from --max-mem, else None.
52
+ lowered_max_mem: Optional[str] = None
44
53
 
45
54
 
46
55
  def parse_mem_arg(value: str) -> Optional[int]:
@@ -71,19 +80,7 @@ def available_ram_mb() -> Optional[int]:
71
80
  if line.startswith("MemAvailable:"):
72
81
  return int(line.split()[1]) // 1024
73
82
  elif system == "Darwin":
74
- # vm_stat exposes reclaimable inactive+purgeable pools that SC_AVPHYS_PAGES omits.
75
- import subprocess
76
- out = subprocess.run(["vm_stat"], capture_output=True, text=True, timeout=10).stdout
77
- page = 4096
78
- pm = re.search(r"page size of (\d+) bytes", out)
79
- if pm:
80
- page = int(pm.group(1))
81
- counts = dict(re.findall(r"^(.+?):\s+(\d+)\.", out, re.M))
82
- free = int(counts.get("Pages free", 0))
83
- inactive = int(counts.get("Pages inactive", 0))
84
- purgeable = int(counts.get("Pages purgeable", 0))
85
- if free or inactive:
86
- return ((free + inactive + purgeable) * page) // (1024 * 1024)
83
+ return _darwin_available_mb()
87
84
  elif system == "Windows":
88
85
  class _MemStatusEx(ctypes.Structure):
89
86
  _fields_ = [
@@ -109,6 +106,58 @@ def available_ram_mb() -> Optional[int]:
109
106
  return None
110
107
 
111
108
 
109
+ def _sysctl_int(name: str) -> Optional[int]:
110
+ import subprocess
111
+ try:
112
+ out = subprocess.run(["sysctl", "-n", name], capture_output=True, text=True,
113
+ encoding="utf-8", errors="replace", timeout=10).stdout.strip()
114
+ except (OSError, subprocess.SubprocessError):
115
+ return None
116
+ return int(out) if out.isdigit() else None
117
+
118
+
119
+ def _vm_stat_available_mb(out: str) -> Optional[int]:
120
+ """free + inactive + speculative + purgeable pages from `vm_stat` output, in MB."""
121
+ page = 4096
122
+ pm = re.search(r"page size of (\d+) bytes", out)
123
+ if pm:
124
+ page = int(pm.group(1))
125
+ counts = dict(re.findall(r"^(.+?):\s+(\d+)\.", out, re.M))
126
+ pages = sum(int(counts.get(k, 0)) for k in
127
+ ("Pages free", "Pages inactive", "Pages speculative", "Pages purgeable"))
128
+ if not pages:
129
+ return None
130
+ return (pages * page) // (1024 * 1024)
131
+
132
+
133
+ def _darwin_available_mb(memsize: Optional[int] = None, level: Optional[int] = None,
134
+ vm_stat_out: Optional[str] = None) -> Optional[int]:
135
+ """Available memory on macOS.
136
+
137
+ The kernel's own figure is kern.memorystatus_level (the "free percentage"
138
+ `memory_pressure` prints), which counts memory the compressor can reclaim.
139
+ Page counts from vm_stat miss that and read a few GB low on a machine that
140
+ is not under pressure at all, so they are only the fallback.
141
+ """
142
+ import subprocess
143
+ if memsize is None:
144
+ memsize = _sysctl_int("hw.memsize")
145
+ if level is None:
146
+ level = _sysctl_int("kern.memorystatus_level")
147
+ from_level = None
148
+ if memsize and level is not None and 0 < level <= 100:
149
+ from_level = (memsize * level // 100) // (1024 * 1024)
150
+ if vm_stat_out is None:
151
+ try:
152
+ vm_stat_out = subprocess.run(["vm_stat"], capture_output=True, text=True,
153
+ encoding="utf-8", errors="replace", timeout=10).stdout
154
+ except (OSError, subprocess.SubprocessError):
155
+ vm_stat_out = ""
156
+ from_pages = _vm_stat_available_mb(vm_stat_out or "")
157
+ known = [v for v in (from_level, from_pages) if v is not None]
158
+ return max(known) if known else None
159
+
160
+
112
161
  def _is_app_dex(name: str) -> bool:
113
162
  """True for app-code DEX (APK `classes*.dex` or App Bundle `<module>/dex/classes*.dex`), not asset .dex."""
114
163
  base = name.rsplit("/", 1)[-1]
@@ -225,8 +274,20 @@ def check_memory_preflight(apk_path: str, max_mem: str,
225
274
 
226
275
  if available_mb is not None:
227
276
  needed_rss = int(max_mem_mb * JVM_RSS_OVERHEAD)
228
- if needed_rss > (available_mb - RAM_SAFETY_RESERVE_MB):
229
- safe_gb = max(1, int((available_mb - RAM_SAFETY_RESERVE_MB) / JVM_RSS_OVERHEAD / 1024))
277
+ room_mb = available_mb - RAM_SAFETY_RESERVE_MB
278
+ if needed_rss > room_mb:
279
+ # -Xmx is a ceiling the JVM only grows to under pressure: when a smaller
280
+ # heap still fits this app with room to spare, use that instead of refusing.
281
+ fit_mb = (max(0, int(room_mb / JVM_RSS_OVERHEAD)) // 256) * 256
282
+ if fit_mb >= max(MIN_ESTIMATE_MB, need_mb * AUTO_LOWER_MARGIN):
283
+ note = (
284
+ f"Only {_fmt_mb(available_mb)} of memory is available right now, so JADX runs "
285
+ f"with a {_fmt_mb(fit_mb)} Java heap instead of --max-mem {max_mem} "
286
+ f"(this app needs about {_fmt_mb(need_mb)})."
287
+ )
288
+ return PreflightVerdict(False, "lowered", note, need_mb, max_mem_mb,
289
+ available_mb, stats, f"{fit_mb}m")
290
+ safe_gb = max(1, int(room_mb / JVM_RSS_OVERHEAD / 1024))
230
291
  lower_line = f" - lower to --max-mem {safe_gb}g, which fits in what's free"
231
292
  if safe_gb * 1024 < need_mb:
232
293
  lower_line += (f" (though this app looks like it needs about {_fmt_mb(need_mb)}, "
@@ -234,7 +295,8 @@ def check_memory_preflight(apk_path: str, max_mem: str,
234
295
  msg = (
235
296
  f"--max-mem {max_mem} asks JADX for {_fmt_mb(max_mem_mb)} of Java heap, which needs "
236
297
  f"about {_fmt_mb(needed_rss)} of real memory once JVM overhead is counted. This "
237
- f"machine only has {_fmt_mb(available_mb)} available right now.\n\n"
298
+ f"machine only has {_fmt_mb(available_mb)} available right now, not enough for "
299
+ f"this app ({scale}, about {_fmt_mb(need_mb)} of heap).\n\n"
238
300
  f"Running this would push the machine into swap and most likely get JADX killed by "
239
301
  f"the OS out-of-memory killer (and may take other apps down with it). Options:\n"
240
302
  f" - free up memory and try again\n"
@@ -30,8 +30,14 @@ JADX_BIN = os.path.join(JADX_HOME, "bin", "jadx.bat" if _IS_WINDOWS else "jadx")
30
30
  # jadx 1.5.0 needs Java 11+; a JRE is fetched when the system has none.
31
31
  _JRE_MAJOR = "17"
32
32
  _JRE_ADOPTIUM_OS = "windows" if _IS_WINDOWS else ("mac" if _IS_MAC else "linux")
33
- # x64 only: ARM Linux is unsupported and fails loudly on the archive.
34
- _JRE_ADOPTIUM_ARCH = "x64"
33
+ # Apple Silicon and ARM Linux get the native aarch64 build (an x64 JRE needs
34
+ # Rosetta on a Mac and does not run at all on ARM Linux). Windows stays x64:
35
+ # Temurin 17 has no Windows ARM JRE, and Windows on ARM emulates x64.
36
+ _JRE_ADOPTIUM_ARCH = (
37
+ "aarch64"
38
+ if not _IS_WINDOWS and platform.machine().lower() in ("arm64", "aarch64")
39
+ else "x64"
40
+ )
35
41
  JRE_URL = (
36
42
  f"https://api.adoptium.net/v3/binary/latest/{_JRE_MAJOR}/ga/"
37
43
  f"{_JRE_ADOPTIUM_OS}/{_JRE_ADOPTIUM_ARCH}/jre/hotspot/normal/eclipse"
@@ -69,7 +75,7 @@ def resolve_jadx_binary():
69
75
 
70
76
  def _java_major_version(java_bin):
71
77
  try:
72
- result = subprocess.run([java_bin, "-version"], capture_output=True, text=True, timeout=10)
78
+ result = subprocess.run([java_bin, "-version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10)
73
79
  except Exception:
74
80
  return None
75
81
  out = result.stderr or result.stdout or ""
@@ -87,10 +93,12 @@ def _jre_bin_dir():
87
93
  # Discovered, not hardcoded: the Adoptium top-level dir carries the patch version.
88
94
  if not os.path.isdir(JRE_HOME):
89
95
  return None
90
- for entry in os.listdir(JRE_HOME):
91
- candidate = os.path.join(JRE_HOME, entry, "bin")
92
- if os.path.isdir(candidate):
93
- return candidate
96
+ for entry in sorted(os.listdir(JRE_HOME)):
97
+ # Linux/Windows: <jdk-17...-jre>/bin. macOS: <jdk-17...-jre>/Contents/Home/bin.
98
+ for sub in (("bin",), ("Contents", "Home", "bin")):
99
+ candidate = os.path.join(JRE_HOME, entry, *sub)
100
+ if os.path.isdir(candidate):
101
+ return candidate
94
102
  return None
95
103
 
96
104
 
@@ -133,16 +141,31 @@ def resolve_java_home():
133
141
 
134
142
  bin_dir = _jre_bin_dir()
135
143
  if bin_dir:
136
- return bin_dir
144
+ java = os.path.join(bin_dir, "java.exe" if _IS_WINDOWS else "java")
145
+ # A JRE left by an older narvy can be the wrong architecture (x64 on
146
+ # Apple Silicon without Rosetta): it must actually run, or it is replaced.
147
+ if (_java_major_version(java) or 0) >= 11:
148
+ return bin_dir
149
+ logger.warning(f"The cached JRE in {JRE_HOME} does not run here - downloading a fresh one")
137
150
 
138
151
  return _download_jre()
139
152
 
140
153
 
141
154
  def _jadx_cmd(jadx_bin, extra_args):
142
- # A .bat needs `cmd /c`; shell=True would mis-quote paths.
143
- if _IS_WINDOWS and jadx_bin.lower().endswith(".bat"):
144
- return ["cmd", "/c", jadx_bin] + extra_args
145
- return [jadx_bin] + extra_args
155
+ """argv for jadx. On Windows a .bat goes through cmd.exe as ONE string.
156
+
157
+ A list like ["cmd", "/c", bat, ...] breaks as soon as the .bat path is quoted
158
+ (a space in the user name, or C:\\Program Files): cmd /c strips the first
159
+ and the last quote of the line and runs `C:\\Users\\Jean`. With /s and the
160
+ whole line wrapped in one extra pair of quotes, cmd strips exactly that pair.
161
+ """
162
+ if _IS_WINDOWS and jadx_bin.lower().endswith((".bat", ".cmd")):
163
+ return 'cmd /d /s /c "' + subprocess.list2cmdline([jadx_bin] + list(extra_args)) + '"'
164
+ return [jadx_bin] + list(extra_args)
165
+
166
+
167
+ def _cmd_text(cmd):
168
+ return cmd if isinstance(cmd, str) else " ".join(str(c) for c in cmd)
146
169
 
147
170
 
148
171
  def _validate_apk_zip(apk_path: str):
@@ -199,6 +222,10 @@ def _as_limit_preexec(max_mem_mb):
199
222
  return _set_limits
200
223
 
201
224
 
225
+ # Set by decompile_apk when it ran jadx with a smaller heap than --max-mem; the caller prints it.
226
+ LAST_HEAP_NOTE = None
227
+
228
+
202
229
  def decompile_apk(apk_path: str, output_dir: str, max_mem: str = "4g", force: bool = False,
203
230
  extra_inputs=None):
204
231
  """Decompile an APK with jadx; returns (success, error_detail). jadx keeps the FIRST input's manifest, so apk_path stays first; `extra_inputs` merge into one tree."""
@@ -206,10 +233,16 @@ def decompile_apk(apk_path: str, output_dir: str, max_mem: str = "4g", force: bo
206
233
  if validation_error:
207
234
  return False, validation_error
208
235
 
236
+ global LAST_HEAP_NOTE
237
+ LAST_HEAP_NOTE = None
209
238
  if not force:
210
239
  verdict = check_memory_preflight(apk_path, max_mem)
211
240
  if verdict.should_block:
212
241
  return False, verdict.message
242
+ lowered = getattr(verdict, "lowered_max_mem", None)
243
+ if lowered:
244
+ max_mem = lowered
245
+ LAST_HEAP_NOTE = verdict.message
213
246
 
214
247
  try:
215
248
  jadx_bin = resolve_jadx_binary()
@@ -260,7 +293,7 @@ def decompile_apk(apk_path: str, output_dir: str, max_mem: str = "4g", force: bo
260
293
  process = subprocess.run(
261
294
  cmd,
262
295
  capture_output=True,
263
- text=True,
296
+ text=True, encoding="utf-8", errors="replace",
264
297
  check=True,
265
298
  timeout=900,
266
299
  env=jadx_env,
@@ -269,7 +302,7 @@ def decompile_apk(apk_path: str, output_dir: str, max_mem: str = "4g", force: bo
269
302
  return True, ""
270
303
  except subprocess.CalledProcessError as e:
271
304
  # jadx/cmd.exe don't consistently use stderr; report both streams.
272
- parts = [f"JADX failed (exit code {e.returncode})", f"Command run: {' '.join(str(c) for c in cmd)}"]
305
+ parts = [f"JADX failed (exit code {e.returncode})", f"Command run: {_cmd_text(cmd)}"]
273
306
  if e.stdout:
274
307
  parts.append(f"JADX stdout:\n{e.stdout}")
275
308
  if e.stderr:
@@ -279,7 +312,7 @@ def decompile_apk(apk_path: str, output_dir: str, max_mem: str = "4g", force: bo
279
312
  "No output captured on either stdout or stderr. Run "
280
313
  "`narvy doctor` to check your Java/jadx setup, or try "
281
314
  f"running this exact command yourself to see the raw error: "
282
- f"{' '.join(str(c) for c in cmd)}"
315
+ f"{_cmd_text(cmd)}"
283
316
  )
284
317
  # OOM is unambiguous; a bare -9 (SIGKILL) is ambiguous, so they differ.
285
318
  _combined_output = (e.stdout or "") + (e.stderr or "")
@@ -18,7 +18,7 @@ console = Console()
18
18
 
19
19
  def _java_version(java_bin):
20
20
  """Run `java -version` and return (version_line, major_version_int_or_None)."""
21
- result = subprocess.run([java_bin, "-version"], capture_output=True, text=True, timeout=10)
21
+ result = subprocess.run([java_bin, "-version"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10)
22
22
  out = result.stderr or result.stdout or ""
23
23
  version_line = out.splitlines()[0] if out else "unknown version"
24
24
  # Handles both the old "1.8.0_501" scheme and the modern "17.0.2" one.
@@ -225,7 +225,7 @@ def scp_to_remote(conn: HostConn, local_path: str, remote_path: str,
225
225
  argv = ["sshpass", "-e", *argv]
226
226
 
227
227
  try:
228
- proc = subprocess.run(argv, capture_output=True, text=True,
228
+ proc = subprocess.run(argv, capture_output=True, text=True, encoding="utf-8", errors="replace",
229
229
  timeout=timeout, env=env)
230
230
  except subprocess.TimeoutExpired as exc:
231
231
  raise LynisBootstrapError(
@@ -85,7 +85,7 @@ def capture_host_key(hostname: str, port: int = 22,
85
85
  try:
86
86
  proc = subprocess.run(
87
87
  ["ssh-keyscan", "-p", str(port), "-T", str(timeout), hostname],
88
- capture_output=True, text=True, timeout=timeout + 10,
88
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout + 10,
89
89
  )
90
90
  except (subprocess.TimeoutExpired, OSError):
91
91
  return None
@@ -178,7 +178,7 @@ def ssh_exec(conn: HostConn, remote_cmd: str,
178
178
 
179
179
  try:
180
180
  proc = subprocess.run(
181
- argv, capture_output=True, text=True, timeout=timeout, env=env,
181
+ argv, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, env=env,
182
182
  )
183
183
  except subprocess.TimeoutExpired as exc:
184
184
  raise SSHExecError(
@@ -54,7 +54,7 @@ def _icdump_class_count(binary_path: str, timeout: int = 60
54
54
  try:
55
55
  proc = subprocess.run(
56
56
  [_sys.executable, "-c", _ICDUMP_PROBE_SCRIPT, binary_path],
57
- capture_output=True, text=True, timeout=timeout,
57
+ capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout,
58
58
  )
59
59
  except subprocess.TimeoutExpired:
60
60
  return False, 0, 0, f"timed out after {timeout}s"
@@ -997,7 +997,7 @@ def analyze_ipa(ipa_path: str, override_config: Optional[ScopeConfig] = None) ->
997
997
  framework_members = [n for n in names if n.startswith(frameworks_prefix)]
998
998
 
999
999
  import tempfile
1000
- with tempfile.TemporaryDirectory(prefix="narvy-ios-") as tmpdir:
1000
+ with tempfile.TemporaryDirectory(prefix="narvy-ios-", ignore_cleanup_errors=True) as tmpdir:
1001
1001
  main_binary_path = _safe_extract_member(zf, main_binary_member, tmpdir)
1002
1002
  if main_binary_path is None:
1003
1003
  result["error"] = f"Failed to extract main binary {main_binary_member} " \