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.
- {narvy_cli-1.0.2/narvy_cli.egg-info → narvy_cli-1.0.3}/PKG-INFO +1 -1
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/__init__.py +1 -1
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/android/source_analyzer.py +99 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/main.py +258 -19
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/python.yml +3 -5
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/secrets.yml +9 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/secrets_supplement.yml +8 -4
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/sca/web_deps.py +205 -4
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/source_analyzer.py +276 -54
- {narvy_cli-1.0.2 → narvy_cli-1.0.3/narvy_cli.egg-info}/PKG-INFO +1 -1
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/CHANGELOG.md +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/LICENSE +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/MANIFEST.in +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/README.md +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/SECURITY.md +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/android/__init__.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/android/split_bundle.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/android_rule_context.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/apk_memory_preflight.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/auth.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ci_templates/bitbucket.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ci_templates/github.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ci_templates/gitlab.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/cloud/__init__.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/cloud/aws_scan.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/comment_filter.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/crypto_taint_lite.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/decompiler.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/doctor.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/__init__.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/audit.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/host_knowledge.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/lynis_bootstrap.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/lynis_parser.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/narvy_checks.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/report.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/host/ssh_exec.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/__init__.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/binary_analyzer.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/plist_checks.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/source_analyzer.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/third_party_filter.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/ios/trust_all_context.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/native_hardening.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/reporter.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rule_engine.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android/config.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android/crypto.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android/secrets.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android/storage.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android/webview.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/android.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios/objc/crypto.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios/objc/network.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios/objc/secrets.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios/objc/storage.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios/objc/webview.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/ios_swift.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/go.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/java.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/javascript.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/kotlin.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/csharp_narvy/config.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/csharp_narvy/crypto.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/csharp_narvy/deserialization.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/csharp_narvy/injection.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/csharp_narvy/xxe.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/auth_jwt.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/deserialization.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/mybatis.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/snakeyaml.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/spring_authz.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/spring_config.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/spring_hardening.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/sqli.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/local/java_narvy/xxe.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/php.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/ruby.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/rules/web/rust.yml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/sca/__init__.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/sca/android_deps.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/sca/ios_deps.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/sca/osv_client.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/scope_config.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/semgrep_engine.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/stack_protector_evidence.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/third_party_filter.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/uploader.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/weak_prng_context.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/__init__.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/nuclei_binary.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/scan_blocklist.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/scanner.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy/web/ssrf_guard.py +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy_cli.egg-info/SOURCES.txt +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy_cli.egg-info/dependency_links.txt +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy_cli.egg-info/entry_points.txt +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy_cli.egg-info/requires.txt +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/narvy_cli.egg-info/top_level.txt +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/pyproject.toml +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/setup.cfg +0 -0
- {narvy_cli-1.0.2 → narvy_cli-1.0.3}/setup.py +0 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
2510
|
-
-
|
|
2511
|
-
|
|
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
|
-
|
|
381
|
-
-
|
|
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.
|