narvy-cli 1.1.5__tar.gz → 1.2.0__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 (118) hide show
  1. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/CHANGELOG.md +36 -0
  2. {narvy_cli-1.1.5/narvy_cli.egg-info → narvy_cli-1.2.0}/PKG-INFO +46 -3
  3. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/README.md +45 -2
  4. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/__init__.py +1 -1
  5. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/main.py +39 -1
  6. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/reporter.py +6 -1
  7. narvy_cli-1.2.0/narvy/secret_output.py +232 -0
  8. narvy_cli-1.2.0/narvy/secret_presentation.py +1053 -0
  9. narvy_cli-1.2.0/narvy/store_check/__init__.py +2 -0
  10. narvy_cli-1.2.0/narvy/store_check/__main__.py +5 -0
  11. narvy_cli-1.2.0/narvy/store_check/analyze.py +73 -0
  12. narvy_cli-1.2.0/narvy/store_check/android/arsc.py +109 -0
  13. narvy_cli-1.2.0/narvy/store_check/android/axml.py +178 -0
  14. narvy_cli-1.2.0/narvy/store_check/android/checks.py +668 -0
  15. narvy_cli-1.2.0/narvy/store_check/android/elf.py +79 -0
  16. narvy_cli-1.2.0/narvy/store_check/android/loader.py +160 -0
  17. narvy_cli-1.2.0/narvy/store_check/android/protoxml.py +181 -0
  18. narvy_cli-1.2.0/narvy/store_check/android/xmlmodel.py +106 -0
  19. narvy_cli-1.2.0/narvy/store_check/archive.py +108 -0
  20. narvy_cli-1.2.0/narvy/store_check/cli.py +129 -0
  21. narvy_cli-1.2.0/narvy/store_check/data/__init__.py +0 -0
  22. narvy_cli-1.2.0/narvy/store_check/data/apple.py +195 -0
  23. narvy_cli-1.2.0/narvy/store_check/data/play.py +162 -0
  24. narvy_cli-1.2.0/narvy/store_check/ios/__init__.py +0 -0
  25. narvy_cli-1.2.0/narvy/store_check/ios/checks.py +467 -0
  26. narvy_cli-1.2.0/narvy/store_check/ios/loader.py +154 -0
  27. narvy_cli-1.2.0/narvy/store_check/ios/macho.py +352 -0
  28. narvy_cli-1.2.0/narvy/store_check/model.py +119 -0
  29. narvy_cli-1.2.0/narvy/web/__init__.py +0 -0
  30. {narvy_cli-1.1.5 → narvy_cli-1.2.0/narvy_cli.egg-info}/PKG-INFO +46 -3
  31. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy_cli.egg-info/SOURCES.txt +23 -0
  32. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/LICENSE +0 -0
  33. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/MANIFEST.in +0 -0
  34. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/SECURITY.md +0 -0
  35. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/__main__.py +0 -0
  36. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/android/__init__.py +0 -0
  37. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/android/source_analyzer.py +0 -0
  38. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/android/split_bundle.py +0 -0
  39. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/android_rule_context.py +0 -0
  40. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/apk_memory_preflight.py +0 -0
  41. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/auth.py +0 -0
  42. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/ci_templates/bitbucket.yml +0 -0
  43. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/ci_templates/github.yml +0 -0
  44. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/ci_templates/gitlab.yml +0 -0
  45. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/cloud/__init__.py +0 -0
  46. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/cloud/aws_scan.py +0 -0
  47. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/comment_filter.py +0 -0
  48. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/crypto_taint_lite.py +0 -0
  49. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/decompiler.py +0 -0
  50. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/doctor.py +0 -0
  51. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/host/__init__.py +0 -0
  52. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/host/audit.py +0 -0
  53. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/host/host_knowledge.py +0 -0
  54. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/host/lynis_bootstrap.py +0 -0
  55. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/host/lynis_parser.py +0 -0
  56. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/host/narvy_checks.py +0 -0
  57. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/host/report.py +0 -0
  58. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/host/ssh_exec.py +0 -0
  59. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/ios/__init__.py +0 -0
  60. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/ios/binary_analyzer.py +0 -0
  61. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/ios/plist_checks.py +0 -0
  62. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/ios/source_analyzer.py +0 -0
  63. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/ios/third_party_filter.py +0 -0
  64. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/ios/trust_all_context.py +0 -0
  65. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/native_hardening.py +0 -0
  66. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/own_reports.py +0 -0
  67. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/pathhint.py +0 -0
  68. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/pathnorm.py +0 -0
  69. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/proc.py +0 -0
  70. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rule_engine.py +0 -0
  71. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/android/config.yml +0 -0
  72. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/android/crypto.yml +0 -0
  73. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/android/secrets.yml +0 -0
  74. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/android/storage.yml +0 -0
  75. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/android/webview.yml +0 -0
  76. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/android.yml +0 -0
  77. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/ios/objc/crypto.yml +0 -0
  78. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/ios/objc/network.yml +0 -0
  79. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/ios/objc/secrets.yml +0 -0
  80. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/ios/objc/storage.yml +0 -0
  81. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/ios/objc/webview.yml +0 -0
  82. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/ios_swift.yml +0 -0
  83. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/web/go.yml +0 -0
  84. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/web/java.yml +0 -0
  85. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/web/javascript.yml +0 -0
  86. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/web/kotlin.yml +0 -0
  87. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/web/php.yml +0 -0
  88. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/web/python.yml +0 -0
  89. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/web/ruby.yml +0 -0
  90. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/web/rust.yml +0 -0
  91. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/web/secrets.yml +0 -0
  92. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/rules/web/secrets_supplement.yml +0 -0
  93. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/sca/__init__.py +0 -0
  94. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/sca/android_deps.py +0 -0
  95. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/sca/ios_deps.py +0 -0
  96. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/sca/osv_client.py +0 -0
  97. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/sca/web_deps.py +0 -0
  98. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/scope_config.py +0 -0
  99. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/secret_value_grade.py +0 -0
  100. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/semgrep_engine.py +0 -0
  101. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/stack_protector_evidence.py +0 -0
  102. {narvy_cli-1.1.5/narvy/web → narvy_cli-1.2.0/narvy/store_check/android}/__init__.py +0 -0
  103. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/telemetry.py +0 -0
  104. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/third_party_filter.py +0 -0
  105. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/uploader.py +0 -0
  106. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/weak_prng_context.py +0 -0
  107. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/web/nuclei_binary.py +0 -0
  108. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/web/scan_blocklist.py +0 -0
  109. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/web/scanner.py +0 -0
  110. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/web/source_analyzer.py +0 -0
  111. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy/web/ssrf_guard.py +0 -0
  112. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy_cli.egg-info/dependency_links.txt +0 -0
  113. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy_cli.egg-info/entry_points.txt +0 -0
  114. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy_cli.egg-info/requires.txt +0 -0
  115. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/narvy_cli.egg-info/top_level.txt +0 -0
  116. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/pyproject.toml +0 -0
  117. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/setup.cfg +0 -0
  118. {narvy_cli-1.1.5 → narvy_cli-1.2.0}/setup.py +0 -0
@@ -5,6 +5,42 @@ 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.2.0]
9
+
10
+ ### Added
11
+
12
+ - `narvy store-check <file>`: store readiness checks for an APK, AAB,
13
+ split-APK set (`.apks`, `.xapk`, `.apkm`) or IPA against the Google Play
14
+ and App Store upload requirements: target API level, 16 KB page size
15
+ (ELF segment alignment and zip alignment of native libraries),
16
+ `android:exported` on components with intent filters, debuggable builds,
17
+ cleartext traffic, foreground service types, permissions that need a Play
18
+ Console declaration, iOS SDK and minimum OS version, required-reason APIs
19
+ against the privacy manifests, listed third-party SDKs without a privacy
20
+ manifest, App Transport Security and purpose strings. Every finding cites
21
+ the value read in the binary and the official page the rule comes from;
22
+ a question the binary cannot settle is reported `not determined`, never
23
+ guessed. Options: `--json` (both languages), `--lang fr`, `--as-of DATE`
24
+ (store requirements are dated), `--fail-on warning`, `--verbose`. Exit
25
+ codes: 0 no blocker, 1 blocker (or warning with `--fail-on warning`),
26
+ 3 file not analysable. The check runs offline, reads the archive in
27
+ memory, writes nothing to disk and sends no telemetry. Python standard
28
+ library only, no new dependency. It is not a security scan.
29
+
30
+ ### Changed
31
+
32
+ - Secret findings are worded for what they are: "Potential secret: <kind>
33
+ (format match, not tested)" or "(pattern match, not tested)". Narvy never
34
+ tests a secret (no login, no API call, no revocation check). Findings
35
+ carry `secret_confidence` (`likely` or `potential`) and `secret_tested:
36
+ false` in JSON and SARIF. Public client keys shipped in app code (Firebase
37
+ configuration, Google API keys restricted by design) are reported as
38
+ Info.
39
+ - Secret values are masked in every output: console table, JSON, SARIF,
40
+ the upload payload, raw tool output (`--verbose`) and log records. The
41
+ wording and masking are the same as the Narvy dashboard, so dashboard
42
+ fingerprints of uploaded findings do not change.
43
+
8
44
  ## [1.1.5]
9
45
 
10
46
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narvy-cli
3
- Version: 1.1.5
3
+ Version: 1.2.0
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
@@ -56,6 +56,11 @@ access or your own existing credentials:
56
56
  - `cloud-scan` - AWS account posture check using your own ambient AWS
57
57
  credentials
58
58
 
59
+ and one check that is not a security scan:
60
+
61
+ - `store-check` - Google Play and App Store upload requirements for an
62
+ APK, AAB, split-APK set or IPA, offline (see [Store check](#store-check))
63
+
59
64
  Your code, binaries and findings are never uploaded unless you explicitly
60
65
  pass `--upload`. The CLI does send anonymous usage statistics, described
61
66
  exactly in [Telemetry](#telemetry), which you can turn off.
@@ -181,6 +186,10 @@ narvy scans
181
186
 
182
187
  # Scan + upload every .apk/.aab/.apkm/.xapk/.ipa found in a folder, in one command
183
188
  narvy upload-all ./my-apps-folder/
189
+
190
+ # Google Play / App Store upload requirements, offline (not a security scan)
191
+ narvy store-check app-release.aab
192
+ narvy store-check MyApp.ipa --lang fr --json
184
193
  ```
185
194
 
186
195
  Run `narvy doctor` before your first scan to check your Java/jadx/iOS
@@ -237,6 +246,40 @@ exhaust the machine. `NARVY_SEMGREP_JOBS` and `NARVY_SEMGREP_MAX_MEMORY_MB`
237
246
  listed in the scan notes. Outside a terminal (CI logs), a progress line is
238
247
  printed every 30 seconds; `NARVY_PROGRESS_INTERVAL` changes that (0 = off).
239
248
 
249
+ ## Store check
250
+
251
+ `narvy store-check` answers one question: will Google Play or the App Store
252
+ accept this build, and what will they ask about it? It reads the manifest,
253
+ resources, native libraries, Info.plist, Mach-O load commands, imported
254
+ symbols and privacy manifests of the file, and evaluates them against the
255
+ store requirements in force at a date (`--as-of`, default today).
256
+
257
+ ```bash
258
+ narvy store-check app-release.aab
259
+ narvy store-check app.xapk --fail-on warning # CI gate on warnings too
260
+ narvy store-check MyApp.ipa --json > store.json # EN and FR messages in one report
261
+ narvy store-check MyApp.ipa --as-of 2027-02-01 # evaluate a future deadline
262
+ ```
263
+
264
+ | Store | Checks |
265
+ |-------|--------|
266
+ | Google Play | `PLAY-TARGET-SDK`, `PLAY-16KB-ELF`, `PLAY-16KB-ZIPALIGN`, `PLAY-EXPORTED`, `PLAY-DEBUGGABLE`, `PLAY-CLEARTEXT`, `PLAY-FGS-TYPE`, `PLAY-FGS-PERMISSION`, `PLAY-FGS-DECLARATION`, `PLAY-PERMISSION-DECLARATION` |
267
+ | App Store | `APPSTORE-SDK-VERSION`, `APPSTORE-MIN-OS`, `APPSTORE-REQUIRED-REASON-API`, `APPSTORE-PRIVACY-MANIFEST`, `APPSTORE-THIRD-PARTY-SDK`, `APPSTORE-ATS`, `APPSTORE-PURPOSE-STRINGS` |
268
+
269
+ Each check reports `fail` (severity `blocker`, `warning` or `info`),
270
+ `pass`, `not_determined` (the binary does not settle the question) or
271
+ `not_applicable`, with the evidence read from the file and the official
272
+ page the rule comes from. Store values and their sources live in
273
+ `narvy/store_check/data/`.
274
+
275
+ It runs offline: no network call, no telemetry event, nothing written to
276
+ disk (the archive is read in memory). It does not check store listing
277
+ metadata, Data safety answers, whether a declared permission use is
278
+ acceptable under Play policy, or SDK signatures.
279
+
280
+ Exit codes: 0 no blocker (with `--fail-on warning`, no warning either),
281
+ 1 at least one blocker (or warning), 3 the file cannot be analysed.
282
+
240
283
  ## Dependency check (SCA)
241
284
 
242
285
  The dependency check sends package names and versions (never code, file paths or findings) to Narvy's EU-hosted vulnerability database. Set OSV_API_URL to use another OSV-compatible server.
@@ -262,8 +305,8 @@ in the scan's coverage note. Results are cached for 7 days in
262
305
 
263
306
  ## Telemetry
264
307
 
265
- The CLI sends one small anonymous event per command, so we can see which
266
- scan types are used and where they fail. It is sent in the background with
308
+ The CLI sends one small anonymous event per command (`store-check` sends
309
+ none), so we can see which scan types are used and where they fail. It is sent in the background with
267
310
  a 1 second timeout, is never retried or stored for later, and never fails a
268
311
  scan. At most it adds 1 second at exit. A notice is printed the first time it runs.
269
312
 
@@ -23,6 +23,11 @@ access or your own existing credentials:
23
23
  - `cloud-scan` - AWS account posture check using your own ambient AWS
24
24
  credentials
25
25
 
26
+ and one check that is not a security scan:
27
+
28
+ - `store-check` - Google Play and App Store upload requirements for an
29
+ APK, AAB, split-APK set or IPA, offline (see [Store check](#store-check))
30
+
26
31
  Your code, binaries and findings are never uploaded unless you explicitly
27
32
  pass `--upload`. The CLI does send anonymous usage statistics, described
28
33
  exactly in [Telemetry](#telemetry), which you can turn off.
@@ -148,6 +153,10 @@ narvy scans
148
153
 
149
154
  # Scan + upload every .apk/.aab/.apkm/.xapk/.ipa found in a folder, in one command
150
155
  narvy upload-all ./my-apps-folder/
156
+
157
+ # Google Play / App Store upload requirements, offline (not a security scan)
158
+ narvy store-check app-release.aab
159
+ narvy store-check MyApp.ipa --lang fr --json
151
160
  ```
152
161
 
153
162
  Run `narvy doctor` before your first scan to check your Java/jadx/iOS
@@ -204,6 +213,40 @@ exhaust the machine. `NARVY_SEMGREP_JOBS` and `NARVY_SEMGREP_MAX_MEMORY_MB`
204
213
  listed in the scan notes. Outside a terminal (CI logs), a progress line is
205
214
  printed every 30 seconds; `NARVY_PROGRESS_INTERVAL` changes that (0 = off).
206
215
 
216
+ ## Store check
217
+
218
+ `narvy store-check` answers one question: will Google Play or the App Store
219
+ accept this build, and what will they ask about it? It reads the manifest,
220
+ resources, native libraries, Info.plist, Mach-O load commands, imported
221
+ symbols and privacy manifests of the file, and evaluates them against the
222
+ store requirements in force at a date (`--as-of`, default today).
223
+
224
+ ```bash
225
+ narvy store-check app-release.aab
226
+ narvy store-check app.xapk --fail-on warning # CI gate on warnings too
227
+ narvy store-check MyApp.ipa --json > store.json # EN and FR messages in one report
228
+ narvy store-check MyApp.ipa --as-of 2027-02-01 # evaluate a future deadline
229
+ ```
230
+
231
+ | Store | Checks |
232
+ |-------|--------|
233
+ | Google Play | `PLAY-TARGET-SDK`, `PLAY-16KB-ELF`, `PLAY-16KB-ZIPALIGN`, `PLAY-EXPORTED`, `PLAY-DEBUGGABLE`, `PLAY-CLEARTEXT`, `PLAY-FGS-TYPE`, `PLAY-FGS-PERMISSION`, `PLAY-FGS-DECLARATION`, `PLAY-PERMISSION-DECLARATION` |
234
+ | App Store | `APPSTORE-SDK-VERSION`, `APPSTORE-MIN-OS`, `APPSTORE-REQUIRED-REASON-API`, `APPSTORE-PRIVACY-MANIFEST`, `APPSTORE-THIRD-PARTY-SDK`, `APPSTORE-ATS`, `APPSTORE-PURPOSE-STRINGS` |
235
+
236
+ Each check reports `fail` (severity `blocker`, `warning` or `info`),
237
+ `pass`, `not_determined` (the binary does not settle the question) or
238
+ `not_applicable`, with the evidence read from the file and the official
239
+ page the rule comes from. Store values and their sources live in
240
+ `narvy/store_check/data/`.
241
+
242
+ It runs offline: no network call, no telemetry event, nothing written to
243
+ disk (the archive is read in memory). It does not check store listing
244
+ metadata, Data safety answers, whether a declared permission use is
245
+ acceptable under Play policy, or SDK signatures.
246
+
247
+ Exit codes: 0 no blocker (with `--fail-on warning`, no warning either),
248
+ 1 at least one blocker (or warning), 3 the file cannot be analysed.
249
+
207
250
  ## Dependency check (SCA)
208
251
 
209
252
  The dependency check sends package names and versions (never code, file paths or findings) to Narvy's EU-hosted vulnerability database. Set OSV_API_URL to use another OSV-compatible server.
@@ -229,8 +272,8 @@ in the scan's coverage note. Results are cached for 7 days in
229
272
 
230
273
  ## Telemetry
231
274
 
232
- The CLI sends one small anonymous event per command, so we can see which
233
- scan types are used and where they fail. It is sent in the background with
275
+ The CLI sends one small anonymous event per command (`store-check` sends
276
+ none), so we can see which scan types are used and where they fail. It is sent in the background with
234
277
  a 1 second timeout, is never retried or stored for later, and never fails a
235
278
  scan. At most it adds 1 second at exit. A notice is printed the first time it runs.
236
279
 
@@ -1,3 +1,3 @@
1
1
  """Narvy CLI - free, local SAST for Android, iOS and polyglot source."""
2
2
 
3
- __version__ = "1.1.5"
3
+ __version__ = "1.2.0"
@@ -41,6 +41,7 @@ from . import decompiler as _decompiler
41
41
  from .apk_memory_preflight import is_auto as is_auto_mem
42
42
  from .rule_engine import load_rules_from_dir, run_rules_on_file
43
43
  from . import auth, own_reports, uploader, telemetry
44
+ from . import secret_output
44
45
  from .third_party_filter import get_own_package_roots, resolve_file_scope, check_android_override
45
46
  from .scope_config import load_scope_config
46
47
  from . import semgrep_engine
@@ -63,6 +64,7 @@ from .web.scan_blocklist import check_blocklist, ScanBlocked, REFUSAL_MESSAGE
63
64
  from .web.ssrf_guard import validate_url as web_validate_url, SSRFBlocked
64
65
  from .host.ssh_exec import HostConn, SSHExecError, _validate_conn
65
66
  from .host.audit import audit_host
67
+ from .store_check import cli as store_check_cli
66
68
  from .host.report import normalize_host_findings, severity_counts as host_severity_counts
67
69
  # cloud/aws_scan.py and its boto3 dependency are imported lazily inside
68
70
  # cmd_cloud_scan(): boto3 is an opt-in extra, not a dependency of the whole CLI.
@@ -158,7 +160,7 @@ def _print_raw_tool_output(label, text):
158
160
  if not text:
159
161
  return
160
162
  console.print(f"\n[dim]--- raw {label} output ---[/dim]")
161
- console.print(f"[dim]{rich_escape(text)}[/dim]")
163
+ console.print(f"[dim]{rich_escape(secret_output.sp.mask_text(text))}[/dim]")
162
164
  console.print("[dim]--- end raw output ---[/dim]\n")
163
165
 
164
166
 
@@ -1632,6 +1634,7 @@ def _scan_to_json(all_findings, rules, scan_mode, target_path, encrypted, scan_m
1632
1634
  "recommendation": details.get("recommendation"),
1633
1635
  "cwe": details.get("cwe"),
1634
1636
  "masvs": details.get("masvs"),
1637
+ **secret_output.json_secret_fields(f),
1635
1638
  })
1636
1639
  if f.get("original_severity"):
1637
1640
  out_findings[-1]["original_severity"] = f["original_severity"]
@@ -1847,6 +1850,11 @@ def cmd_scan(args):
1847
1850
  console.print(f"[dim]Ignored Narvy report file(s) from an earlier run: {rich_escape(names)}"
1848
1851
  f"{f' and {more} more' if more > 0 else ''}.[/dim]")
1849
1852
 
1853
+ # Honest secret wording + masking, once, on the final list: after every
1854
+ # severity decision, before the table, JSON, SARIF and the upload payload.
1855
+ # Narvy never tests a secret; no output (local or uploaded) carries a raw value.
1856
+ secret_output.present_cli_findings(all_findings, target_path, scan_mode)
1857
+
1850
1858
  # Say so when a hybrid repo (RN/Flutter/Capacitor) has code this mode
1851
1859
  # didn't cover, instead of exiting 0 silently.
1852
1860
  uncovered_notes = _uncovered_surface_notes(target_path, scan_mode,
@@ -2398,6 +2406,8 @@ def _web_to_sarif(findings):
2398
2406
  f.get("url", ""),
2399
2407
  f.get("severity"),
2400
2408
  ) for f in findings]
2409
+ for res, f in zip(results, findings):
2410
+ res["properties"].update(secret_output.sarif_secret_properties(f))
2401
2411
  return {
2402
2412
  "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
2403
2413
  "version": "2.1.0",
@@ -2464,6 +2474,8 @@ def cmd_web_scan(args):
2464
2474
  _print_raw_tool_output("nuclei", result.get("stderr"))
2465
2475
 
2466
2476
  findings = result.get("findings", [])
2477
+ # Honest secret wording + masking before any output (same list as result["findings"]).
2478
+ secret_output.present_web_findings(findings)
2467
2479
 
2468
2480
  if args.format == "json":
2469
2481
  output = json.dumps({"tool": "Narvy Web Scanner CLI", **result}, indent=2)
@@ -2718,6 +2730,23 @@ def _cloud_to_sarif(findings):
2718
2730
  }
2719
2731
 
2720
2732
 
2733
+ STORE_CHECK_EXIT_CODES_HELP = (
2734
+ "exit codes:\n"
2735
+ " 0 no blocker (with --fail-on warning: no warning either)\n"
2736
+ " 1 at least one blocker (with --fail-on warning: or at least one warning)\n"
2737
+ " 3 the file cannot be analysed (not found, not an APK, AAB, split-APK set\n"
2738
+ " or IPA, damaged archive) or an option is invalid\n"
2739
+ " 130 interrupted (Ctrl-C, SIGTERM)\n\n"
2740
+ "Offline: no network call, no telemetry, nothing written to disk. Store\n"
2741
+ "requirements are dated; --as-of evaluates them at another date."
2742
+ )
2743
+
2744
+
2745
+ def cmd_store_check(args, parser):
2746
+ """Store readiness checks (narvy.store_check). Not a security scan, no telemetry."""
2747
+ raise SystemExit(store_check_cli.run(args, parser, input_error_code=EXIT_BAD_TARGET))
2748
+
2749
+
2721
2750
  def cmd_cloud_scan(args):
2722
2751
  telemetry.note(surface="cloud")
2723
2752
  _reject_format_as_file(args.output_file)
@@ -2903,6 +2932,8 @@ def _safe_console_streams():
2903
2932
 
2904
2933
  def cli(prog=None):
2905
2934
  _safe_console_streams()
2935
+ # Mask provider-format tokens, PEM blocks, JWTs and URL passwords in any log record.
2936
+ secret_output.sp.install_log_masking()
2906
2937
  parser =_ArgumentParser(prog=prog, allow_abbrev=False, description=(
2907
2938
  "Narvy CLI - Static Analysis for Android APKs/AABs, iOS IPAs, "
2908
2939
  "Android/iOS source projects, and web/backend source projects "
@@ -3050,6 +3081,13 @@ def cli(prog=None):
3050
3081
  p_cloud.add_argument("--fail-on", choices=["critical", "high", "medium", "low", "any"], help=FAIL_ON_HELP)
3051
3082
  p_cloud.set_defaults(func=cmd_cloud_scan)
3052
3083
 
3084
+ p_store = sub.add_parser(
3085
+ "store-check", allow_abbrev=False, epilog=STORE_CHECK_EXIT_CODES_HELP,
3086
+ formatter_class=argparse.RawDescriptionHelpFormatter,
3087
+ help="Check an APK, AAB, XAPK or IPA against Google Play and App Store upload requirements (offline)")
3088
+ store_check_cli.add_arguments(p_store)
3089
+ p_store.set_defaults(func=lambda a: cmd_store_check(a, p_store))
3090
+
3053
3091
  args = parser.parse_args()
3054
3092
  # SIGTERM/SIGHUP (CI job cancel, docker stop, dropped SSH) take the Ctrl-C path so
3055
3093
  # child scanners (semgrep workers) are torn down instead of orphaned.
@@ -2,6 +2,7 @@ import json
2
2
  from typing import List, Dict, Any
3
3
 
4
4
  from narvy import __version__
5
+ from narvy.secret_output import sarif_secret_properties
5
6
 
6
7
  # Canonical SARIF severity mapping, shared by every emitter (scan + web/host/cloud).
7
8
  # level = SARIF's per-result rank; security-severity = GitHub/GitLab 0-10 code-scanning score.
@@ -57,7 +58,10 @@ def generate_sarif_report(findings: List[Dict[str, Any]], rules: List[Dict[str,
57
58
  "ruleId": finding['rule_id'],
58
59
  "level": _SARIF_LEVEL.get(_sev, "warning"),
59
60
  "message": {
60
- "text": finding['details']['description']
61
+ # A presented secret leads with its honest title ("Potential secret: ...
62
+ # (format match, not tested)"); every other finding keeps its description.
63
+ "text": (finding.get('title') if finding.get('secret_presentation')
64
+ and finding.get('title') else finding['details']['description'])
61
65
  },
62
66
  "locations": [{
63
67
  "physicalLocation": {
@@ -78,6 +82,7 @@ def generate_sarif_report(findings: List[Dict[str, Any]], rules: List[Dict[str,
78
82
  "prngContextEvidence": finding['details'].get('prng_context_evidence', '')}
79
83
  if isinstance(finding.get('details'), dict)
80
84
  and finding['details'].get('prng_context') else {}),
85
+ **sarif_secret_properties(finding),
81
86
  }
82
87
  })
83
88
 
@@ -0,0 +1,232 @@
1
+ """CLI wiring of narvy/secret_presentation.py (honest secret wording + masking).
2
+
3
+ narvy/secret_presentation.py is a byte-identical copy of the hosted engine's
4
+ module, so the CLI and the dashboard word and mask a secret the same way.
5
+ This file only adapts the CLI finding shape to it:
6
+
7
+ * CLI findings keep description/cwe/recommendation in a nested ``details``
8
+ dict. The hosted ingest (cli_ingest.normalize_finding) lifts them to the top
9
+ level before fingerprinting, so the same lift happens here first: the
10
+ classifier then sees what the server sees, and ``fingerprint_mask_basis`` is
11
+ taken from exactly the inputs the server's fingerprint used before masking
12
+ existed (no fixed/new churn on the dashboard).
13
+ * CLI findings carry no snippet. To recognise the provider format (and so
14
+ word "format match" vs "pattern match", and spot public client keys) the
15
+ matched line is read from disk into a transient ``line_content`` key, used
16
+ by the classifier and removed again. It never reaches any output.
17
+ * Terminal, JSON and SARIF read ``details``; the honest, masked wording is
18
+ copied back there. Severity stays upper-case (the CLI convention).
19
+
20
+ Runs once per scan, after every severity decision (secret_value_grade) and
21
+ before any output or upload.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import os
26
+ from typing import Any, Dict, Iterable, List, Optional
27
+
28
+ from . import secret_presentation as sp
29
+
30
+ __all__ = ["present_cli_finding", "present_cli_findings", "present_web_findings",
31
+ "json_secret_fields", "sarif_secret_properties", "sp"]
32
+
33
+ _PEM_BEGIN = "PRIVATE KEY-----"
34
+ _PEM_MAX_LINES = 120
35
+
36
+ # Fields of a presented finding the local JSON report carries.
37
+ JSON_SECRET_FIELDS = (
38
+ "title", "title_fr", "description_fr", "remediation", "remediation_fr",
39
+ "severity_rationale", "severity_rationale_fr", "secret_confidence", "secret_tested",
40
+ "secret_public_by_design", "secret_kind", "secret_match_basis", "original_severity",
41
+ )
42
+
43
+
44
+ def _lift(f: Dict[str, Any], key: str, value: Any, added: List[str]) -> None:
45
+ if value in (None, "", [], {}):
46
+ return
47
+ if f.get(key) in (None, "", [], {}):
48
+ f[key] = value
49
+ added.append(key)
50
+
51
+
52
+ def _resolve(file_path: str, root: Optional[str]) -> Optional[str]:
53
+ if not file_path:
54
+ return None
55
+ if os.path.isabs(file_path):
56
+ return file_path if os.path.isfile(file_path) else None
57
+ if root:
58
+ base = root if os.path.isdir(root) else os.path.dirname(root)
59
+ p = os.path.join(base, file_path)
60
+ if os.path.isfile(p):
61
+ return p
62
+ return None
63
+
64
+
65
+ def _matched_text(f: Dict[str, Any], root: Optional[str],
66
+ cache: Dict[str, Optional[List[str]]]) -> Optional[str]:
67
+ """The finding's source line (a whole PEM block when the line opens one)."""
68
+ try:
69
+ line_no = int(f.get("line") or 0)
70
+ except (TypeError, ValueError):
71
+ return None
72
+ path = _resolve(str(f.get("file_path") or ""), root)
73
+ if not path or line_no <= 0:
74
+ return None
75
+ if path not in cache:
76
+ try:
77
+ with open(path, "r", encoding="utf-8", errors="ignore") as fh:
78
+ cache[path] = fh.read().splitlines()
79
+ except OSError:
80
+ cache[path] = None
81
+ lines = cache[path]
82
+ if not lines or line_no > len(lines):
83
+ return None
84
+ text = lines[line_no - 1]
85
+ if _PEM_BEGIN in text and "BEGIN" in text:
86
+ block = [text]
87
+ for nxt in lines[line_no:line_no + _PEM_MAX_LINES]:
88
+ block.append(nxt)
89
+ if "END" in nxt and _PEM_BEGIN in nxt:
90
+ break
91
+ text = "\n".join(block)
92
+ return text
93
+
94
+
95
+ # CLI scan mode -> the {platform, scan_mode} the hosted engine files the upload
96
+ # under (cli_ingest: binary vs source), so the wording ("app package" vs
97
+ # "source code") and the client-context call match the dashboard's.
98
+ _SCAN_CONTEXT = {
99
+ "android": {"platform": "android", "scan_mode": "binary"},
100
+ "android-bundle": {"platform": "android", "scan_mode": "binary"},
101
+ "ios-binary": {"platform": "ios", "scan_mode": "binary"},
102
+ "android-source": {"platform": "android", "scan_mode": "source"},
103
+ "ios-source": {"platform": "ios", "scan_mode": "source"},
104
+ "web-source": {"platform": "web", "scan_mode": "source"},
105
+ }
106
+
107
+
108
+ def scan_context(scan_mode: Optional[str]) -> Optional[Dict[str, str]]:
109
+ return _SCAN_CONTEXT.get(str(scan_mode or ""))
110
+
111
+
112
+ def _present(f: Dict[str, Any], context: Optional[Dict[str, Any]]) -> bool:
113
+ return sp.present_finding(f, context) if context else sp.present_finding(f)
114
+
115
+
116
+ def present_cli_finding(f: Dict[str, Any], root: Optional[str] = None,
117
+ cache: Optional[Dict[str, Optional[List[str]]]] = None,
118
+ context: Optional[Dict[str, Any]] = None) -> bool:
119
+ """Reword + mask one CLI finding in place. Returns True when it changed."""
120
+ if not isinstance(f, dict):
121
+ return False
122
+ cache = {} if cache is None else cache
123
+ details = f.get("details")
124
+ if isinstance(details, dict):
125
+ # Rule definitions share one details dict across all their findings
126
+ # (and with the SARIF rule descriptors): never write into it.
127
+ details = dict(details)
128
+ f["details"] = details
129
+ added: List[str] = []
130
+ if isinstance(details, dict):
131
+ _lift(f, "description", details.get("description"), added)
132
+ _lift(f, "cwe", details.get("cwe"), added)
133
+ elif isinstance(details, str):
134
+ _lift(f, "description", details, added)
135
+
136
+ transient = False
137
+ view = dict(f, **{k: v for k, v in (context or {}).items() if not f.get(k)})
138
+ if sp.is_secret_finding(view) and not f.get("secret_presentation"):
139
+ b = f.get("fingerprint_mask_basis")
140
+ if not (isinstance(b, dict) and b.get("scheme") == sp.MASK_BASIS_SCHEME):
141
+ # Taken before line_content exists: the server never saw that line.
142
+ f["fingerprint_mask_basis"] = sp.fingerprint_basis_of(f)
143
+ if "line_content" not in f:
144
+ text = _matched_text(f, root, cache)
145
+ if text:
146
+ f["line_content"] = text
147
+ transient = True
148
+ try:
149
+ changed = _present(f, context)
150
+ finally:
151
+ if transient:
152
+ f.pop("line_content", None)
153
+
154
+ if not changed and not f.get("secret_presentation"):
155
+ for k in added: # nothing to present: leave the finding as it was
156
+ f.pop(k, None)
157
+ return False
158
+
159
+ if isinstance(f.get("severity"), str):
160
+ f["severity"] = f["severity"].upper()
161
+ if isinstance(f.get("original_severity"), str):
162
+ f["original_severity"] = f["original_severity"].upper()
163
+ details = f.get("details") # present_finding may have replaced it (masked copy)
164
+ if isinstance(details, dict) and f.get("secret_presentation"):
165
+ details["description"] = f.get("description") or details.get("description")
166
+ if isinstance(f.get("remediation"), str):
167
+ details["recommendation"] = f["remediation"]
168
+ return True
169
+
170
+
171
+ def present_cli_findings(findings: Iterable[Dict[str, Any]], root: Optional[str] = None,
172
+ scan_mode: Optional[str] = None) -> int:
173
+ """present_cli_finding over a scan's final findings. Never raises."""
174
+ cache: Dict[str, Optional[List[str]]] = {}
175
+ context = scan_context(scan_mode)
176
+ n = 0
177
+ for f in findings or []:
178
+ try:
179
+ if present_cli_finding(f, root, cache, context):
180
+ n += 1
181
+ except Exception: # noqa: BLE001 - a presentation error must never lose a finding
182
+ try:
183
+ _mask_only(f)
184
+ except Exception: # noqa: BLE001
185
+ pass
186
+ return n
187
+
188
+
189
+ def _mask_only(f: Dict[str, Any]) -> None:
190
+ """Fallback when wording failed: at least never show a format-matched value."""
191
+ for k, v in list(f.items()):
192
+ if isinstance(v, str) and k not in ("file_path", "rule_id", "severity"):
193
+ f[k] = sp.mask_text(v)
194
+ elif isinstance(v, dict):
195
+ f[k] = {kk: sp.mask_text(vv) if isinstance(vv, str) else vv for kk, vv in v.items()}
196
+
197
+
198
+ def present_web_findings(findings: Iterable[Dict[str, Any]]) -> int:
199
+ """web-scan findings: everything nuclei sees was served to browsers, so a
200
+ public-by-design key there is client context (same as the hosted web scanner)."""
201
+ n = 0
202
+ for f in findings or []:
203
+ if not isinstance(f, dict):
204
+ continue
205
+ try:
206
+ f.setdefault("secret_client_context", True)
207
+ if sp.present_finding(f):
208
+ n += 1
209
+ except Exception: # noqa: BLE001
210
+ try:
211
+ _mask_only(f)
212
+ except Exception: # noqa: BLE001
213
+ pass
214
+ return n
215
+
216
+
217
+ def json_secret_fields(f: Dict[str, Any]) -> Dict[str, Any]:
218
+ """Extra keys for the local JSON report (never secret_sha256 or basis internals)."""
219
+ if not f.get("secret_presentation"):
220
+ return {}
221
+ return {k: f[k] for k in JSON_SECRET_FIELDS if k in f}
222
+
223
+
224
+ def sarif_secret_properties(f: Dict[str, Any]) -> Dict[str, Any]:
225
+ if not f.get("secret_presentation"):
226
+ return {}
227
+ out: Dict[str, Any] = {"secret_tested": False}
228
+ for k in ("secret_confidence", "secret_public_by_design", "secret_kind",
229
+ "secret_match_basis", "original_severity"):
230
+ if f.get(k) not in (None, ""):
231
+ out[k] = f[k]
232
+ return out