narvy-cli 1.0.0__tar.gz → 1.0.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 (102) hide show
  1. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/CHANGELOG.md +41 -0
  2. {narvy_cli-1.0.0/narvy_cli.egg-info → narvy_cli-1.0.1}/PKG-INFO +5 -3
  3. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/README.md +4 -2
  4. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/__init__.py +1 -1
  5. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/cloud/aws_scan.py +58 -66
  6. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/main.py +145 -33
  7. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/reporter.py +27 -1
  8. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/sca/android_deps.py +5 -0
  9. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/sca/ios_deps.py +5 -0
  10. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/sca/osv_client.py +17 -5
  11. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/sca/web_deps.py +5 -0
  12. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/scanner.py +4 -1
  13. {narvy_cli-1.0.0 → narvy_cli-1.0.1/narvy_cli.egg-info}/PKG-INFO +5 -3
  14. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/LICENSE +0 -0
  15. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/MANIFEST.in +0 -0
  16. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/SECURITY.md +0 -0
  17. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/android/__init__.py +0 -0
  18. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/android/source_analyzer.py +0 -0
  19. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/android/split_bundle.py +0 -0
  20. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/android_rule_context.py +0 -0
  21. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/apk_memory_preflight.py +0 -0
  22. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/auth.py +0 -0
  23. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ci_templates/bitbucket.yml +0 -0
  24. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ci_templates/github.yml +0 -0
  25. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ci_templates/gitlab.yml +0 -0
  26. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/cloud/__init__.py +0 -0
  27. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/comment_filter.py +0 -0
  28. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/crypto_taint_lite.py +0 -0
  29. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/decompiler.py +0 -0
  30. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/doctor.py +0 -0
  31. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/__init__.py +0 -0
  32. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/audit.py +0 -0
  33. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/host_knowledge.py +0 -0
  34. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/lynis_bootstrap.py +0 -0
  35. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/lynis_parser.py +0 -0
  36. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/narvy_checks.py +0 -0
  37. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/report.py +0 -0
  38. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/ssh_exec.py +0 -0
  39. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/__init__.py +0 -0
  40. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/binary_analyzer.py +0 -0
  41. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/plist_checks.py +0 -0
  42. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/source_analyzer.py +0 -0
  43. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/third_party_filter.py +0 -0
  44. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/trust_all_context.py +0 -0
  45. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/native_hardening.py +0 -0
  46. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rule_engine.py +0 -0
  47. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android/config.yml +0 -0
  48. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android/crypto.yml +0 -0
  49. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android/secrets.yml +0 -0
  50. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android/storage.yml +0 -0
  51. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android/webview.yml +0 -0
  52. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android.yml +0 -0
  53. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios/objc/crypto.yml +0 -0
  54. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios/objc/network.yml +0 -0
  55. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios/objc/secrets.yml +0 -0
  56. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios/objc/storage.yml +0 -0
  57. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios/objc/webview.yml +0 -0
  58. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios_swift.yml +0 -0
  59. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/go.yml +0 -0
  60. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/java.yml +0 -0
  61. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/javascript.yml +0 -0
  62. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/kotlin.yml +0 -0
  63. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/csharp_narvy/config.yml +0 -0
  64. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/csharp_narvy/crypto.yml +0 -0
  65. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/csharp_narvy/deserialization.yml +0 -0
  66. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/csharp_narvy/injection.yml +0 -0
  67. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/csharp_narvy/xxe.yml +0 -0
  68. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/auth_jwt.yml +0 -0
  69. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/deserialization.yml +0 -0
  70. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/mybatis.yml +0 -0
  71. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/snakeyaml.yml +0 -0
  72. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/spring_authz.yml +0 -0
  73. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/spring_config.yml +0 -0
  74. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/spring_hardening.yml +0 -0
  75. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/sqli.yml +0 -0
  76. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/xxe.yml +0 -0
  77. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/php.yml +0 -0
  78. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/python.yml +0 -0
  79. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/ruby.yml +0 -0
  80. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/rust.yml +0 -0
  81. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/secrets.yml +0 -0
  82. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/secrets_supplement.yml +0 -0
  83. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/sca/__init__.py +0 -0
  84. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/scope_config.py +0 -0
  85. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/semgrep_engine.py +0 -0
  86. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/stack_protector_evidence.py +0 -0
  87. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/third_party_filter.py +0 -0
  88. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/uploader.py +0 -0
  89. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/weak_prng_context.py +0 -0
  90. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/__init__.py +0 -0
  91. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/nuclei_binary.py +0 -0
  92. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/scan_blocklist.py +0 -0
  93. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/source_analyzer.py +0 -0
  94. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/ssrf_guard.py +0 -0
  95. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy_cli.egg-info/SOURCES.txt +0 -0
  96. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy_cli.egg-info/dependency_links.txt +0 -0
  97. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy_cli.egg-info/entry_points.txt +0 -0
  98. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy_cli.egg-info/requires.txt +0 -0
  99. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy_cli.egg-info/top_level.txt +0 -0
  100. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/pyproject.toml +0 -0
  101. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/setup.cfg +0 -0
  102. {narvy_cli-1.0.0 → narvy_cli-1.0.1}/setup.py +0 -0
@@ -5,6 +5,47 @@ 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.0.1]
9
+
10
+ ### Fixed
11
+
12
+ - `--fail-on` no longer green-passes a build whose scan silently degraded. An
13
+ SCA run that could not reach OSV.dev (outage / cold cache) is now reported as
14
+ `degraded` and fails the gate (exit 2), not a clean 0. `cloud-scan`,
15
+ `web-scan` and `host-audit` now feed their own coverage gaps (skipped AWS
16
+ analyzers, SSRF-blocked redirect, partial non-root audit) into `--fail-on`.
17
+ - `cloud-scan`: one AccessDenied no longer aborts the remaining analyzers for a
18
+ region; each runs in isolation and skipped checks are reported under
19
+ `coverage` in the output.
20
+ - SARIF output on every surface (`scan`, `web-scan`, `host-audit`, `cloud-scan`)
21
+ now carries a per-result `level` and a `security-severity` property, so
22
+ GitHub/GitLab code scanning can rank findings.
23
+
24
+ ### Changed
25
+
26
+ - `web-scan`, `host-audit` and `cloud-scan` now use `-o`/`--output` for the
27
+ format and `-f`/`--file` for the destination, consistent with `scan`. The
28
+ older `--format` / `--output-file` long options still work as aliases. A file
29
+ path that is actually a format name (e.g. `-f json`) is now rejected loudly
30
+ instead of writing a mis-named text file.
31
+
32
+ ## [1.0.0]
33
+
34
+ First stable release, published to PyPI as `narvy-cli`.
35
+
36
+ ### Changed
37
+
38
+ - Distribution name is `narvy-cli` on PyPI (`pip install narvy-cli`); the
39
+ command and import package remain `narvy`.
40
+ - Web/backend rule packs are Narvy-authored and run on the Semgrep OSS engine,
41
+ which is invoked as a dependency.
42
+
43
+ ### Notes
44
+
45
+ - Runs entirely on your machine; nothing is uploaded unless you pass `--upload`.
46
+ - Honest coverage limits are surfaced in the tool's own output (for example,
47
+ FairPlay-encrypted iOS binaries and partial multi-ABI native coverage).
48
+
8
49
  ## [0.9.0]
9
50
 
10
51
  First public release. A free, local security scanner that runs entirely on your
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narvy-cli
3
- Version: 1.0.0
3
+ Version: 1.0.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
@@ -161,5 +161,7 @@ as Narvy's own.
161
161
  ## Contributing
162
162
 
163
163
  Issues and pull requests welcome, especially new or improved detection
164
- rules. Rule packs live under `narvy/rules/`: Android/iOS
165
- (`rules/android`, `rules/ios`) and web/backend (`rules/web`, Semgrep-based).
164
+ rules. All rule packs are Narvy's own, under `narvy/rules/`: Android/iOS
165
+ (`rules/android`, `rules/ios`) and web/backend (`rules/web`). The
166
+ web/backend packs run on the Semgrep OSS engine, which Narvy invokes as a
167
+ dependency; the rules themselves are authored by Narvy (Apache-2.0).
@@ -128,5 +128,7 @@ as Narvy's own.
128
128
  ## Contributing
129
129
 
130
130
  Issues and pull requests welcome, especially new or improved detection
131
- rules. Rule packs live under `narvy/rules/`: Android/iOS
132
- (`rules/android`, `rules/ios`) and web/backend (`rules/web`, Semgrep-based).
131
+ rules. All rule packs are Narvy's own, under `narvy/rules/`: Android/iOS
132
+ (`rules/android`, `rules/ios`) and web/backend (`rules/web`). The
133
+ web/backend packs run on the Semgrep OSS engine, which Narvy invokes as a
134
+ dependency; the rules themselves are authored by Narvy (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.0"
3
+ __version__ = "1.0.1"
@@ -1055,6 +1055,18 @@ def analyze_aws_security(credentials, region=None, endpoint_url=None):
1055
1055
  # letting the per-check handlers below report an empty, clean scan.
1056
1056
  _verify_credentials(endpoint_url=endpoint_url)
1057
1057
 
1058
+ # Track every analyzer that could not complete (AccessDenied / throttling /
1059
+ # missing permission) so 0 findings from a blinded scan is reported as a
1060
+ # coverage gap, not as a clean pass.
1061
+ coverage_errors = []
1062
+
1063
+ def _collect(dest, label, fn):
1064
+ try:
1065
+ dest.extend(fn())
1066
+ except Exception as e:
1067
+ coverage_errors.append(f"{label}: {e}")
1068
+ logger.error(f"Error analyzing {label}: {e}")
1069
+
1058
1070
  all_vulnerable_groups = []
1059
1071
  all_vulnerable_vpcs = []
1060
1072
  all_vulnerable_rds = []
@@ -1085,84 +1097,56 @@ def analyze_aws_security(credentials, region=None, endpoint_url=None):
1085
1097
  logger.info(f"Scanning {len(regions)} region(s)")
1086
1098
  except Exception as e:
1087
1099
  logger.error(f"Error getting regions: {str(e)}")
1100
+ coverage_errors.append(f"region enumeration (ec2:DescribeRegions): {e}")
1088
1101
  regions = []
1089
1102
 
1103
+ # Each analyzer is isolated: one AccessDenied must not skip its siblings for the region.
1090
1104
  for reg in regions:
1091
1105
  logger.info(f"Analyzing region: {reg}")
1092
- try:
1093
- all_vulnerable_groups.extend(analyze_security_groups(reg, endpoint_url=endpoint_url))
1094
- all_vulnerable_vpcs.extend(analyze_vpc_configuration(reg, endpoint_url=endpoint_url))
1095
- all_vulnerable_rds.extend(analyze_rds_instances(reg, endpoint_url=endpoint_url))
1096
- all_vulnerable_lambdas.extend(analyze_lambda_functions(reg, endpoint_url=endpoint_url))
1097
- all_vulnerable_ecs.extend(analyze_ecs_clusters(reg, endpoint_url=endpoint_url))
1098
- all_vulnerable_eb.extend(analyze_elastic_beanstalk(reg, endpoint_url=endpoint_url))
1099
- all_vulnerable_apis.extend(analyze_api_gateway(reg, endpoint_url=endpoint_url))
1100
- all_vulnerable_dynamodb.extend(analyze_dynamodb(reg, endpoint_url=endpoint_url))
1101
- all_gd_issues.extend(analyze_guardduty(reg, endpoint_url=endpoint_url))
1102
- all_vulnerable_eks.extend(analyze_eks(reg, endpoint_url=endpoint_url))
1103
- all_vulnerable_redshift.extend(analyze_redshift(reg, endpoint_url=endpoint_url))
1104
- all_vulnerable_secrets.extend(analyze_secrets_manager(reg, endpoint_url=endpoint_url))
1105
- all_vulnerable_sqs.extend(analyze_sqs(reg, endpoint_url=endpoint_url))
1106
- all_vulnerable_sns.extend(analyze_sns(reg, endpoint_url=endpoint_url))
1107
- all_vulnerable_glue_jobs.extend(analyze_glue(reg, endpoint_url=endpoint_url))
1108
- all_vulnerable_emr_clusters.extend(analyze_emr(reg, endpoint_url=endpoint_url))
1109
- all_vulnerable_kms_keys.extend(analyze_kms(reg, endpoint_url=endpoint_url))
1110
- all_waf_issues.extend(analyze_waf(reg, endpoint_url=endpoint_url))
1111
- except Exception as e:
1112
- logger.error(f"Error analyzing region {reg}: {str(e)}")
1106
+ _collect(all_vulnerable_groups, f"{reg}/ec2_security_groups", lambda r=reg: analyze_security_groups(r, endpoint_url=endpoint_url))
1107
+ _collect(all_vulnerable_vpcs, f"{reg}/vpc_configuration", lambda r=reg: analyze_vpc_configuration(r, endpoint_url=endpoint_url))
1108
+ _collect(all_vulnerable_rds, f"{reg}/rds_instances", lambda r=reg: analyze_rds_instances(r, endpoint_url=endpoint_url))
1109
+ _collect(all_vulnerable_lambdas, f"{reg}/lambda_functions", lambda r=reg: analyze_lambda_functions(r, endpoint_url=endpoint_url))
1110
+ _collect(all_vulnerable_ecs, f"{reg}/ecs_clusters", lambda r=reg: analyze_ecs_clusters(r, endpoint_url=endpoint_url))
1111
+ _collect(all_vulnerable_eb, f"{reg}/elastic_beanstalk", lambda r=reg: analyze_elastic_beanstalk(r, endpoint_url=endpoint_url))
1112
+ _collect(all_vulnerable_apis, f"{reg}/api_gateway", lambda r=reg: analyze_api_gateway(r, endpoint_url=endpoint_url))
1113
+ _collect(all_vulnerable_dynamodb, f"{reg}/dynamodb", lambda r=reg: analyze_dynamodb(r, endpoint_url=endpoint_url))
1114
+ _collect(all_gd_issues, f"{reg}/guardduty", lambda r=reg: analyze_guardduty(r, endpoint_url=endpoint_url))
1115
+ _collect(all_vulnerable_eks, f"{reg}/eks_clusters", lambda r=reg: analyze_eks(r, endpoint_url=endpoint_url))
1116
+ _collect(all_vulnerable_redshift, f"{reg}/redshift_clusters", lambda r=reg: analyze_redshift(r, endpoint_url=endpoint_url))
1117
+ _collect(all_vulnerable_secrets, f"{reg}/secrets_manager", lambda r=reg: analyze_secrets_manager(r, endpoint_url=endpoint_url))
1118
+ _collect(all_vulnerable_sqs, f"{reg}/sqs_queues", lambda r=reg: analyze_sqs(r, endpoint_url=endpoint_url))
1119
+ _collect(all_vulnerable_sns, f"{reg}/sns_topics", lambda r=reg: analyze_sns(r, endpoint_url=endpoint_url))
1120
+ _collect(all_vulnerable_glue_jobs, f"{reg}/glue_jobs", lambda r=reg: analyze_glue(r, endpoint_url=endpoint_url))
1121
+ _collect(all_vulnerable_emr_clusters, f"{reg}/emr_clusters", lambda r=reg: analyze_emr(r, endpoint_url=endpoint_url))
1122
+ _collect(all_vulnerable_kms_keys, f"{reg}/kms_keys", lambda r=reg: analyze_kms(r, endpoint_url=endpoint_url))
1123
+ _collect(all_waf_issues, f"{reg}/waf", lambda r=reg: analyze_waf(r, endpoint_url=endpoint_url))
1113
1124
 
1114
1125
  # Account-scoped checks: run once against the primary region only.
1115
1126
  primary_region = regions[0] if regions else (region or 'us-east-1')
1116
- try:
1117
- all_cloudtrail_issues.extend(
1118
- analyze_cloudtrail(primary_region, endpoint_url=endpoint_url))
1119
- except Exception as e:
1120
- logger.error(f"Error analyzing CloudTrail: {str(e)}")
1121
- try:
1122
- all_config_issues.extend(
1123
- analyze_config(primary_region, endpoint_url=endpoint_url))
1124
- except Exception as e:
1125
- logger.error(f"Error analyzing Config: {str(e)}")
1126
- try:
1127
- all_cloudwatch_issues.extend(
1128
- analyze_cloudwatch(primary_region, endpoint_url=endpoint_url))
1129
- except Exception as e:
1130
- logger.error(f"Error analyzing CloudWatch: {str(e)}")
1127
+ _collect(all_cloudtrail_issues, "cloudtrail", lambda: analyze_cloudtrail(primary_region, endpoint_url=endpoint_url))
1128
+ _collect(all_config_issues, "config", lambda: analyze_config(primary_region, endpoint_url=endpoint_url))
1129
+ _collect(all_cloudwatch_issues, "cloudwatch", lambda: analyze_cloudwatch(primary_region, endpoint_url=endpoint_url))
1131
1130
 
1132
- try:
1133
- logger.info("Analyzing S3 Buckets...")
1134
- vulnerable_buckets = analyze_s3_buckets(endpoint_url=endpoint_url)
1135
- except Exception as e:
1136
- logger.error(f"Error analyzing S3 buckets: {str(e)}")
1137
- vulnerable_buckets = []
1131
+ vulnerable_buckets = []
1132
+ logger.info("Analyzing S3 Buckets...")
1133
+ _collect(vulnerable_buckets, "s3_buckets", lambda: analyze_s3_buckets(endpoint_url=endpoint_url))
1138
1134
 
1139
- try:
1140
- logger.info("Analyzing IAM Users for MFA...")
1141
- users_without_mfa = analyze_iam_users(endpoint_url=endpoint_url)
1142
- except Exception as e:
1143
- logger.error(f"Error analyzing IAM users: {str(e)}")
1144
- users_without_mfa = []
1135
+ users_without_mfa = []
1136
+ logger.info("Analyzing IAM Users for MFA...")
1137
+ _collect(users_without_mfa, "iam_users", lambda: analyze_iam_users(endpoint_url=endpoint_url))
1145
1138
 
1146
- try:
1147
- logger.info("Analyzing IAM Policies...")
1148
- risky_policies = analyze_iam_policies(endpoint_url=endpoint_url)
1149
- except Exception as e:
1150
- logger.error(f"Error analyzing IAM policies: {str(e)}")
1151
- risky_policies = []
1139
+ risky_policies = []
1140
+ logger.info("Analyzing IAM Policies...")
1141
+ _collect(risky_policies, "iam_policies", lambda: analyze_iam_policies(endpoint_url=endpoint_url))
1152
1142
 
1153
- try:
1154
- logger.info("Analyzing CloudFront Distributions...")
1155
- vulnerable_cf = analyze_cloudfront(endpoint_url=endpoint_url)
1156
- except Exception as e:
1157
- logger.error(f"Error analyzing CloudFront: {str(e)}")
1158
- vulnerable_cf = []
1143
+ vulnerable_cf = []
1144
+ logger.info("Analyzing CloudFront Distributions...")
1145
+ _collect(vulnerable_cf, "cloudfront", lambda: analyze_cloudfront(endpoint_url=endpoint_url))
1159
1146
 
1160
- try:
1161
- logger.info("Checking CIS Compliance...")
1162
- compliance_issues = check_cis_compliance(endpoint_url=endpoint_url)
1163
- except Exception as e:
1164
- logger.error(f"Error checking CIS compliance: {str(e)}")
1165
- compliance_issues = []
1147
+ compliance_issues = []
1148
+ logger.info("Checking CIS Compliance...")
1149
+ _collect(compliance_issues, "cis_compliance", lambda: check_cis_compliance(endpoint_url=endpoint_url))
1166
1150
 
1167
1151
  logger.info("Generating report")
1168
1152
  report = generate_report(
@@ -1175,6 +1159,14 @@ def analyze_aws_security(credentials, region=None, endpoint_url=None):
1175
1159
  all_vulnerable_kms_keys, all_config_issues, all_cloudtrail_issues, all_cloudwatch_issues
1176
1160
  )
1177
1161
 
1162
+ # Coverage: a scan that could not read some services/regions is NOT a clean 0.
1163
+ report["coverage"] = {
1164
+ "status": "partial" if coverage_errors else "complete",
1165
+ "regions_scanned": len(regions),
1166
+ "checks_skipped": len(coverage_errors),
1167
+ "errors": coverage_errors,
1168
+ }
1169
+
1178
1170
  logger.info("AWS security analysis complete")
1179
1171
  return report
1180
1172
 
@@ -19,7 +19,7 @@ from rich.markup import escape as rich_escape
19
19
  from rich.prompt import Confirm
20
20
 
21
21
  from narvy import __version__
22
- from narvy.reporter import generate_sarif_report
22
+ from narvy.reporter import generate_sarif_report, sarif_level, sarif_security_severity
23
23
  from .decompiler import decompile_apk
24
24
  from .rule_engine import load_rules_from_dir, run_rules_on_file
25
25
  from . import auth, uploader
@@ -551,13 +551,19 @@ def _sca_coverage(stats):
551
551
  reasons.append("osv_dependency_cap")
552
552
  if stats.get("github_rate_limited"):
553
553
  reasons.append("github_advisory_rate_limit")
554
+ # An OSV outage / cold cache means "0 CVEs" is UNKNOWN, not clean: degrade, don't pass.
555
+ unreachable = bool(stats.get("osv_unreachable"))
556
+ if unreachable:
557
+ reasons.append("osv_unreachable")
554
558
  cov = {
555
- "status": "partial" if reasons else "complete",
559
+ "status": "degraded" if unreachable else ("partial" if reasons else "complete"),
556
560
  "dependencies_detected": detected,
557
561
  "dependencies_checked": checked,
558
562
  }
559
563
  if reasons:
560
564
  cov["degraded_reasons"] = reasons
565
+ if stats.get("osv_dependencies_unresolved"):
566
+ cov["dependencies_unresolved"] = stats["osv_dependencies_unresolved"]
561
567
  return cov
562
568
 
563
569
 
@@ -1240,10 +1246,28 @@ def cmd_scan(args):
1240
1246
  console.print("[dim]This is a flat, unfiltered list (one row per rule hit). The cloud groups, de-duplicates and false-positive-filters the same findings and shows a severity-ranked default view, so its headline count for the same code is often different - and can be smaller - by design, not because it found less.[/dim]")
1241
1247
  console.print("Run `narvy login` then re-run with [bold]--upload[/bold] for the full server-side scan (compliance mapping, ML triage, dashboard history) - [link=https://narvy.io]narvy.io[/link]")
1242
1248
 
1249
+ # SCA that could not reach OSV.dev (outage / cold cache) reports 0 CVEs as UNKNOWN, not clean.
1250
+ sca_cov = scan_meta.get("sca_coverage")
1251
+ if isinstance(sca_cov, dict) and sca_cov.get("status") == "degraded":
1252
+ _unres = sca_cov.get("dependencies_unresolved", 0)
1253
+ console.print(
1254
+ f"[bold yellow]SCA is INCOMPLETE: the OSV.dev vulnerability database was "
1255
+ f"unreachable (network error / cold cache), so {_unres} dependency(ies) "
1256
+ f"could not be checked. Treat 0 CVEs on those as UNKNOWN, not as clean - "
1257
+ f"re-run with network access to OSV.dev.[/bold yellow]"
1258
+ )
1259
+
1243
1260
  # Must stay last so failing the build never swallows the report/upload.
1244
1261
  # A coverage gap = a pass that couldn't read the target, where exit 0 would falsely claim clean.
1245
1262
  coverage_gaps = []
1246
1263
  if getattr(args, "fail_on", None):
1264
+ if isinstance(sca_cov, dict) and sca_cov.get("status") == "degraded":
1265
+ coverage_gaps.append(
1266
+ "The SCA dependency pass could not reach the OSV.dev vulnerability "
1267
+ f"database for {sca_cov.get('dependencies_unresolved', 0)} dependency(ies) "
1268
+ "(network error / cold cache), so their CVE status is UNKNOWN. Re-run "
1269
+ "with network access to OSV.dev."
1270
+ )
1247
1271
  if encrypted_scan:
1248
1272
  coverage_gaps.append(
1249
1273
  "This IPA is FairPlay-encrypted, so the rules that read code and string "
@@ -1361,6 +1385,17 @@ def cmd_upload_all(args):
1361
1385
  raise SystemExit(1)
1362
1386
 
1363
1387
 
1388
+ def _reject_format_as_file(output_file):
1389
+ """Catch a swapped flag: -f/--file given a format name (e.g. `-f json`) instead of a path."""
1390
+ if output_file in ("text", "json", "sarif"):
1391
+ console.print(
1392
+ f"[bold red]ERROR:[/bold red] -f/--file expects a file path but got "
1393
+ f"'{output_file}', which is a format name. Use [bold]-o {output_file}[/bold] "
1394
+ f"to select the format, and -f/--file for the output path ('-' for stdout)."
1395
+ )
1396
+ raise SystemExit(2)
1397
+
1398
+
1364
1399
  def _write_or_print(output_text, output_path):
1365
1400
  if output_path and output_path != "-":
1366
1401
  try:
@@ -1435,25 +1470,48 @@ def _exit_on_fail_on(findings, fail_on, get_severity=lambda f: f.get("severity",
1435
1470
  raise SystemExit(1)
1436
1471
 
1437
1472
 
1473
+ def _sarif_result(rule_id, message_text, uri, severity):
1474
+ """One SARIF result carrying level + security-severity, so consumers can rank it."""
1475
+ return {
1476
+ "ruleId": rule_id,
1477
+ "level": sarif_level(severity),
1478
+ "message": {"text": message_text},
1479
+ "locations": [{"physicalLocation": {"artifactLocation": {"uri": uri or ""}}}],
1480
+ "properties": {"security-severity": sarif_security_severity(severity)},
1481
+ }
1482
+
1483
+
1438
1484
  def _distinct_rules_from_results(results):
1439
1485
  """tool.driver.rules descriptors for the distinct ruleIds in `results`, in insertion order.
1440
- SARIF consumers resolve results[].ruleId against tool.driver.rules[].id, so every referenced id must be declared."""
1486
+ SARIF consumers resolve results[].ruleId against tool.driver.rules[].id, so every referenced id must be
1487
+ declared; each carries the highest security-severity/level seen for that id."""
1441
1488
  rules = []
1442
- seen = set()
1489
+ seen = {}
1443
1490
  for r in results:
1444
1491
  rid = r.get("ruleId")
1445
- if rid and rid not in seen:
1446
- seen.add(rid)
1447
- rules.append({"id": rid})
1492
+ if not rid:
1493
+ continue
1494
+ ss = r.get("properties", {}).get("security-severity", "0.0")
1495
+ lvl = r.get("level", "warning")
1496
+ if rid not in seen:
1497
+ entry = {"id": rid,
1498
+ "defaultConfiguration": {"level": lvl},
1499
+ "properties": {"security-severity": ss}}
1500
+ seen[rid] = entry
1501
+ rules.append(entry)
1502
+ elif float(ss) > float(seen[rid]["properties"]["security-severity"]):
1503
+ seen[rid]["properties"]["security-severity"] = ss
1504
+ seen[rid]["defaultConfiguration"]["level"] = lvl
1448
1505
  return rules
1449
1506
 
1450
1507
 
1451
1508
  def _web_to_sarif(findings):
1452
- results = [{
1453
- "ruleId": f.get("template_id", "web-finding"),
1454
- "message": {"text": f.get("title", f.get("description", ""))},
1455
- "locations": [{"physicalLocation": {"artifactLocation": {"uri": f.get("url", "")}}}],
1456
- } for f in findings]
1509
+ results = [_sarif_result(
1510
+ f.get("template_id", "web-finding"),
1511
+ f.get("title", f.get("description", "")),
1512
+ f.get("url", ""),
1513
+ f.get("severity"),
1514
+ ) for f in findings]
1457
1515
  return {
1458
1516
  "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
1459
1517
  "version": "2.1.0",
@@ -1466,6 +1524,7 @@ def _web_to_sarif(findings):
1466
1524
 
1467
1525
 
1468
1526
  def cmd_web_scan(args):
1527
+ _reject_format_as_file(args.output_file)
1469
1528
  # nuclei treats a non-positive rate as no rate limiting at all.
1470
1529
  rate_limit = max(1, min(args.rate_limit, WEB_MAX_RATE_LIMIT))
1471
1530
 
@@ -1543,15 +1602,24 @@ def cmd_web_scan(args):
1543
1602
  output = "\n".join(lines)
1544
1603
 
1545
1604
  _write_or_print(output, args.output_file)
1546
- _exit_on_fail_on(findings, args.fail_on)
1605
+ coverage_gaps = []
1606
+ if result.get("coverage_incomplete"):
1607
+ coverage_gaps.append(
1608
+ result.get("redirect_note")
1609
+ or "Only part of the target could be scanned (a redirect led to a host the "
1610
+ "SSRF policy would not follow), so these results are incomplete - 0 "
1611
+ "findings means UNKNOWN, not clean."
1612
+ )
1613
+ _exit_on_fail_on(findings, args.fail_on, coverage_gaps=coverage_gaps)
1547
1614
 
1548
1615
 
1549
1616
  def _host_to_sarif(findings, coverage="unknown", audit_privilege="unknown"):
1550
- results = [{
1551
- "ruleId": f.get("rule_id") or "host-finding",
1552
- "message": {"text": f.get("title") or f.get("description", "")},
1553
- "locations": [{"physicalLocation": {"artifactLocation": {"uri": "lynis"}}}],
1554
- } for f in findings]
1617
+ results = [_sarif_result(
1618
+ f.get("rule_id") or "host-finding",
1619
+ f.get("title") or f.get("description", ""),
1620
+ f.get("location") or "lynis",
1621
+ f.get("severity"),
1622
+ ) for f in findings]
1555
1623
  return {
1556
1624
  "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
1557
1625
  "version": "2.1.0",
@@ -1584,6 +1652,7 @@ def _split_host_port(host, port):
1584
1652
 
1585
1653
 
1586
1654
  def cmd_host_audit(args):
1655
+ _reject_format_as_file(args.output_file)
1587
1656
  args.host, args.port = _split_host_port(args.host, args.port)
1588
1657
  if args.format == "text":
1589
1658
  console.print(f"\n[bold]Narvy Host Audit[/bold] - {args.user}@{args.host}:{args.port}")
@@ -1690,7 +1759,15 @@ def cmd_host_audit(args):
1690
1759
  output = "\n".join(lines)
1691
1760
 
1692
1761
  _write_or_print(output, args.output_file)
1693
- _exit_on_fail_on(findings, args.fail_on)
1762
+ coverage_gaps = []
1763
+ if coverage == "partial":
1764
+ coverage_gaps.append(
1765
+ f"This was a PARTIAL audit (privilege: {audit_privilege}) - the root-only "
1766
+ "CIS/deep hardening checks were skipped, so 0 findings at/above the "
1767
+ "threshold means UNKNOWN for those checks, not clean. Run as root or with "
1768
+ "NOPASSWD sudo for full coverage."
1769
+ )
1770
+ _exit_on_fail_on(findings, args.fail_on, coverage_gaps=coverage_gaps)
1694
1771
 
1695
1772
 
1696
1773
  def _cloud_flatten(report):
@@ -1716,11 +1793,12 @@ def _cloud_flatten(report):
1716
1793
 
1717
1794
 
1718
1795
  def _cloud_to_sarif(findings):
1719
- results = [{
1720
- "ruleId": f["category"],
1721
- "message": {"text": f["title"]},
1722
- "locations": [{"physicalLocation": {"artifactLocation": {"uri": f["resource"] or f["category"]}}}],
1723
- } for f in findings]
1796
+ results = [_sarif_result(
1797
+ f["category"],
1798
+ f["title"],
1799
+ f["resource"] or f["category"],
1800
+ f.get("severity"),
1801
+ ) for f in findings]
1724
1802
  return {
1725
1803
  "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
1726
1804
  "version": "2.1.0",
@@ -1733,6 +1811,7 @@ def _cloud_to_sarif(findings):
1733
1811
 
1734
1812
 
1735
1813
  def cmd_cloud_scan(args):
1814
+ _reject_format_as_file(args.output_file)
1736
1815
  try:
1737
1816
  from .cloud.aws_scan import analyze_aws_security
1738
1817
  except ImportError:
@@ -1763,10 +1842,24 @@ def cmd_cloud_scan(args):
1763
1842
 
1764
1843
  findings = _cloud_flatten(report)
1765
1844
 
1845
+ coverage = report.get("coverage") or {}
1846
+ coverage_partial = coverage.get("status") == "partial"
1847
+ coverage_gaps = []
1848
+ if coverage_partial:
1849
+ skipped = coverage.get("checks_skipped", 0)
1850
+ coverage_gaps.append(
1851
+ f"{skipped} AWS analyzer(s) could not complete (e.g. AccessDenied / "
1852
+ "throttling / a denied permission), so those services/regions were NOT "
1853
+ "assessed. 0 findings there means UNKNOWN, not clean. See coverage.errors "
1854
+ "in the JSON output and re-run with a role that can read them."
1855
+ )
1856
+
1766
1857
  if args.format == "json":
1767
1858
  output = json.dumps(report, indent=2, default=str)
1768
1859
  elif args.format == "sarif":
1769
- output = json.dumps(_cloud_to_sarif(findings), indent=2)
1860
+ sarif = _cloud_to_sarif(findings)
1861
+ sarif["runs"][0]["properties"] = {"coverage": coverage}
1862
+ output = json.dumps(sarif, indent=2)
1770
1863
  else:
1771
1864
  lines = [
1772
1865
  "=" * 60, "NARVY CLOUD POSTURE RESULTS", "=" * 60, "",
@@ -1780,14 +1873,24 @@ def cmd_cloud_scan(args):
1780
1873
  if by_sev.get(sev):
1781
1874
  lines.append(f" {sev.upper()}: {by_sev[sev]}")
1782
1875
  lines.append("")
1876
+ if coverage_partial:
1877
+ lines.append(
1878
+ f"Coverage: PARTIAL - {coverage.get('checks_skipped', 0)} analyzer(s) "
1879
+ "could not complete (AccessDenied / throttling / denied permission). "
1880
+ "Those services/regions were NOT assessed; 0 findings there is UNKNOWN, "
1881
+ "not clean:")
1882
+ for err in coverage.get("errors", []):
1883
+ lines.append(f" - {err}")
1884
+ lines.append("")
1783
1885
  for f in findings:
1784
1886
  lines.append(f"[{f['severity'].upper()}] ({f['category']}) {f['title']}")
1785
1887
  if not findings:
1786
- lines.append("No issues found.")
1888
+ lines.append("No issues found." if not coverage_partial
1889
+ else "No issues found among the services that COULD be assessed (see coverage note above).")
1787
1890
  output = "\n".join(lines)
1788
1891
 
1789
1892
  _write_or_print(output, args.output_file)
1790
- _exit_on_fail_on(findings, args.fail_on)
1893
+ _exit_on_fail_on(findings, args.fail_on, coverage_gaps=coverage_gaps)
1791
1894
 
1792
1895
 
1793
1896
  _CI_TEMPLATES = {
@@ -1942,8 +2045,11 @@ def cli():
1942
2045
  help="Dynamic mode: run nuclei's full template set (sends non-GET probes, checks input handling, etc.) instead of the passive GET-only subset. Only scan targets you own or are explicitly authorized to test. Destructive tags (dos, fuzzing, brute-force, intrusive) stay excluded.")
1943
2046
  p_web.add_argument("--rate-limit", type=int, default=WEB_MAX_RATE_LIMIT,
1944
2047
  help=f"Requests per second (max {WEB_MAX_RATE_LIMIT}, enforced)")
1945
- p_web.add_argument("--format", "-f", choices=["text", "json", "sarif"], default="text")
1946
- p_web.add_argument("--output-file", "-o", help="Output file path (default: stdout)")
2048
+ p_web.add_argument("--output", "-o", "--format", dest="format", choices=["text", "json", "sarif"],
2049
+ default="text", type=str.lower,
2050
+ help="Output format: text (default), json or sarif. Consistent with `scan`: -o = format.")
2051
+ p_web.add_argument("--file", "-f", "--output-file", dest="output_file", default=None,
2052
+ help="Write output to this path ('-' = stdout, the default). Consistent with `scan`: -f = file.")
1947
2053
  p_web.add_argument("--fail-on", choices=["critical", "high", "medium", "low", "any"])
1948
2054
  p_web.set_defaults(func=cmd_web_scan)
1949
2055
 
@@ -1955,16 +2061,22 @@ def cli():
1955
2061
  help="Authenticate with an SSH password (prompted, or read from the NARVY_SSH_PASSWORD env var) instead of a key. The host key is captured on first contact (trust-on-first-use) and pinned for the duration of this run. Note: this is not a defence against an active man-in-the-middle, which would have its own key pinned; only send a password over a network you trust, and prefer key auth where possible.")
1956
2062
  p_host.add_argument("--privilege", choices=["auto", "root", "nopasswd_sudo", "none"],
1957
2063
  default="auto", help="Privilege mode (default: auto-detect)")
1958
- p_host.add_argument("--format", "-f", choices=["text", "json", "sarif"], default="text")
1959
- p_host.add_argument("--output-file", "-o", help="Output file path (default: stdout)")
2064
+ p_host.add_argument("--output", "-o", "--format", dest="format", choices=["text", "json", "sarif"],
2065
+ default="text", type=str.lower,
2066
+ help="Output format: text (default), json or sarif. Consistent with `scan`: -o = format.")
2067
+ p_host.add_argument("--file", "-f", "--output-file", dest="output_file", default=None,
2068
+ help="Write output to this path ('-' = stdout, the default). Consistent with `scan`: -f = file.")
1960
2069
  p_host.add_argument("--fail-on", choices=["critical", "high", "medium", "low", "any"])
1961
2070
  p_host.set_defaults(func=cmd_host_audit)
1962
2071
 
1963
2072
  p_cloud = sub.add_parser("cloud-scan", help="Cloud posture scan using your own ambient credentials (aws configure / env vars / IAM role - zero credential custody)")
1964
2073
  p_cloud.add_argument("--provider", choices=["aws"], default="aws", help="Cloud provider (GCP/Azure not yet in the CLI)")
1965
2074
  p_cloud.add_argument("--region", help="Specific region to scan (default: all regions)")
1966
- p_cloud.add_argument("--format", "-f", choices=["text", "json", "sarif"], default="text")
1967
- p_cloud.add_argument("--output-file", "-o", help="Output file path (default: stdout)")
2075
+ p_cloud.add_argument("--output", "-o", "--format", dest="format", choices=["text", "json", "sarif"],
2076
+ default="text", type=str.lower,
2077
+ help="Output format: text (default), json or sarif. Consistent with `scan`: -o = format.")
2078
+ p_cloud.add_argument("--file", "-f", "--output-file", dest="output_file", default=None,
2079
+ help="Write output to this path ('-' = stdout, the default). Consistent with `scan`: -f = file.")
1968
2080
  p_cloud.add_argument("--fail-on", choices=["critical", "high", "medium", "low", "any"])
1969
2081
  p_cloud.set_defaults(func=cmd_cloud_scan)
1970
2082
 
@@ -3,6 +3,28 @@ from typing import List, Dict, Any
3
3
 
4
4
  from narvy import __version__
5
5
 
6
+ # Canonical SARIF severity mapping, shared by every emitter (scan + web/host/cloud).
7
+ # level = SARIF's per-result rank; security-severity = GitHub/GitLab 0-10 code-scanning score.
8
+ _SARIF_LEVEL_BY_SEV = {
9
+ "critical": "error", "high": "error",
10
+ "medium": "warning",
11
+ "low": "note", "info": "note",
12
+ }
13
+ _SARIF_SECURITY_SEVERITY_BY_SEV = {
14
+ "critical": "9.5", "high": "8.0", "medium": "5.5", "low": "2.0", "info": "0.0",
15
+ }
16
+
17
+
18
+ def sarif_level(severity: str) -> str:
19
+ """SARIF result level (error/warning/note) for a finding severity."""
20
+ return _SARIF_LEVEL_BY_SEV.get(str(severity or "").strip().lower(), "warning")
21
+
22
+
23
+ def sarif_security_severity(severity: str) -> str:
24
+ """GitHub/GitLab code-scanning security-severity (0-10 string) for a finding severity."""
25
+ return _SARIF_SECURITY_SEVERITY_BY_SEV.get(str(severity or "").strip().lower(), "0.0")
26
+
27
+
6
28
  _FAIRPLAY_RULE_ID = "IOS-FAIRPLAY-ENCRYPTED"
7
29
  _FAIRPLAY_COVERAGE_TEXT = (
8
30
  "Partial coverage: this iOS binary is FairPlay-encrypted (App Store DRM, "
@@ -51,6 +73,7 @@ def generate_sarif_report(findings: List[Dict[str, Any]], rules: List[Dict[str,
51
73
  "confidence": confidence,
52
74
  "precision": _SARIF_PRECISION.get(confidence, "medium"),
53
75
  "severity": _sev or "UNKNOWN",
76
+ "security-severity": sarif_security_severity(_sev),
54
77
  **({"prngContext": finding['details']['prng_context'],
55
78
  "prngContextEvidence": finding['details'].get('prng_context_evidence', '')}
56
79
  if isinstance(finding.get('details'), dict)
@@ -81,6 +104,7 @@ def generate_sarif_report(findings: List[Dict[str, Any]], rules: List[Dict[str,
81
104
  tool_rules = []
82
105
  for rule in rules:
83
106
  rule_confidence = rule.get('confidence', 'MEDIUM')
107
+ _rule_sev = str(rule.get('severity', '') or '').lower()
84
108
  tool_rules.append({
85
109
  "id": rule['id'],
86
110
  "name": rule['name'],
@@ -93,10 +117,12 @@ def generate_sarif_report(findings: List[Dict[str, Any]], rules: List[Dict[str,
93
117
  "help": {
94
118
  "text": f"CWE: {rule['details'].get('cwe')}\nMASVS: {rule['details'].get('masvs')}"
95
119
  },
120
+ "defaultConfiguration": {"level": sarif_level(_rule_sev)},
96
121
  "properties": {
97
122
  "tags": ["security", "android", rule.get('masvs', '').lower()],
98
123
  "precision": _SARIF_PRECISION.get(rule_confidence, "medium"),
99
- "problem.severity": rule.get('severity', 'UNKNOWN').lower()
124
+ "problem.severity": rule.get('severity', 'UNKNOWN').lower(),
125
+ "security-severity": sarif_security_severity(_rule_sev),
100
126
  }
101
127
  })
102
128
 
@@ -330,6 +330,8 @@ def scan(apk_path: str, osv_client: OSVClient = None) -> Tuple[List[Dict[str, An
330
330
  rule_defs: List[Dict[str, Any]] = []
331
331
  vulnerable_coords: Set[str] = set()
332
332
 
333
+ unresolved_before = getattr(client, "query_failures_no_cache", 0)
334
+
333
335
  for dep in deduped:
334
336
  if not dep.version or dep.version == "unknown":
335
337
  continue
@@ -346,4 +348,7 @@ def scan(apk_path: str, osv_client: OSVClient = None) -> Tuple[List[Dict[str, An
346
348
  vulnerable_coords.add(dep.maven_name)
347
349
 
348
350
  stats["vulnerable_dependencies"] = len(vulnerable_coords)
351
+ unresolved = getattr(client, "query_failures_no_cache", 0) - unresolved_before
352
+ stats["osv_dependencies_unresolved"] = unresolved
353
+ stats["osv_unreachable"] = unresolved > 0
349
354
  return findings, rule_defs, stats
@@ -544,6 +544,8 @@ def _scan_deps(deps: List[_IOSDep], osv_client: Optional[OSVClient] = None) -> T
544
544
  vulnerable_names: set = set()
545
545
  gh_used_for = 0
546
546
 
547
+ unresolved_before = getattr(client, "query_failures_no_cache", 0)
548
+
547
549
  for dep in unique_deps:
548
550
  use_github = gh_used_for < _GH_MAX_QUERIES_PER_SCAN
549
551
  cve_hits = _check_all_sources(dep, client, gh_budget, use_github)
@@ -563,6 +565,9 @@ def _scan_deps(deps: List[_IOSDep], osv_client: Optional[OSVClient] = None) -> T
563
565
  "github_queries_made": gh_budget.queries_made,
564
566
  "github_rate_limited": gh_budget.exhausted,
565
567
  }
568
+ unresolved = getattr(client, "query_failures_no_cache", 0) - unresolved_before
569
+ stats["osv_dependencies_unresolved"] = unresolved
570
+ stats["osv_unreachable"] = unresolved > 0
566
571
  return findings, rule_defs, stats
567
572
 
568
573
 
@@ -472,6 +472,9 @@ class OSVClient:
472
472
  self.batch_url = batch_url
473
473
  self.timeout = timeout
474
474
  self.cache = cache or OSVCache()
475
+ # Coverage counters: tell "0 CVEs because OSV said so" from "0 because OSV was unreachable".
476
+ self.batch_failures = 0
477
+ self.query_failures_no_cache = 0
475
478
  self._session = session or requests.Session()
476
479
  self._session.headers.update(
477
480
  {
@@ -543,6 +546,8 @@ class OSVClient:
543
546
  )
544
547
  if f is not None
545
548
  ]
549
+ # Failed and nothing cached: this dependency is UNKNOWN, not clean.
550
+ self.query_failures_no_cache += 1
546
551
  return []
547
552
 
548
553
  def query_dict(self, package_name: str, version: str, ecosystem: str = "Maven") -> List[Dict[str, Any]]:
@@ -590,14 +595,21 @@ class OSVClient:
590
595
  resp.raise_for_status()
591
596
  data = resp.json()
592
597
  results = data.get("results", []) or []
593
- for idx, r in enumerate(results):
594
- if idx >= len(chunk):
595
- break
596
- vulns = r.get("vulns") or []
597
- if vulns:
598
+ # A short/empty results array leaves some queried deps unanswered:
599
+ # mark each unresolved (defer to query()) so the coverage gate sees it.
600
+ incomplete = False
601
+ for idx in range(len(chunk)):
602
+ r = results[idx] if idx < len(results) else None
603
+ if not isinstance(r, dict):
598
604
  result[chunk[idx]] = True
605
+ incomplete = True
606
+ elif r.get("vulns"):
607
+ result[chunk[idx]] = True
608
+ if incomplete:
609
+ self.batch_failures += 1
599
610
  except (requests.RequestException, ValueError, json.JSONDecodeError) as exc:
600
611
  logger.warning(f"OSV batch query failed (chunk {i}-{i+len(chunk)}): {exc}")
612
+ self.batch_failures += 1
601
613
  # Unknown for this chunk: defer to query() rather than under-report.
602
614
  for name, version in chunk:
603
615
  result[(name, version)] = True
@@ -932,6 +932,8 @@ def scan(source_dir: str, osv_client: OSVClient = None) -> Tuple[List[Dict[str,
932
932
  rule_defs: List[Dict[str, Any]] = []
933
933
  vulnerable_coords: Set[str] = set()
934
934
 
935
+ unresolved_before = getattr(client, "query_failures_no_cache", 0)
936
+
935
937
  for ecosystem, eco_deps in by_ecosystem.items():
936
938
  pkg_version_pairs = [(d.name, d.version) for d in eco_deps]
937
939
  has_vuln = client.has_any_vuln_batch(pkg_version_pairs, ecosystem=ecosystem)
@@ -952,4 +954,7 @@ def scan(source_dir: str, osv_client: OSVClient = None) -> Tuple[List[Dict[str,
952
954
  vulnerable_coords.add(f"{dep.ecosystem}:{dep.name}")
953
955
 
954
956
  stats["vulnerable_dependencies"] = len(vulnerable_coords)
957
+ unresolved = getattr(client, "query_failures_no_cache", 0) - unresolved_before
958
+ stats["osv_dependencies_unresolved"] = unresolved
959
+ stats["osv_unreachable"] = unresolved > 0
955
960
  return findings, rule_defs, stats
@@ -173,7 +173,7 @@ class NucleiScanner:
173
173
  """Follow `target`'s redirect chain through the SSRF guard; never raises
174
174
  (keeps the original target on failure). Returns {url, note, final_status}."""
175
175
  from .ssrf_guard import safe_get as _ssrf_safe_get, SSRFBlocked as _Blocked
176
- out = {"url": target, "note": None, "final_status": None}
176
+ out = {"url": target, "note": None, "final_status": None, "coverage_incomplete": False}
177
177
  try:
178
178
  resp = _ssrf_safe_get(
179
179
  target, timeout=10, verify=False,
@@ -188,6 +188,7 @@ class NucleiScanner:
188
188
  f"Only the redirect stub at {target} was scanned - treat these results "
189
189
  f"as incomplete."
190
190
  )
191
+ out["coverage_incomplete"] = True
191
192
  logger.warning(f"_resolve_redirect_target: {out['note']}")
192
193
  return out
193
194
  except Exception as e:
@@ -210,6 +211,7 @@ class NucleiScanner:
210
211
  f"(SSRF policy: {e}). Only the redirect stub at {target} was "
211
212
  f"scanned - treat these results as incomplete."
212
213
  )
214
+ out["coverage_incomplete"] = True
213
215
  logger.warning(f"_resolve_redirect_target: {out['note']}")
214
216
  return out
215
217
  out["url"] = final_url
@@ -379,6 +381,7 @@ class NucleiScanner:
379
381
  "target": target,
380
382
  "scanned_url": scan_target,
381
383
  "redirect_note": redirect_note,
384
+ "coverage_incomplete": redirect.get("coverage_incomplete", False),
382
385
  "degraded": False,
383
386
  "degraded_reason": None,
384
387
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narvy-cli
3
- Version: 1.0.0
3
+ Version: 1.0.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
@@ -161,5 +161,7 @@ as Narvy's own.
161
161
  ## Contributing
162
162
 
163
163
  Issues and pull requests welcome, especially new or improved detection
164
- rules. Rule packs live under `narvy/rules/`: Android/iOS
165
- (`rules/android`, `rules/ios`) and web/backend (`rules/web`, Semgrep-based).
164
+ rules. All rule packs are Narvy's own, under `narvy/rules/`: Android/iOS
165
+ (`rules/android`, `rules/ios`) and web/backend (`rules/web`). The
166
+ web/backend packs run on the Semgrep OSS engine, which Narvy invokes as a
167
+ dependency; the rules themselves are authored by Narvy (Apache-2.0).
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