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.
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/CHANGELOG.md +41 -0
- {narvy_cli-1.0.0/narvy_cli.egg-info → narvy_cli-1.0.1}/PKG-INFO +5 -3
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/README.md +4 -2
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/__init__.py +1 -1
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/cloud/aws_scan.py +58 -66
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/main.py +145 -33
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/reporter.py +27 -1
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/sca/android_deps.py +5 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/sca/ios_deps.py +5 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/sca/osv_client.py +17 -5
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/sca/web_deps.py +5 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/scanner.py +4 -1
- {narvy_cli-1.0.0 → narvy_cli-1.0.1/narvy_cli.egg-info}/PKG-INFO +5 -3
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/LICENSE +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/MANIFEST.in +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/SECURITY.md +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/android/__init__.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/android/source_analyzer.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/android/split_bundle.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/android_rule_context.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/apk_memory_preflight.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/auth.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ci_templates/bitbucket.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ci_templates/github.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ci_templates/gitlab.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/cloud/__init__.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/comment_filter.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/crypto_taint_lite.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/decompiler.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/doctor.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/__init__.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/audit.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/host_knowledge.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/lynis_bootstrap.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/lynis_parser.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/narvy_checks.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/report.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/host/ssh_exec.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/__init__.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/binary_analyzer.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/plist_checks.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/source_analyzer.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/third_party_filter.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/ios/trust_all_context.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/native_hardening.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rule_engine.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android/config.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android/crypto.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android/secrets.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android/storage.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android/webview.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/android.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios/objc/crypto.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios/objc/network.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios/objc/secrets.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios/objc/storage.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios/objc/webview.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/ios_swift.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/go.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/java.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/javascript.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/kotlin.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/csharp_narvy/config.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/csharp_narvy/crypto.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/csharp_narvy/deserialization.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/csharp_narvy/injection.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/csharp_narvy/xxe.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/auth_jwt.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/deserialization.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/mybatis.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/snakeyaml.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/spring_authz.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/spring_config.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/spring_hardening.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/sqli.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/local/java_narvy/xxe.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/php.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/python.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/ruby.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/rust.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/secrets.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/rules/web/secrets_supplement.yml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/sca/__init__.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/scope_config.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/semgrep_engine.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/stack_protector_evidence.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/third_party_filter.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/uploader.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/weak_prng_context.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/__init__.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/nuclei_binary.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/scan_blocklist.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/source_analyzer.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy/web/ssrf_guard.py +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy_cli.egg-info/SOURCES.txt +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy_cli.egg-info/dependency_links.txt +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy_cli.egg-info/entry_points.txt +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy_cli.egg-info/requires.txt +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/narvy_cli.egg-info/top_level.txt +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/pyproject.toml +0 -0
- {narvy_cli-1.0.0 → narvy_cli-1.0.1}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
165
|
-
(`rules/android`, `rules/ios`) and web/backend (`rules/web
|
|
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.
|
|
132
|
-
(`rules/android`, `rules/ios`) and web/backend (`rules/web
|
|
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).
|
|
@@ -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
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
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
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
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
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
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
|
|
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 =
|
|
1489
|
+
seen = {}
|
|
1443
1490
|
for r in results:
|
|
1444
1491
|
rid = r.get("ruleId")
|
|
1445
|
-
if
|
|
1446
|
-
|
|
1447
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1552
|
-
|
|
1553
|
-
"
|
|
1554
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
-
|
|
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("--
|
|
1946
|
-
|
|
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("--
|
|
1959
|
-
|
|
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("--
|
|
1967
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if
|
|
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.
|
|
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.
|
|
165
|
-
(`rules/android`, `rules/ios`) and web/backend (`rules/web
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|