narvy-cli 1.0.2__tar.gz → 1.0.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 (102) hide show
  1. {narvy_cli-1.0.2/narvy_cli.egg-info → narvy_cli-1.0.3}/PKG-INFO +1 -1
  2. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/__init__.py +1 -1
  3. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/android/source_analyzer.py +99 -0
  4. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/main.py +258 -19
  5. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/python.yml +3 -5
  6. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/secrets.yml +9 -0
  7. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/secrets_supplement.yml +8 -4
  8. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/sca/web_deps.py +205 -4
  9. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/source_analyzer.py +276 -54
  10. {narvy_cli-1.0.2 → narvy_cli-1.0.3/narvy_cli.egg-info}/PKG-INFO +1 -1
  11. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/CHANGELOG.md +0 -0
  12. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/LICENSE +0 -0
  13. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/MANIFEST.in +0 -0
  14. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/README.md +0 -0
  15. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/SECURITY.md +0 -0
  16. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/android/__init__.py +0 -0
  17. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/android/split_bundle.py +0 -0
  18. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/android_rule_context.py +0 -0
  19. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/apk_memory_preflight.py +0 -0
  20. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/auth.py +0 -0
  21. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ci_templates/bitbucket.yml +0 -0
  22. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ci_templates/github.yml +0 -0
  23. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ci_templates/gitlab.yml +0 -0
  24. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/cloud/__init__.py +0 -0
  25. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/cloud/aws_scan.py +0 -0
  26. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/comment_filter.py +0 -0
  27. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/crypto_taint_lite.py +0 -0
  28. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/decompiler.py +0 -0
  29. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/doctor.py +0 -0
  30. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/__init__.py +0 -0
  31. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/audit.py +0 -0
  32. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/host_knowledge.py +0 -0
  33. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/lynis_bootstrap.py +0 -0
  34. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/lynis_parser.py +0 -0
  35. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/narvy_checks.py +0 -0
  36. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/report.py +0 -0
  37. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/ssh_exec.py +0 -0
  38. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/__init__.py +0 -0
  39. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/binary_analyzer.py +0 -0
  40. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/plist_checks.py +0 -0
  41. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/source_analyzer.py +0 -0
  42. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/third_party_filter.py +0 -0
  43. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/trust_all_context.py +0 -0
  44. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/native_hardening.py +0 -0
  45. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/reporter.py +0 -0
  46. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rule_engine.py +0 -0
  47. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android/config.yml +0 -0
  48. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android/crypto.yml +0 -0
  49. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android/secrets.yml +0 -0
  50. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android/storage.yml +0 -0
  51. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android/webview.yml +0 -0
  52. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android.yml +0 -0
  53. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios/objc/crypto.yml +0 -0
  54. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios/objc/network.yml +0 -0
  55. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios/objc/secrets.yml +0 -0
  56. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios/objc/storage.yml +0 -0
  57. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios/objc/webview.yml +0 -0
  58. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios_swift.yml +0 -0
  59. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/go.yml +0 -0
  60. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/java.yml +0 -0
  61. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/javascript.yml +0 -0
  62. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/kotlin.yml +0 -0
  63. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/csharp_narvy/config.yml +0 -0
  64. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/csharp_narvy/crypto.yml +0 -0
  65. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/csharp_narvy/deserialization.yml +0 -0
  66. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/csharp_narvy/injection.yml +0 -0
  67. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/csharp_narvy/xxe.yml +0 -0
  68. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/auth_jwt.yml +0 -0
  69. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/deserialization.yml +0 -0
  70. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/mybatis.yml +0 -0
  71. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/snakeyaml.yml +0 -0
  72. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/spring_authz.yml +0 -0
  73. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/spring_config.yml +0 -0
  74. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/spring_hardening.yml +0 -0
  75. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/sqli.yml +0 -0
  76. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/xxe.yml +0 -0
  77. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/php.yml +0 -0
  78. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/ruby.yml +0 -0
  79. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/rust.yml +0 -0
  80. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/sca/__init__.py +0 -0
  81. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/sca/android_deps.py +0 -0
  82. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/sca/ios_deps.py +0 -0
  83. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/sca/osv_client.py +0 -0
  84. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/scope_config.py +0 -0
  85. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/semgrep_engine.py +0 -0
  86. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/stack_protector_evidence.py +0 -0
  87. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/third_party_filter.py +0 -0
  88. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/uploader.py +0 -0
  89. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/weak_prng_context.py +0 -0
  90. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/__init__.py +0 -0
  91. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/nuclei_binary.py +0 -0
  92. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/scan_blocklist.py +0 -0
  93. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/scanner.py +0 -0
  94. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/ssrf_guard.py +0 -0
  95. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy_cli.egg-info/SOURCES.txt +0 -0
  96. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy_cli.egg-info/dependency_links.txt +0 -0
  97. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy_cli.egg-info/entry_points.txt +0 -0
  98. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy_cli.egg-info/requires.txt +0 -0
  99. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy_cli.egg-info/top_level.txt +0 -0
  100. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/pyproject.toml +0 -0
  101. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/setup.cfg +0 -0
  102. {narvy_cli-1.0.2 → narvy_cli-1.0.3}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narvy-cli
3
- Version: 1.0.2
3
+ Version: 1.0.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
@@ -1,3 +1,3 @@
1
1
  """Narvy CLI - free, local SAST for Android, iOS and polyglot source."""
2
2
 
3
- __version__ = "1.0.2"
3
+ __version__ = "1.0.3"
@@ -35,6 +35,102 @@ _SKIP_DIR_NAMES = frozenset({
35
35
  _NAMESPACE_RE = re.compile(r'\bnamespace\s*=?\s*["\']([\w.]+)["\']')
36
36
  _APPLICATION_ID_RE = re.compile(r'\bapplicationId\s*=?\s*["\']([\w.]+)["\']')
37
37
 
38
+ # Name-based hardcoded-secret rules prone to preference-key / type-name echoes.
39
+ _SECRET_NAME_RULE_IDS = frozenset({
40
+ "AND-S-001", "AND-S-002", "AND-S-010",
41
+ "android-hardcoded-credential-java", "android-hardcoded-credential-kotlin",
42
+ })
43
+ _TYPE_DECL_RE = re.compile(r'\b(?:class|interface|object|enum)\b\s+[A-Za-z_]')
44
+ _QUOTED_RE = re.compile(r'"([^"\n]*)"')
45
+ _IDENT_VALUE_RE = re.compile(r'^[a-z][a-z0-9_]*$')
46
+ _LHS_NAME_RE = re.compile(r'([A-Za-z_][A-Za-z0-9_]*)\s*(?::[^=\n]*)?=\s*"')
47
+ # Preference-key constant naming (KEY_.../PREF_...): its value is a settings-store
48
+ # key string, not a secret.
49
+ _PREFKEY_CONST_RE = re.compile(r'^(?:KEY|PREF|PREFS|PREFERENCE|SETTING|SETTINGS)_', re.IGNORECASE)
50
+
51
+ # Test / fixture locations where a headline CRITICAL/HIGH is usually a fixture.
52
+ _TEST_PATH_FRAGMENTS = (
53
+ "/test/", "/tests/", "/androidtest/", "/__tests__/", "/spec/", "/specs/",
54
+ "/fixtures/", "/fixture/", "/testdata/", "/mocks/", "/__mocks__/",
55
+ )
56
+ _TEST_FILE_RE = re.compile(
57
+ r"(?:^|[._-])(?:test|tests|spec|specs|mock|mocks|fixture|fixtures)(?:[._-]|$)",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+
62
+ def _line_text(source_dir: str, rel_path: str, line: int) -> str:
63
+ try:
64
+ full = os.path.join(source_dir, rel_path)
65
+ with open(full, "r", encoding="utf-8", errors="ignore") as fh:
66
+ for i, text in enumerate(fh, start=1):
67
+ if i == line:
68
+ return text
69
+ except OSError:
70
+ pass
71
+ return ""
72
+
73
+
74
+ def _is_prefkey_echo_or_typename(line: str) -> bool:
75
+ """A preference-key-name literal echoing its constant, or a class/type name - not a real secret."""
76
+ if '"' not in line and _TYPE_DECL_RE.search(line):
77
+ return True
78
+ m = _QUOTED_RE.search(line)
79
+ if not m:
80
+ return False
81
+ val = m.group(1)
82
+ if not _IDENT_VALUE_RE.match(val):
83
+ return False
84
+ lhs = _LHS_NAME_RE.search(line)
85
+ if not lhs:
86
+ return False
87
+ name = lhs.group(1)
88
+ # Echo: the value repeats the constant name, or the constant is a KEY_*/PREF_*
89
+ # preference-key holding a settings-store key slug (e.g. KEY_SECRET_TOKEN =
90
+ # "secret_token_pref").
91
+ return val.lower() in name.lower() or bool(_PREFKEY_CONST_RE.match(name))
92
+
93
+
94
+ def _has_no_string_literal_rhs(line: str) -> bool:
95
+ """A name-based secret match whose right-hand side has NO string literal - a
96
+ function call or identifier (e.g. `val apiKey = getApiKeyOrDefault()`), not a
97
+ hardcoded secret. A real hardcoded secret is always a quoted literal."""
98
+ return not _QUOTED_RE.search(line)
99
+
100
+
101
+ def _drop_prefkey_echo_secrets(source_dir: str, findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
102
+ kept = []
103
+ for f in findings:
104
+ if f.get("rule_id") in _SECRET_NAME_RULE_IDS:
105
+ line = _line_text(source_dir, f.get("file_path", ""), f.get("line") or 0)
106
+ if line and (_has_no_string_literal_rhs(line) or _is_prefkey_echo_or_typename(line)):
107
+ continue
108
+ kept.append(f)
109
+ return kept
110
+
111
+
112
+ def _is_test_path(rel_path: str) -> bool:
113
+ p = "/" + rel_path.replace("\\", "/").lstrip("/")
114
+ if any(frag in p.lower() for frag in _TEST_PATH_FRAGMENTS):
115
+ return True
116
+ return bool(_TEST_FILE_RE.search(p.rsplit("/", 1)[-1]))
117
+
118
+
119
+ def _downrank_test_findings(findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
120
+ """Lower (never drop) CRITICAL/HIGH/MEDIUM findings in test/fixture paths to LOW and tag them."""
121
+ note = (
122
+ " [Narvy: this hit is in a test/fixture path, where such code is usually a "
123
+ "fixture rather than shipped app code, so severity is lowered - confirm it "
124
+ "is not a real production issue.]"
125
+ )
126
+ for f in findings:
127
+ if f.get("severity") in ("CRITICAL", "HIGH", "MEDIUM") and _is_test_path(f.get("file_path", "")):
128
+ f["severity"] = "LOW"
129
+ details = f.setdefault("details", {})
130
+ if isinstance(details, dict) and note.strip() not in (details.get("description") or ""):
131
+ details["description"] = (details.get("description") or "") + note
132
+ return findings
133
+
38
134
  # A module may use either source-root name, or both.
39
135
  _SRC_LEAF_DIRS = ("java", "kotlin")
40
136
  _MAX_INFER_DEPTH = 12
@@ -254,6 +350,9 @@ def analyze_source(source_dir: str, override_config: Optional[ScopeConfig] = Non
254
350
 
255
351
  rule_defs = list(rules) + semgrep_rule_defs
256
352
 
353
+ all_findings = _drop_prefkey_echo_secrets(source_dir, all_findings)
354
+ all_findings = _downrank_test_findings(all_findings)
355
+
257
356
  return {
258
357
  "ok": True,
259
358
  "error": None,
@@ -132,7 +132,20 @@ _WEB_SOURCE_MARKER_FILES = (
132
132
  _WEB_SOURCE_MARKER_SUFFIXES = (
133
133
  ".gemspec", ".csproj", ".fsproj", ".vbproj", ".sln", ".slnx",
134
134
  )
135
- _WEB_SOURCE_DETECT_MAX_DEPTH = 3
135
+ # Fallback when a project ships no manifest at all (a classic PHP app is often
136
+ # just .php files, no composer.json). Only reached after the iOS-source and
137
+ # Android-source checks return False, so these extensions mean web/backend source.
138
+ _WEB_SOURCE_CODE_EXTS = (
139
+ ".php", ".py", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx",
140
+ ".go", ".rb", ".rs", ".java", ".cs", ".vb", ".fs",
141
+ )
142
+ # 6 to match the iOS/Android detectors: monorepos nest a service's manifest
143
+ # several levels deep (org/team/service/src), so a shallow cap missed them.
144
+ _WEB_SOURCE_DETECT_MAX_DEPTH = 6
145
+
146
+ # .NET manifests. A cross-platform .NET app (MAUI/Xamarin) ships a generated
147
+ # AndroidManifest.xml but is C# at heart, so it must not route to android-source.
148
+ _DOTNET_PROJECT_SUFFIXES = (".sln", ".slnx", ".csproj", ".fsproj", ".vbproj")
136
149
 
137
150
 
138
151
  def _looks_like_web_source_dir(path: str) -> bool:
@@ -146,7 +159,9 @@ def _looks_like_web_source_dir(path: str) -> bool:
146
159
  dirs[:] = [d for d in dirs if d not in _ANDROID_SKIP_DIR_NAMES and not d.startswith(".")
147
160
  and d not in ("node_modules", "vendor")]
148
161
  for f in files:
149
- if f in _WEB_SOURCE_MARKER_FILES or f.endswith(_WEB_SOURCE_MARKER_SUFFIXES):
162
+ if (f in _WEB_SOURCE_MARKER_FILES
163
+ or f.endswith(_WEB_SOURCE_MARKER_SUFFIXES)
164
+ or f.endswith(_WEB_SOURCE_CODE_EXTS)):
150
165
  return True
151
166
  return False
152
167
 
@@ -173,20 +188,19 @@ def _looks_like_ios_source_dir(path: str) -> bool:
173
188
 
174
189
 
175
190
  def _has_ios_project_marker(path: str) -> bool:
176
- """Strong iOS signal: an Xcode project/workspace or SwiftPM Package.swift (a bare .swift/.m file is not enough)."""
177
- base_depth = path.rstrip(os.sep).count(os.sep)
178
- for root, dirs, files in os.walk(path):
179
- depth = root.rstrip(os.sep).count(os.sep) - base_depth
180
- if depth >= _IOS_DETECT_MAX_DEPTH:
181
- dirs[:] = []
182
- continue
183
- dirs[:] = [d for d in dirs if d not in IOS_VENDOR_DIR_NAMES and d not in (".git", "node_modules")]
184
- for d in dirs:
185
- if d.endswith(_IOS_SOURCE_MARKER_EXTS):
186
- return True
187
- for f in files:
188
- if f in _IOS_SOURCE_MARKER_FILES:
189
- return True
191
+ """Strong iOS signal: an Xcode project/workspace or SwiftPM Package.swift AT
192
+ THE SCAN ROOT only. A .xcodeproj buried under examples/ (a mobile sample
193
+ inside a web/TS repo) must NOT flip the whole repo to iOS - a root-level web
194
+ manifest then wins, and a genuinely iOS-only project whose .xcodeproj is
195
+ nested is still caught by the weak _looks_like_ios_source_dir fallback that
196
+ runs after the web-source check."""
197
+ try:
198
+ entries = os.listdir(path)
199
+ except OSError:
200
+ return False
201
+ for e in entries:
202
+ if e.endswith(_IOS_SOURCE_MARKER_EXTS) or e in _IOS_SOURCE_MARKER_FILES:
203
+ return True
190
204
  return False
191
205
 
192
206
 
@@ -203,15 +217,82 @@ def _file_head_contains(path: str, needles, regex=None) -> bool:
203
217
  return bool(regex and regex.search(head))
204
218
 
205
219
 
220
+ def _has_android_gradle_marker(path: str) -> bool:
221
+ """True if path holds an Android Gradle build (AGP plugin id, `android {}` DSL, or AndroidX property)."""
222
+ base_depth = path.rstrip(os.sep).count(os.sep)
223
+ for root, dirs, files in os.walk(path):
224
+ depth = root.rstrip(os.sep).count(os.sep) - base_depth
225
+ if depth >= _ANDROID_DETECT_MAX_DEPTH:
226
+ dirs[:] = []
227
+ continue
228
+ dirs[:] = [d for d in dirs if d not in _ANDROID_SKIP_DIR_NAMES and not d.startswith(".")]
229
+ for f in files:
230
+ if f.endswith(_ANDROID_GRADLE_FILE_SUFFIXES) or f == _ANDROID_VERSION_CATALOG_FILENAME:
231
+ if _file_head_contains(os.path.join(root, f), _ANDROID_GRADLE_PLUGIN_IDS,
232
+ _ANDROID_GRADLE_DSL_RE):
233
+ return True
234
+ elif f in _ANDROID_MARKER_PROPERTY_FILES:
235
+ if _file_head_contains(os.path.join(root, f), _ANDROID_MARKER_PROPERTIES):
236
+ return True
237
+ return False
238
+
239
+
240
+ def _has_android_manifest(path: str) -> bool:
241
+ """True if an AndroidManifest.xml exists anywhere in the (pruned) tree."""
242
+ base_depth = path.rstrip(os.sep).count(os.sep)
243
+ for root, dirs, files in os.walk(path):
244
+ depth = root.rstrip(os.sep).count(os.sep) - base_depth
245
+ if depth >= _ANDROID_DETECT_MAX_DEPTH:
246
+ dirs[:] = []
247
+ continue
248
+ dirs[:] = [d for d in dirs if d not in _ANDROID_SKIP_DIR_NAMES and not d.startswith(".")]
249
+ if _ANDROID_MANIFEST_FILENAME in files:
250
+ return True
251
+ return False
252
+
253
+
254
+ def _has_dotnet_project_marker(path: str) -> bool:
255
+ """True if a .NET solution/project file exists (.sln/.csproj/.fsproj/.vbproj)."""
256
+ base_depth = path.rstrip(os.sep).count(os.sep)
257
+ for root, dirs, files in os.walk(path):
258
+ depth = root.rstrip(os.sep).count(os.sep) - base_depth
259
+ if depth >= _ANDROID_DETECT_MAX_DEPTH:
260
+ dirs[:] = []
261
+ continue
262
+ dirs[:] = [d for d in dirs if d not in _ANDROID_SKIP_DIR_NAMES and not d.startswith(".")]
263
+ for f in files:
264
+ if f.endswith(_DOTNET_PROJECT_SUFFIXES):
265
+ return True
266
+ return False
267
+
268
+
206
269
  def _looks_like_android_source_dir(path: str) -> bool:
207
270
  """True if path holds an AndroidManifest.xml or an Android Gradle marker."""
208
- base_depth = path.rstrip(os.sep).count(os.sep)
271
+ return _has_android_manifest(path) or _has_android_gradle_marker(path)
272
+
273
+
274
+ def _has_android_marker_outside_android_dirs(path: str) -> bool:
275
+ """True if an AndroidManifest.xml or Android Gradle marker is reachable
276
+ WITHOUT passing through a directory segment named 'android'. A native Android
277
+ project puts markers at the root / app/ (a Gradle project that IS the root);
278
+ a RN/Capacitor monorepo confines every native marker under an android/ dir
279
+ (the top-level android/, or a per-module modules/x/android/)."""
280
+ base = path.rstrip(os.sep)
281
+ base_depth = base.count(os.sep)
282
+
283
+ def outside_android(root: str) -> bool:
284
+ rel = os.path.relpath(root, path)
285
+ segs = [] if rel == "." else rel.split(os.sep)
286
+ return "android" not in segs
287
+
209
288
  for root, dirs, files in os.walk(path):
210
289
  depth = root.rstrip(os.sep).count(os.sep) - base_depth
211
290
  if depth >= _ANDROID_DETECT_MAX_DEPTH:
212
291
  dirs[:] = []
213
292
  continue
214
293
  dirs[:] = [d for d in dirs if d not in _ANDROID_SKIP_DIR_NAMES and not d.startswith(".")]
294
+ if not outside_android(root):
295
+ continue
215
296
  if _ANDROID_MANIFEST_FILENAME in files:
216
297
  return True
217
298
  for f in files:
@@ -225,6 +306,20 @@ def _looks_like_android_source_dir(path: str) -> bool:
225
306
  return False
226
307
 
227
308
 
309
+ def _is_hybrid_web_primary(path: str) -> bool:
310
+ """A React Native / Capacitor monorepo: a root package.json plus real JS/TS
311
+ source, with every native Android marker confined under an android/ dir. The
312
+ JS/TS is the primary surface, so scan web-source and leave a loud note for
313
+ the uncovered native shell. A native Android app with a stray root
314
+ package.json keeps Android markers outside android/ (root / app/), so it is
315
+ NOT flipped."""
316
+ if not os.path.isfile(os.path.join(path, "package.json")):
317
+ return False
318
+ if not _has_code_outside(path, _JS_CODE_EXTS, _HYBRID_SKIP_DIRS):
319
+ return False
320
+ return not _has_android_marker_outside_android_dirs(path)
321
+
322
+
228
323
  def _looks_like_jvm_non_android_dir(path: str) -> bool:
229
324
  """Only valid after _looks_like_android_source_dir and _looks_like_web_source_dir said no."""
230
325
  base_depth = path.rstrip(os.sep).count(os.sep)
@@ -247,7 +342,17 @@ def _detect_scan_mode(target_path: str) -> str:
247
342
  return "ios-source"
248
343
  # Android/web manifests beat a stray .m/.mm/.swift file (e.g. a Cgo bridge in a Go repo).
249
344
  if _looks_like_android_source_dir(target_path):
250
- return "android-source"
345
+ # RN/Capacitor monorepo: native lives only in a nested android/ subdir
346
+ # while the primary JS/TS surface sits at the root. Scan the JS/TS.
347
+ if _is_hybrid_web_primary(target_path):
348
+ return "web-source"
349
+ # A .NET cross-platform project (MAUI/Xamarin) has an AndroidManifest.xml
350
+ # but no Android Gradle build; its C# code belongs in web-source, not the
351
+ # java/kotlin-only Android analyzer.
352
+ if _has_dotnet_project_marker(target_path) and not _has_android_gradle_marker(target_path):
353
+ pass
354
+ else:
355
+ return "android-source"
251
356
  if _looks_like_web_source_dir(target_path):
252
357
  return "web-source"
253
358
  if _looks_like_jvm_non_android_dir(target_path):
@@ -281,6 +386,106 @@ def _detect_scan_mode(target_path: str) -> str:
281
386
  )
282
387
 
283
388
 
389
+ _JS_CODE_EXTS = (".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx")
390
+ _HYBRID_SKIP_DIRS = frozenset({
391
+ "node_modules", ".git", "build", "dist", "out", ".gradle", "Pods",
392
+ "android", "ios", "vendor", "__pycache__", ".dart_tool",
393
+ })
394
+
395
+
396
+ def _has_code_outside(target_path: str, exts, skip_dirs, max_depth: int = 4) -> bool:
397
+ """True if a source file with one of `exts` exists outside `skip_dirs`."""
398
+ base_depth = target_path.rstrip(os.sep).count(os.sep)
399
+ for root, dirs, files in os.walk(target_path):
400
+ depth = root.rstrip(os.sep).count(os.sep) - base_depth
401
+ if depth >= max_depth:
402
+ dirs[:] = []
403
+ continue
404
+ dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]
405
+ if any(f.endswith(exts) for f in files):
406
+ return True
407
+ return False
408
+
409
+
410
+ def _subdir_has(target_path: str, subdir: str, exts, names=(), max_depth: int = 4) -> bool:
411
+ root_dir = os.path.join(target_path, subdir)
412
+ if not os.path.isdir(root_dir):
413
+ return False
414
+ base_depth = root_dir.rstrip(os.sep).count(os.sep)
415
+ for root, dirs, files in os.walk(root_dir):
416
+ depth = root.rstrip(os.sep).count(os.sep) - base_depth
417
+ if depth >= max_depth:
418
+ dirs[:] = []
419
+ continue
420
+ dirs[:] = [d for d in dirs if not d.startswith(".") and d not in ("build", "Pods")]
421
+ for f in files:
422
+ if f.endswith(exts) or f in names:
423
+ return True
424
+ for d in dirs:
425
+ if d.endswith((".xcodeproj", ".xcworkspace")):
426
+ return True
427
+ return False
428
+
429
+
430
+ def _uncovered_surface_notes(target_path: str, scan_mode: str) -> List[str]:
431
+ """Significant OTHER code surfaces present in a hybrid tree that the chosen
432
+ scan_mode does NOT cover (React Native / Flutter / Capacitor monorepos scan
433
+ only the native shell). Detection precedence is unchanged - this only makes
434
+ the gap loud instead of silent."""
435
+ if not os.path.isdir(target_path):
436
+ return []
437
+ notes: List[str] = []
438
+ covers_js = scan_mode == "web-source"
439
+ covers_android = scan_mode == "android-source"
440
+ covers_ios = scan_mode in ("ios-source", "ios-binary")
441
+
442
+ # JS/TS primary code (RN / Capacitor): root package.json + JS outside native shells.
443
+ if not covers_js and os.path.isfile(os.path.join(target_path, "package.json")) \
444
+ and _has_code_outside(target_path, _JS_CODE_EXTS, _HYBRID_SKIP_DIRS):
445
+ notes.append(
446
+ "JavaScript/TypeScript code (a root package.json and .js/.ts files) is "
447
+ "present but was NOT scanned - only the native shell was. Scan it with "
448
+ "`narvy scan <dir>/src` (or the JS/TS subdirectory)."
449
+ )
450
+ # Dart / Flutter primary code.
451
+ if scan_mode != "web-source" and os.path.isfile(os.path.join(target_path, "pubspec.yaml")) \
452
+ and _subdir_has(target_path, "lib", (".dart",)):
453
+ notes.append(
454
+ "Dart/Flutter code (pubspec.yaml and lib/*.dart) is present but was NOT "
455
+ "scanned - only the native shell was. Narvy has no Dart source analyzer; "
456
+ "scan the native android/ and ios/ shells separately for now."
457
+ )
458
+ # An ios/ tree not covered by an Android/web scan. Fall back to a tree-wide
459
+ # check for a hybrid whose native code is scattered (e.g. Expo modules/x/ios).
460
+ if not covers_ios:
461
+ if _subdir_has(target_path, "ios", (".swift", ".m", ".mm"), ("Info.plist",)):
462
+ notes.append(
463
+ "An ios/ project tree is present but was NOT scanned - this scan covered "
464
+ "a different surface. Scan it with `narvy scan <dir>/ios`."
465
+ )
466
+ elif scan_mode == "web-source" and _looks_like_ios_source_dir(target_path):
467
+ notes.append(
468
+ "Native iOS code (Swift/Obj-C) is present in this repo but was NOT "
469
+ "scanned - only the JS/TS surface was. Scan each native module or "
470
+ "the ios/ shell separately with `narvy scan <path>`."
471
+ )
472
+ # An android/ tree not covered by an iOS/web scan, with the same fallback.
473
+ if not covers_android:
474
+ if _subdir_has(target_path, "android", (".java", ".kt"),
475
+ ("AndroidManifest.xml", "build.gradle")):
476
+ notes.append(
477
+ "An android/ project tree is present but was NOT scanned - this scan "
478
+ "covered a different surface. Scan it with `narvy scan <dir>/android`."
479
+ )
480
+ elif scan_mode == "web-source" and _looks_like_android_source_dir(target_path):
481
+ notes.append(
482
+ "Native Android code (AndroidManifest.xml / *.kt / *.java) is present "
483
+ "in this repo but was NOT scanned - only the JS/TS surface was. Scan "
484
+ "each native module or the android/ shell separately with `narvy scan <path>`."
485
+ )
486
+ return notes
487
+
488
+
284
489
  def _positive_int(value):
285
490
  """argparse type: a whole number >= 1. Rejects 0 and negatives client-side
286
491
  with a clean exit-2 error rather than forwarding a nonsense --limit."""
@@ -565,17 +770,32 @@ def _sca_coverage(stats):
565
770
  reasons.append("osv_dependency_cap")
566
771
  if stats.get("github_rate_limited"):
567
772
  reasons.append("github_advisory_rate_limit")
773
+ # A manifest present in the tree whose deps never reached OSV (e.g. a Gemfile
774
+ # without a lockfile, an unsupported lock format) makes 'complete' a false
775
+ # all-clear: degrade to 'partial' and name what was left unresolved.
776
+ unparsed = stats.get("unparsed_manifests") or []
777
+ if unparsed:
778
+ reasons.append("unparsed_manifest")
568
779
  # An OSV outage / cold cache means "0 CVEs" is UNKNOWN, not clean: degrade, don't pass.
569
780
  unreachable = bool(stats.get("osv_unreachable"))
570
781
  if unreachable:
571
782
  reasons.append("osv_unreachable")
783
+ # 0 resolved dependencies is never 'complete' coverage: a manifest we don't
784
+ # yet parse (Gradle version catalogs, gemspec-only, pyproject without a lock)
785
+ # produces 0 deps, and claiming 'complete' there is a false all-clear.
786
+ if not detected and not unreachable and not reasons:
787
+ status = "none"
788
+ else:
789
+ status = "degraded" if unreachable else ("partial" if reasons else "complete")
572
790
  cov = {
573
- "status": "degraded" if unreachable else ("partial" if reasons else "complete"),
791
+ "status": status,
574
792
  "dependencies_detected": detected,
575
793
  "dependencies_checked": checked,
576
794
  }
577
795
  if reasons:
578
796
  cov["degraded_reasons"] = reasons
797
+ if unparsed:
798
+ cov["unparsed_manifests"] = unparsed
579
799
  if stats.get("osv_dependencies_unresolved"):
580
800
  cov["dependencies_unresolved"] = stats["osv_dependencies_unresolved"]
581
801
  return cov
@@ -982,6 +1202,9 @@ def _scan_to_json(all_findings, rules, scan_mode, target_path, encrypted, scan_m
982
1202
  sca_cov = (scan_meta or {}).get("sca_coverage")
983
1203
  if sca_cov is not None:
984
1204
  summary["sca_coverage"] = sca_cov
1205
+ uncovered = (scan_meta or {}).get("uncovered_surfaces")
1206
+ if uncovered:
1207
+ summary["uncovered_surfaces"] = uncovered
985
1208
  return {
986
1209
  "tool": "Narvy CLI",
987
1210
  "version": __version__,
@@ -1071,6 +1294,14 @@ def cmd_scan(args):
1071
1294
  scan_meta = {}
1072
1295
  encrypted_scan = bool(scan_meta.get("encrypted"))
1073
1296
 
1297
+ # Honesty: if this scan mode left a significant other code surface uncovered
1298
+ # (a hybrid RN/Flutter/Capacitor monorepo scans only the native shell), say so
1299
+ # loudly instead of exiting 0 in silence. Detection precedence is unchanged.
1300
+ uncovered_notes = _uncovered_surface_notes(target_path, scan_mode)
1301
+ if uncovered_notes:
1302
+ scan_meta = dict(scan_meta)
1303
+ scan_meta["uncovered_surfaces"] = uncovered_notes
1304
+
1074
1305
  if args.output in ('sarif', 'json'):
1075
1306
  # Written to a file, never stdout: progress notes would interleave.
1076
1307
  if args.output == 'sarif':
@@ -1143,6 +1374,14 @@ def cmd_scan(args):
1143
1374
  "it - see the '.narvy-scope.yml' section of the CLI's SETUP.md[/dim]"
1144
1375
  )
1145
1376
 
1377
+ if uncovered_notes:
1378
+ console.print(
1379
+ "\n[bold yellow]COVERAGE NOTICE: this scan did not cover the whole "
1380
+ f"project.[/bold yellow] Scanned mode: [bold]{scan_mode}[/bold]."
1381
+ )
1382
+ for note in uncovered_notes:
1383
+ console.print(f"[bold yellow] - {note}[/bold yellow]")
1384
+
1146
1385
  if args.upload and not getattr(args, "upload_binary", False):
1147
1386
  _submit_results_ingest(all_findings, scan_mode, target_path)
1148
1387
  elif args.upload:
@@ -2506,14 +2506,12 @@ rules:
2506
2506
  - Hard-coded Secrets
2507
2507
  paths:
2508
2508
  exclude:
2509
- - '**/test*/**'
2510
- - '**/tests/**'
2511
- - '**/*test*.py'
2509
+ # Test/fixture paths are NOT excluded: a real credential committed there is a
2510
+ # common leak vector; it is emitted then down-ranked to LOW. Only example/doc
2511
+ # code (whose literals are illustrative, not live) stays excluded.
2512
2512
  - '**/*example*.py'
2513
2513
  - '**/docs_src/**'
2514
2514
  - '**/examples/**'
2515
- - '**/conftest.py'
2516
- - '**/fixtures/**'
2517
2515
  patterns:
2518
2516
  - pattern-either:
2519
2517
  - pattern: $VAR = "$LITERAL"
@@ -1163,6 +1163,15 @@ rules:
1163
1163
  - pattern-not-regex: (?i)(user|username|xxxx|\$\{|%s|<[a-z_]+>|localhost)
1164
1164
  languages:
1165
1165
  - generic
1166
+ # Template env files (.env.example/.sample/.dist) and CI workflow yml carry
1167
+ # placeholder creds, not real leaks; real .env stays in scope.
1168
+ paths:
1169
+ exclude:
1170
+ - '*.example'
1171
+ - '*.sample'
1172
+ - '*.dist'
1173
+ - '.github/workflows/*'
1174
+ - '**/.github/workflows/*'
1166
1175
  message: A connection string / URL embeds a username:password before the host (scheme://user:pass@host).
1167
1176
  Database, message-queue, and API URIs with inline credentials leak into logs, shell history, and process
1168
1177
  listings. Pass credentials via a separate auth mechanism or env var instead.
@@ -327,6 +327,12 @@ rules:
327
327
  - '*Podfile.lock'
328
328
  - '*cargo.lock'
329
329
  - '*.xcscmblueprint'
330
+ # Template env files and CI workflow yml hold placeholder keys, not real ones.
331
+ - '*.example'
332
+ - '*.sample'
333
+ - '*.dist'
334
+ - '.github/workflows/*'
335
+ - '**/.github/workflows/*'
330
336
 
331
337
  - id: narvy.secrets.generic-high-entropy-assignment
332
338
  patterns:
@@ -377,7 +383,5 @@ rules:
377
383
  - '*bundle.js'
378
384
  - '*vendor/*'
379
385
  - '*node_modules/*'
380
- - '*test*'
381
- - '*spec*'
382
- - '*mock*'
383
- - '*fixture*'
386
+ # Test/spec/mock/fixture paths are NOT excluded: a real secret committed to a
387
+ # test fixture is a common leak vector; it is emitted then down-ranked to LOW.