narvy-cli 1.1.0__tar.gz → 1.1.1__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 (91) hide show
  1. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/CHANGELOG.md +28 -0
  2. {narvy_cli-1.1.0/narvy_cli.egg-info → narvy_cli-1.1.1}/PKG-INFO +5 -2
  3. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/README.md +4 -1
  4. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/__init__.py +1 -1
  5. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/main.py +91 -15
  6. narvy_cli-1.1.1/narvy/own_reports.py +72 -0
  7. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/web/python.yml +67 -0
  8. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/uploader.py +6 -0
  9. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/web/source_analyzer.py +5 -2
  10. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/web/ssrf_guard.py +2 -0
  11. {narvy_cli-1.1.0 → narvy_cli-1.1.1/narvy_cli.egg-info}/PKG-INFO +5 -2
  12. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy_cli.egg-info/SOURCES.txt +1 -0
  13. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/LICENSE +0 -0
  14. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/MANIFEST.in +0 -0
  15. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/SECURITY.md +0 -0
  16. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/android/__init__.py +0 -0
  17. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/android/source_analyzer.py +0 -0
  18. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/android/split_bundle.py +0 -0
  19. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/android_rule_context.py +0 -0
  20. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/apk_memory_preflight.py +0 -0
  21. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/auth.py +0 -0
  22. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/ci_templates/bitbucket.yml +0 -0
  23. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/ci_templates/github.yml +0 -0
  24. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/ci_templates/gitlab.yml +0 -0
  25. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/cloud/__init__.py +0 -0
  26. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/cloud/aws_scan.py +0 -0
  27. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/comment_filter.py +0 -0
  28. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/crypto_taint_lite.py +0 -0
  29. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/decompiler.py +0 -0
  30. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/doctor.py +0 -0
  31. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/host/__init__.py +0 -0
  32. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/host/audit.py +0 -0
  33. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/host/host_knowledge.py +0 -0
  34. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/host/lynis_bootstrap.py +0 -0
  35. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/host/lynis_parser.py +0 -0
  36. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/host/narvy_checks.py +0 -0
  37. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/host/report.py +0 -0
  38. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/host/ssh_exec.py +0 -0
  39. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/ios/__init__.py +0 -0
  40. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/ios/binary_analyzer.py +0 -0
  41. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/ios/plist_checks.py +0 -0
  42. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/ios/source_analyzer.py +0 -0
  43. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/ios/third_party_filter.py +0 -0
  44. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/ios/trust_all_context.py +0 -0
  45. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/native_hardening.py +0 -0
  46. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/proc.py +0 -0
  47. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/reporter.py +0 -0
  48. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rule_engine.py +0 -0
  49. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/android/config.yml +0 -0
  50. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/android/crypto.yml +0 -0
  51. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/android/secrets.yml +0 -0
  52. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/android/storage.yml +0 -0
  53. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/android/webview.yml +0 -0
  54. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/android.yml +0 -0
  55. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/ios/objc/crypto.yml +0 -0
  56. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/ios/objc/network.yml +0 -0
  57. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/ios/objc/secrets.yml +0 -0
  58. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/ios/objc/storage.yml +0 -0
  59. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/ios/objc/webview.yml +0 -0
  60. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/ios_swift.yml +0 -0
  61. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/web/go.yml +0 -0
  62. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/web/java.yml +0 -0
  63. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/web/javascript.yml +0 -0
  64. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/web/kotlin.yml +0 -0
  65. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/web/php.yml +0 -0
  66. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/web/ruby.yml +0 -0
  67. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/web/rust.yml +0 -0
  68. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/web/secrets.yml +0 -0
  69. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/rules/web/secrets_supplement.yml +0 -0
  70. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/sca/__init__.py +0 -0
  71. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/sca/android_deps.py +0 -0
  72. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/sca/ios_deps.py +0 -0
  73. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/sca/osv_client.py +0 -0
  74. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/sca/web_deps.py +0 -0
  75. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/scope_config.py +0 -0
  76. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/semgrep_engine.py +0 -0
  77. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/stack_protector_evidence.py +0 -0
  78. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/telemetry.py +0 -0
  79. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/third_party_filter.py +0 -0
  80. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/weak_prng_context.py +0 -0
  81. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/web/__init__.py +0 -0
  82. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/web/nuclei_binary.py +0 -0
  83. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/web/scan_blocklist.py +0 -0
  84. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy/web/scanner.py +0 -0
  85. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy_cli.egg-info/dependency_links.txt +0 -0
  86. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy_cli.egg-info/entry_points.txt +0 -0
  87. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy_cli.egg-info/requires.txt +0 -0
  88. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/narvy_cli.egg-info/top_level.txt +0 -0
  89. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/pyproject.toml +0 -0
  90. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/setup.cfg +0 -0
  91. {narvy_cli-1.1.0 → narvy_cli-1.1.1}/setup.py +0 -0
@@ -5,6 +5,34 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.1]
9
+
10
+ ### Added
11
+
12
+ - Flask cookie checks: `set_cookie()` without `httponly=True` or
13
+ `secure=True`, the same checks Django already had.
14
+ - `scan` warns when files or folders could not be read (permissions) instead
15
+ of skipping them silently; `-v` lists them and the JSON report has
16
+ `summary.unreadable_paths`.
17
+ - `scan --format` is accepted as an alias of `--output`, like the other
18
+ commands.
19
+
20
+ ### Fixed
21
+
22
+ - A second `scan` of the same folder picked up the report written by the
23
+ first one (`narvy.sarif`, the JSON report) and flagged the rule text quoted
24
+ in it as secrets. Narvy reports are now recognized by their content, and
25
+ the `--file` target is never scanned.
26
+ - `--upload` with only `NARVY_TOKEN` set printed "Uploading ... to for
27
+ server-side curation". The line now names the server (`narvy.io`, or
28
+ `NARVY_URL`), and a failed upload or login says which server refused it.
29
+ - `web-scan http://localhost` said "internal/metadata addresses"; it now says
30
+ the address is loopback and explains why local and private targets are
31
+ refused.
32
+ - The web-scan, host-audit and cloud-scan JSON output carries a `tool` field.
33
+ - `narvy doctor` documents its exit codes (0 all required checks pass, 1 one
34
+ failed).
35
+
8
36
  ## [1.1.0]
9
37
 
10
38
  First release of the community rule set. The 1.0.x preview releases were
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narvy-cli
3
- Version: 1.1.0
3
+ Version: 1.1.1
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
@@ -143,6 +143,9 @@ command. `narvy scans` lists recent scans on your account from the terminal.
143
143
  | 4 | The scan, login, upload or report write failed |
144
144
  | 130 | Interrupted (Ctrl-C, SIGTERM) |
145
145
 
146
+ `narvy doctor` exits 0 when every required check passes and 1 when one fails.
147
+ Missing optional tools are shown as INFO and do not change its exit code.
148
+
146
149
  ### Resource limits
147
150
 
148
151
  The structural analysis pass runs with at most 4 parallel workers and a
@@ -165,7 +168,7 @@ Exactly these fields are sent:
165
168
  |-------|---------|
166
169
  | `schema` | `1`, the event format version |
167
170
  | `install_id` | a random UUID created on first run and stored in `~/.narvy/config.json` (not derived from your machine, user or account) |
168
- | `cli_version`, `python_version`, `os` | `1.1.0`, `3.11`, `linux` / `darwin` / `windows` / `other` |
171
+ | `cli_version`, `python_version`, `os` | `1.1.1`, `3.11`, `linux` / `darwin` / `windows` / `other` |
169
172
  | `ci` | `true` when a CI environment variable is set |
170
173
  | `command` | `scan`, `web-scan`, `host-audit`, ... |
171
174
  | `surface` | `android`, `ios`, `source`, `web`, `host`, `cloud` or `none` |
@@ -110,6 +110,9 @@ command. `narvy scans` lists recent scans on your account from the terminal.
110
110
  | 4 | The scan, login, upload or report write failed |
111
111
  | 130 | Interrupted (Ctrl-C, SIGTERM) |
112
112
 
113
+ `narvy doctor` exits 0 when every required check passes and 1 when one fails.
114
+ Missing optional tools are shown as INFO and do not change its exit code.
115
+
113
116
  ### Resource limits
114
117
 
115
118
  The structural analysis pass runs with at most 4 parallel workers and a
@@ -132,7 +135,7 @@ Exactly these fields are sent:
132
135
  |-------|---------|
133
136
  | `schema` | `1`, the event format version |
134
137
  | `install_id` | a random UUID created on first run and stored in `~/.narvy/config.json` (not derived from your machine, user or account) |
135
- | `cli_version`, `python_version`, `os` | `1.1.0`, `3.11`, `linux` / `darwin` / `windows` / `other` |
138
+ | `cli_version`, `python_version`, `os` | `1.1.1`, `3.11`, `linux` / `darwin` / `windows` / `other` |
136
139
  | `ci` | `true` when a CI environment variable is set |
137
140
  | `command` | `scan`, `web-scan`, `host-audit`, ... |
138
141
  | `surface` | `android`, `ios`, `source`, `web`, `host`, `cloud` or `none` |
@@ -1,3 +1,3 @@
1
1
  """Narvy CLI - free, local SAST for Android, iOS and polyglot source."""
2
2
 
3
- __version__ = "1.1.0"
3
+ __version__ = "1.1.1"
@@ -38,7 +38,7 @@ from narvy import __version__
38
38
  from narvy.reporter import generate_sarif_report, sarif_level, sarif_security_severity
39
39
  from .decompiler import decompile_apk
40
40
  from .rule_engine import load_rules_from_dir, run_rules_on_file
41
- from . import auth, uploader, telemetry
41
+ from . import auth, own_reports, uploader, telemetry
42
42
  from .third_party_filter import get_own_package_roots, resolve_file_scope, check_android_override
43
43
  from .scope_config import load_scope_config
44
44
  from . import semgrep_engine
@@ -775,7 +775,7 @@ def cmd_login(args):
775
775
  raise SystemExit(EXIT_BAD_TARGET)
776
776
  status, data = uploader.whoami(token)
777
777
  if status != 200:
778
- console.print(f"[bold red]Login failed:[/bold red] {data.get('error', status)}")
778
+ console.print(f"[bold red]Login to {uploader.server_host()} failed:[/bold red] {data.get('error', status)}")
779
779
  raise SystemExit(EXIT_ERROR)
780
780
  auth.save_token(token, data["email"], data["plan"])
781
781
  console.print(f"[bold green]Logged in as {data['email']} ({data['plan']} plan).[/bold green]")
@@ -805,7 +805,7 @@ def cmd_scans(args):
805
805
  raise SystemExit(EXIT_ERROR)
806
806
  if status != 200:
807
807
  error_msg = data.get("error") or data.get("message") or data
808
- console.print(f"[bold red]Could not fetch scan history ({status}):[/bold red] {error_msg}")
808
+ console.print(f"[bold red]Could not fetch scan history from {uploader.server_host()} ({status}):[/bold red] {error_msg}")
809
809
  raise SystemExit(EXIT_ERROR)
810
810
 
811
811
  scans = data.get("scans", [])
@@ -821,7 +821,7 @@ def cmd_scans(args):
821
821
  )
822
822
  return
823
823
 
824
- table = Table(title=f"Recent scans ({creds['email']})")
824
+ table = Table(title=f"Recent scans on {rich_escape(_server_and_account(creds))}")
825
825
  table.add_column("Scan ID", style="dim")
826
826
  table.add_column("App")
827
827
  table.add_column("Platform")
@@ -1370,6 +1370,17 @@ def _ingest_scan_mode_label(scan_mode: str) -> str:
1370
1370
  return "android-binary" if scan_mode == "android" else scan_mode
1371
1371
 
1372
1372
 
1373
+ def _server_and_account(creds):
1374
+ """'narvy.io (you@example.com, starter)'; NARVY_TOKEN alone carries no email or plan."""
1375
+ parts = [p for p in ((creds or {}).get("email"), (creds or {}).get("plan")) if p]
1376
+ host = uploader.server_host()
1377
+ return f"{host} ({', '.join(parts)})" if parts else host
1378
+
1379
+
1380
+ def _upload_failed_label(status):
1381
+ return f"Upload to {uploader.server_host()} failed ({f'HTTP {status}' if status else 'no response'}):"
1382
+
1383
+
1373
1384
  def _submit_results_ingest(all_findings, scan_mode, target_path):
1374
1385
  """POST results to /api/v1/ingest. Exits with EXIT_ERROR on any failure."""
1375
1386
  creds = auth.load_credentials()
@@ -1382,7 +1393,7 @@ def _submit_results_ingest(all_findings, scan_mode, target_path):
1382
1393
  app_name = re.sub(r"\.(zip|apk|aab|ipa|xapk|apkm|apks)$", "", app_name, flags=re.IGNORECASE) or app_name
1383
1394
 
1384
1395
  console.print(
1385
- f"[cyan]Uploading {len(all_findings)} finding(s) to {creds['email']} "
1396
+ f"[cyan]Uploading {len(all_findings)} finding(s) to {rich_escape(_server_and_account(creds))} "
1386
1397
  f"for server-side curation...[/cyan]"
1387
1398
  )
1388
1399
  try:
@@ -1391,7 +1402,7 @@ def _submit_results_ingest(all_findings, scan_mode, target_path):
1391
1402
  scan_mode=_ingest_scan_mode_label(scan_mode), findings=all_findings,
1392
1403
  )
1393
1404
  except Exception as e:
1394
- console.print(f"[bold red]Upload failed:[/bold red] {e}")
1405
+ console.print(f"[bold red]Upload to {uploader.server_host()} failed:[/bold red] {e}")
1395
1406
  raise SystemExit(EXIT_ERROR)
1396
1407
 
1397
1408
  if status not in (200, 201):
@@ -1400,7 +1411,7 @@ def _submit_results_ingest(all_findings, scan_mode, target_path):
1400
1411
  console.print(f"[bold red]{asset_msg}[/bold red]")
1401
1412
  raise SystemExit(EXIT_ERROR)
1402
1413
  error_msg = data.get("error") or data if isinstance(data, dict) else data
1403
- console.print(f"[bold red]Upload failed ({status}):[/bold red] {error_msg}")
1414
+ console.print(f"[bold red]{_upload_failed_label(status)}[/bold red] {error_msg}")
1404
1415
  if isinstance(data, dict) and data.get("message"):
1405
1416
  console.print(f"[dim]{data['message']}[/dim]")
1406
1417
  _print_upgrade_hint(status, data)
@@ -1497,6 +1508,9 @@ def _scan_to_json(all_findings, rules, scan_mode, target_path, encrypted, scan_m
1497
1508
  premium = (scan_meta or {}).get("premium_checks_not_run")
1498
1509
  if premium:
1499
1510
  summary["premium_checks_not_run"] = premium
1511
+ unreadable = (scan_meta or {}).get("unreadable_paths")
1512
+ if unreadable:
1513
+ summary["unreadable_paths"] = unreadable
1500
1514
  return {
1501
1515
  "tool": "Narvy CLI",
1502
1516
  "version": __version__,
@@ -1548,8 +1562,33 @@ def _scan_nested_ios_surfaces(target_path, ios_roots, all_findings, rules, scan_
1548
1562
  return all_findings, rules, scan_meta
1549
1563
 
1550
1564
 
1565
+ _UNREADABLE_SKIP_DIRS = frozenset({
1566
+ ".git", "node_modules", ".gradle", ".idea", "Pods", "Carthage", "__pycache__",
1567
+ ".venv", "venv", "DerivedData", ".build",
1568
+ })
1569
+
1570
+
1571
+ def _unreadable_paths(source_dir):
1572
+ """Files and directories under source_dir this user cannot read (vendored dirs aside)."""
1573
+ bad = []
1574
+
1575
+ def _onerror(err):
1576
+ if getattr(err, "filename", None):
1577
+ bad.append(os.path.relpath(err.filename, source_dir) + os.sep)
1578
+
1579
+ for root, dirs, files in os.walk(source_dir, onerror=_onerror):
1580
+ dirs[:] = [d for d in dirs if d not in _UNREADABLE_SKIP_DIRS]
1581
+ for name in files:
1582
+ full = os.path.join(root, name)
1583
+ if not os.path.islink(full) and not os.access(full, os.R_OK):
1584
+ bad.append(os.path.relpath(full, source_dir))
1585
+ return sorted(bad)
1586
+
1587
+
1551
1588
  def cmd_scan(args):
1552
1589
  target_path = args.apk_path
1590
+ own_reports.SKIPPED.clear()
1591
+ own_reports.set_output_path(None)
1553
1592
  if getattr(args, "upload_binary", False):
1554
1593
  args.upload = True
1555
1594
  if not os.path.exists(target_path):
@@ -1593,6 +1632,7 @@ def cmd_scan(args):
1593
1632
  console.print(f"[bold red]Cannot write {args.output.upper()} output to {_preflight_out}: {_werr}.[/bold red]")
1594
1633
  console.print("[dim]Check the parent directory exists and is writable, or pick a different --file path.[/dim]")
1595
1634
  raise SystemExit(EXIT_ERROR)
1635
+ own_reports.set_output_path(_preflight_out)
1596
1636
 
1597
1637
  # Android and iOS-binary paths only: iOS source mode classifies vendored
1598
1638
  # code by directory convention instead.
@@ -1602,6 +1642,8 @@ def cmd_scan(args):
1602
1642
  f"{len(override_config.own)} 'own' pattern(s), "
1603
1643
  f"{len(override_config.vendor)} 'vendor' pattern(s).[/dim]")
1604
1644
 
1645
+ unreadable = _unreadable_paths(target_path) if os.path.isdir(target_path) else []
1646
+
1605
1647
  if scan_mode == "ios-source":
1606
1648
  result = _run_ios_source_scan(target_path)
1607
1649
  elif scan_mode == "ios-binary":
@@ -1637,6 +1679,16 @@ def cmd_scan(args):
1637
1679
  all_findings, rules, scan_meta = _scan_nested_ios_surfaces(
1638
1680
  target_path, extra_ios_roots, all_findings, rules, scan_meta)
1639
1681
 
1682
+ # Our own earlier reports (narvy.sarif, results JSON) quote rule text that
1683
+ # the secret rules would match; they are not project code.
1684
+ if os.path.isdir(target_path):
1685
+ all_findings = own_reports.drop_own_reports(all_findings, target_path)
1686
+ if own_reports.SKIPPED:
1687
+ names = ", ".join(sorted(own_reports.SKIPPED)[:5])
1688
+ more = len(own_reports.SKIPPED) - 5
1689
+ console.print(f"[dim]Ignored Narvy report file(s) from an earlier run: {rich_escape(names)}"
1690
+ f"{f' and {more} more' if more > 0 else ''}.[/dim]")
1691
+
1640
1692
  # Say so when a hybrid repo (RN/Flutter/Capacitor) has code this mode
1641
1693
  # didn't cover, instead of exiting 0 silently.
1642
1694
  uncovered_notes = _uncovered_surface_notes(target_path, scan_mode,
@@ -1655,6 +1707,9 @@ def cmd_scan(args):
1655
1707
  if uncovered_notes:
1656
1708
  scan_meta = dict(scan_meta)
1657
1709
  scan_meta["uncovered_surfaces"] = uncovered_notes
1710
+ if unreadable:
1711
+ scan_meta = dict(scan_meta)
1712
+ scan_meta["unreadable_paths"] = unreadable
1658
1713
 
1659
1714
  if args.output in ('sarif', 'json'):
1660
1715
  # Written to a file, never stdout: progress notes would interleave.
@@ -1736,6 +1791,15 @@ def cmd_scan(args):
1736
1791
  for note in uncovered_notes:
1737
1792
  console.print(f"[bold yellow] - {note}[/bold yellow]")
1738
1793
 
1794
+ if unreadable:
1795
+ console.print(
1796
+ f"\n[bold yellow]{len(unreadable)} file(s) or folder(s) could not be read (permissions) "
1797
+ "and were not scanned.[/bold yellow]"
1798
+ + ("" if getattr(args, "verbose", False) else " [dim]Add -v to list them.[/dim]"))
1799
+ if getattr(args, "verbose", False):
1800
+ for rel in unreadable:
1801
+ console.print(f"[yellow] - {rich_escape(rel)}[/yellow]")
1802
+
1739
1803
  _premium_note = web_source_analyzer.premium_checks_note(premium_counts)
1740
1804
  if _premium_note:
1741
1805
  console.print(f"\n[dim]{rich_escape(_premium_note)}[/dim]")
@@ -1766,7 +1830,7 @@ def cmd_scan(args):
1766
1830
  raise SystemExit(EXIT_BAD_TARGET)
1767
1831
  upload_platform = "android" if scan_mode in ("android", "android-bundle") else "ios"
1768
1832
  confirm_upload = getattr(args, "confirm_upload", False)
1769
- console.print(f"\n[cyan]Uploading {os.path.basename(target_path)} to Narvy for the full server-side scan ({creds['email']}, {creds['plan']})...[/cyan]")
1833
+ console.print(f"\n[cyan]Uploading {os.path.basename(target_path)} to {rich_escape(_server_and_account(creds))} for the full server-side scan...[/cyan]")
1770
1834
  status, data = uploader.upload_scan(
1771
1835
  target_path, creds["token"], platform=upload_platform, analysis_type="sast",
1772
1836
  confirm_warning=confirm_upload,
@@ -1801,7 +1865,7 @@ def cmd_scan(args):
1801
1865
  # The malware-triage gate returns a 'triage' dict alongside the
1802
1866
  # top-level error on 400 (refuse) and 409 (warn).
1803
1867
  error_msg = data.get('error') or data.get('warning') or data
1804
- console.print(f"[bold red]Upload failed ({status}):[/bold red] {error_msg}")
1868
+ console.print(f"[bold red]{_upload_failed_label(status)}[/bold red] {error_msg}")
1805
1869
  _print_upgrade_hint(status, data)
1806
1870
  triage = data.get('triage')
1807
1871
  if isinstance(triage, dict):
@@ -1963,7 +2027,7 @@ def cmd_upload_all(args):
1963
2027
 
1964
2028
  console.print(
1965
2029
  f"[cyan]Found {len(targets)} target(s) under {directory}. "
1966
- f"Scanning and uploading to {creds['email']} ({creds['plan']})...[/cyan]\n"
2030
+ f"Scanning and uploading to {rich_escape(_server_and_account(creds))}...[/cyan]\n"
1967
2031
  )
1968
2032
 
1969
2033
  succeeded, failed = [], []
@@ -2159,7 +2223,12 @@ def cmd_web_scan(args):
2159
2223
  try:
2160
2224
  web_validate_url(args.url)
2161
2225
  except SSRFBlocked as e:
2162
- console.print(f"[bold red]ERROR:[/bold red] {e}")
2226
+ console.print(f"[bold red]ERROR:[/bold red] {rich_escape(str(e))}")
2227
+ if any(w in str(e) for w in ("loopback", "internal", "private")):
2228
+ console.print(
2229
+ "[dim]web-scan only scans publicly reachable URLs. Loopback, private-network and "
2230
+ "cloud metadata addresses are refused so a scan can never reach internal services. "
2231
+ "Point it at the app's public or staging URL.[/dim]")
2163
2232
  raise SystemExit(EXIT_BAD_TARGET)
2164
2233
 
2165
2234
  with scan_progress() as progress:
@@ -2186,7 +2255,7 @@ def cmd_web_scan(args):
2186
2255
  findings = result.get("findings", [])
2187
2256
 
2188
2257
  if args.format == "json":
2189
- output = json.dumps(result, indent=2)
2258
+ output = json.dumps({"tool": "Narvy Web Scanner CLI", **result}, indent=2)
2190
2259
  elif args.format == "sarif":
2191
2260
  output = json.dumps(_web_to_sarif(findings), indent=2)
2192
2261
  else:
@@ -2338,6 +2407,7 @@ def cmd_host_audit(args):
2338
2407
  "Narvy internal host checks + Lynis (GPL-3.0) hardening baseline")
2339
2408
 
2340
2409
  result = {
2410
+ "tool": "Narvy Host Audit CLI",
2341
2411
  "target": f"{args.user}@{args.host}:{args.port}",
2342
2412
  "duration_seconds": round(duration, 1),
2343
2413
  "scan_engine": scan_engine,
@@ -2483,7 +2553,7 @@ def cmd_cloud_scan(args):
2483
2553
  )
2484
2554
 
2485
2555
  if args.format == "json":
2486
- output = json.dumps(report, indent=2, default=str)
2556
+ output = json.dumps({"tool": "Narvy Cloud Scanner CLI", **report}, indent=2, default=str)
2487
2557
  elif args.format == "sarif":
2488
2558
  sarif = _cloud_to_sarif(findings)
2489
2559
  sarif["runs"][0]["properties"] = {"coverage": coverage}
@@ -2640,7 +2710,11 @@ def cli():
2640
2710
  p_scans.add_argument("--format", choices=["text", "json"], default="text", help="Output format (default: text)")
2641
2711
  p_scans.set_defaults(func=cmd_scans)
2642
2712
 
2643
- p_doctor = sub.add_parser("doctor", allow_abbrev=False, help="Check your Java/jadx/network/iOS-tooling setup before scanning")
2713
+ p_doctor = sub.add_parser(
2714
+ "doctor", allow_abbrev=False, help="Check your Java/jadx/network/iOS-tooling setup before scanning",
2715
+ epilog="exit codes:\n 0 every required check passed (missing optional tools are INFO only)\n"
2716
+ " 1 at least one required check failed",
2717
+ formatter_class=argparse.RawDescriptionHelpFormatter)
2644
2718
  p_doctor.set_defaults(func=lambda args: run_doctor())
2645
2719
 
2646
2720
  p_scan = sub.add_parser(
@@ -2653,8 +2727,10 @@ def cli():
2653
2727
  p_scan.add_argument("--max-mem", default="4g", help="Max memory for jadx, e.g. '2g' or '4096m' (default: 4g). Android only.")
2654
2728
  p_scan.add_argument("--force", action="store_true",
2655
2729
  help="Decompile anyway when the memory estimate says jadx will likely run out of memory.")
2656
- p_scan.add_argument("--output", "-o", default="console", choices=["console", "sarif", "json"],
2730
+ p_scan.add_argument("--output", "-o", "--format", dest="output", default="console", choices=["console", "sarif", "json"],
2657
2731
  type=str.lower, help="Output format: console (default), sarif or json. sarif and json are written to --file.")
2732
+ p_scan.add_argument("-v", "--verbose", action="store_true",
2733
+ help="List every file the scan could not read, not just the count.")
2658
2734
  p_scan.add_argument("--file", "-f", default=None, help="Output file for --output sarif/json (default: narvy-results.sarif or narvy-results.json). Ignored for console output.")
2659
2735
  p_scan.add_argument("--upload", action="store_true", help="Send the findings (never your code or binary) to your Narvy dashboard after the scan. Needs `narvy login` and a paid plan.")
2660
2736
  p_scan.add_argument("--upload-binary", action="store_true", help="Upload the binary itself (.apk/.aab/.ipa) for a server-side scan instead of the results. Implies --upload.")
@@ -0,0 +1,72 @@
1
+ """Recognize report files written by narvy itself, so a re-run never scans them."""
2
+ import json
3
+ import os
4
+ from typing import Dict, Optional, Set
5
+
6
+ # A real report of ours is far below this; bigger files are left to the rules.
7
+ _MAX_REPORT_BYTES = 256 * 1024 * 1024
8
+
9
+ _output_path: Optional[str] = None
10
+ _cache: Dict[str, bool] = {}
11
+ # Report files whose findings were dropped this run, for the end-of-scan note.
12
+ SKIPPED: Set[str] = set()
13
+
14
+
15
+ def set_output_path(path: Optional[str]) -> None:
16
+ """The --file target of this run: whatever it holds now gets overwritten."""
17
+ global _output_path
18
+ _output_path = os.path.realpath(path) if path and path != "-" else None
19
+
20
+
21
+ def _has_narvy_marker(data) -> bool:
22
+ if not isinstance(data, dict):
23
+ return False
24
+ tool = data.get("tool")
25
+ if isinstance(tool, str) and tool.startswith("Narvy"):
26
+ return True
27
+ runs = data.get("runs")
28
+ if isinstance(runs, list) and runs:
29
+ for run in runs:
30
+ tool = run.get("tool") if isinstance(run, dict) else None
31
+ driver = tool.get("driver") if isinstance(tool, dict) else None
32
+ name = driver.get("name") if isinstance(driver, dict) else None
33
+ if not (isinstance(name, str) and name.startswith("Narvy")):
34
+ return False
35
+ return True
36
+ return False
37
+
38
+
39
+ def is_own_report(path: str) -> bool:
40
+ """True for this run's --file target or a JSON/SARIF report narvy wrote."""
41
+ try:
42
+ real = os.path.realpath(path)
43
+ except (OSError, ValueError):
44
+ return False
45
+ if _output_path and real == _output_path:
46
+ return True
47
+ if real in _cache:
48
+ return _cache[real]
49
+ result = False
50
+ try:
51
+ if os.path.isfile(real) and os.path.getsize(real) <= _MAX_REPORT_BYTES:
52
+ with open(real, "rb") as f:
53
+ if f.read(256).lstrip(b"\xef\xbb\xbf \t\r\n").startswith(b"{"):
54
+ f.seek(0)
55
+ result = _has_narvy_marker(json.load(f))
56
+ except (OSError, ValueError, RecursionError):
57
+ result = False
58
+ _cache[real] = result
59
+ return result
60
+
61
+
62
+ def drop_own_reports(findings, target_dir: str):
63
+ """Findings minus those inside narvy report files, for a source-directory scan."""
64
+ kept = []
65
+ for f in findings:
66
+ fp = f.get("file_path") or ""
67
+ full = fp if os.path.isabs(fp) else os.path.join(target_dir, fp)
68
+ if fp and is_own_report(full):
69
+ SKIPPED.add(os.path.relpath(full, target_dir))
70
+ else:
71
+ kept.append(f)
72
+ return kept
@@ -2017,6 +2017,73 @@ rules:
2017
2017
  ...
2018
2018
 
2019
2019
  '
2020
+ - id: narvy.python.session.flask-set-cookie-missing-httponly.flask-set-cookie-missing-httponly
2021
+ message: '`set_cookie()` is called without `httponly=True`. A cookie without HttpOnly is readable from
2022
+ JavaScript via `document.cookie`, so any XSS on the page can exfiltrate it (session hijack). CWE-1004.
2023
+ Set `httponly=True`, or `SESSION_COOKIE_HTTPONLY = True` globally.'
2024
+ severity: WARNING
2025
+ languages:
2026
+ - python
2027
+ metadata:
2028
+ cwe:
2029
+ - 'CWE-1004: Sensitive Cookie Without ''HttpOnly'' Flag'
2030
+ owasp:
2031
+ - A05:2021 - Security Misconfiguration
2032
+ category: security
2033
+ subcategory:
2034
+ - vuln
2035
+ technology:
2036
+ - flask
2037
+ likelihood: MEDIUM
2038
+ impact: MEDIUM
2039
+ confidence: MEDIUM
2040
+ vulnerability_class:
2041
+ - Cookie Security
2042
+ patterns:
2043
+ - pattern-either:
2044
+ - pattern-inside: |
2045
+ import flask
2046
+ ...
2047
+ - pattern-inside: |
2048
+ from flask import $FLASK_NAME
2049
+ ...
2050
+ - pattern: $RESP.set_cookie($NAME, $VALUE, ...)
2051
+ - pattern-not: $RESP.set_cookie($NAME, $VALUE, ..., httponly=True, ...)
2052
+ - pattern-not: $RESP.set_cookie($NAME, $VALUE, ..., httponly=$X, ...)
2053
+ - id: narvy.python.session.flask-set-cookie-missing-secure.flask-set-cookie-missing-secure
2054
+ message: '`set_cookie()` is called without `secure=True`. Without the Secure flag, the browser will
2055
+ also send this cookie over plain HTTP, exposing it to network eavesdroppers and active MITM (CWE-614).
2056
+ Set `secure=True` for any cookie carrying session/auth state, or set `SESSION_COOKIE_SECURE = True`
2057
+ globally in Flask config.'
2058
+ severity: WARNING
2059
+ languages:
2060
+ - python
2061
+ metadata:
2062
+ cwe:
2063
+ - 'CWE-614: Sensitive Cookie in HTTPS Session Without ''Secure'' Attribute'
2064
+ owasp:
2065
+ - A05:2021 - Security Misconfiguration
2066
+ category: security
2067
+ subcategory:
2068
+ - vuln
2069
+ technology:
2070
+ - flask
2071
+ likelihood: MEDIUM
2072
+ impact: MEDIUM
2073
+ confidence: MEDIUM
2074
+ vulnerability_class:
2075
+ - Cookie Security
2076
+ patterns:
2077
+ - pattern-either:
2078
+ - pattern-inside: |
2079
+ import flask
2080
+ ...
2081
+ - pattern-inside: |
2082
+ from flask import $FLASK_NAME
2083
+ ...
2084
+ - pattern: $RESP.set_cookie($NAME, $VALUE, ...)
2085
+ - pattern-not: $RESP.set_cookie($NAME, $VALUE, ..., secure=True, ...)
2086
+ - pattern-not: $RESP.set_cookie($NAME, $VALUE, ..., secure=$X, ...)
2020
2087
  - id: narvy.python.sqli.pymongo-eval-where-injection.pymongo-eval-where-injection
2021
2088
  message: MongoDB `$where` clause (or `db.eval()`) built with a tainted string. `$where` executes JavaScript
2022
2089
  server-side against every document; an attacker-controlled fragment here is server-side NoSQL code
@@ -1,4 +1,5 @@
1
1
  import os
2
+ from urllib.parse import urlparse
2
3
 
3
4
  import requests
4
5
 
@@ -6,6 +7,11 @@ import requests
6
7
  API_BASE = (os.environ.get("NARVY_URL") or "https://narvy.io").rstrip("/")
7
8
 
8
9
 
10
+ def server_host():
11
+ """Host the CLI talks to, for messages (narvy.io unless NARVY_URL says otherwise)."""
12
+ return urlparse(API_BASE).netloc or API_BASE
13
+
14
+
9
15
  def _network_error(exc):
10
16
  """Return the (status, data) shape callers handle; status 0 never matches a real HTTP code."""
11
17
  return 0, {
@@ -9,7 +9,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple
9
9
 
10
10
  import yaml
11
11
 
12
- from narvy import semgrep_engine
12
+ from narvy import own_reports, semgrep_engine
13
13
  from narvy.proc import run_tree
14
14
 
15
15
  _HERE = os.path.dirname(__file__)
@@ -43,7 +43,7 @@ def pro_rules_available() -> bool:
43
43
  # can say what it didn't run.
44
44
  PREMIUM_CHECKS: Dict[str, Tuple[str, int]] = {
45
45
  "django": ("Django / Django REST framework", 24),
46
- "flask": ("Flask", 12),
46
+ "flask": ("Flask", 10),
47
47
  "fastapi": ("FastAPI", 1),
48
48
  "express": ("Express", 2),
49
49
  "laravel": ("Laravel", 7),
@@ -735,6 +735,9 @@ def _parse_semgrep_data(data: Dict[str, Any], source_dir: str,
735
735
  skipped_generated += 1
736
736
  continue
737
737
  rel_path = os.path.relpath(abs_path, source_dir)
738
+ if own_reports.is_own_report(abs_path):
739
+ own_reports.SKIPPED.add(rel_path)
740
+ continue
738
741
  extra = r.get("extra", {})
739
742
  meta = extra.get("metadata", {})
740
743
  check_id = r.get("check_id", "semgrep-finding").rsplit(".", 1)[-1]
@@ -154,6 +154,8 @@ def validate_url(url: str) -> Tuple[str, str, int]:
154
154
  except SSRFBlocked:
155
155
  raise
156
156
 
157
+ if hostname == 'localhost':
158
+ raise SSRFBlocked("localhost is this machine (loopback); scanning it is not allowed")
157
159
  if hostname in METADATA_HOSTNAMES:
158
160
  raise SSRFBlocked("Scanning internal/metadata addresses is not allowed")
159
161
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narvy-cli
3
- Version: 1.1.0
3
+ Version: 1.1.1
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
@@ -143,6 +143,9 @@ command. `narvy scans` lists recent scans on your account from the terminal.
143
143
  | 4 | The scan, login, upload or report write failed |
144
144
  | 130 | Interrupted (Ctrl-C, SIGTERM) |
145
145
 
146
+ `narvy doctor` exits 0 when every required check passes and 1 when one fails.
147
+ Missing optional tools are shown as INFO and do not change its exit code.
148
+
146
149
  ### Resource limits
147
150
 
148
151
  The structural analysis pass runs with at most 4 parallel workers and a
@@ -165,7 +168,7 @@ Exactly these fields are sent:
165
168
  |-------|---------|
166
169
  | `schema` | `1`, the event format version |
167
170
  | `install_id` | a random UUID created on first run and stored in `~/.narvy/config.json` (not derived from your machine, user or account) |
168
- | `cli_version`, `python_version`, `os` | `1.1.0`, `3.11`, `linux` / `darwin` / `windows` / `other` |
171
+ | `cli_version`, `python_version`, `os` | `1.1.1`, `3.11`, `linux` / `darwin` / `windows` / `other` |
169
172
  | `ci` | `true` when a CI environment variable is set |
170
173
  | `command` | `scan`, `web-scan`, `host-audit`, ... |
171
174
  | `surface` | `android`, `ios`, `source`, `web`, `host`, `cloud` or `none` |
@@ -15,6 +15,7 @@ narvy/decompiler.py
15
15
  narvy/doctor.py
16
16
  narvy/main.py
17
17
  narvy/native_hardening.py
18
+ narvy/own_reports.py
18
19
  narvy/proc.py
19
20
  narvy/reporter.py
20
21
  narvy/rule_engine.py
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes