nat-engine 1__py3-none-any.whl
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.
- mannf/__init__.py +33 -0
- mannf/__main__.py +10 -0
- mannf/_version.py +8 -0
- mannf/agents/__init__.py +7 -0
- mannf/agents/analyzer_agent.py +9 -0
- mannf/agents/base.py +9 -0
- mannf/agents/bdi_agent.py +9 -0
- mannf/agents/belief_state.py +9 -0
- mannf/agents/coordinator_agent.py +9 -0
- mannf/agents/executor_agent.py +9 -0
- mannf/agents/monitor_agent.py +9 -0
- mannf/agents/oracle_agent.py +9 -0
- mannf/agents/planner_agent.py +9 -0
- mannf/agents/test_agent.py +9 -0
- mannf/anomaly/__init__.py +7 -0
- mannf/anomaly/enhanced_detector.py +9 -0
- mannf/cli.py +9 -0
- mannf/core/__init__.py +26 -0
- mannf/core/agents/__init__.py +52 -0
- mannf/core/agents/accessibility_scanner_agent.py +245 -0
- mannf/core/agents/analyzer_agent.py +224 -0
- mannf/core/agents/autonomous_loop_agent.py +1086 -0
- mannf/core/agents/autonomous_loop_models.py +62 -0
- mannf/core/agents/autonomous_run_differ.py +427 -0
- mannf/core/agents/base.py +128 -0
- mannf/core/agents/bdi_agent.py +330 -0
- mannf/core/agents/belief_state.py +202 -0
- mannf/core/agents/browser_coordinator_agent.py +224 -0
- mannf/core/agents/browser_executor_agent.py +410 -0
- mannf/core/agents/coordinator_agent.py +262 -0
- mannf/core/agents/executor_agent.py +222 -0
- mannf/core/agents/monitor_agent.py +188 -0
- mannf/core/agents/oracle_agent.py +150 -0
- mannf/core/agents/performance_testing_agent.py +279 -0
- mannf/core/agents/planner_agent.py +128 -0
- mannf/core/agents/test_agent.py +249 -0
- mannf/core/agents/visual_regression_agent.py +311 -0
- mannf/core/agents/web_crawler_agent.py +510 -0
- mannf/core/agents/worker_pool.py +366 -0
- mannf/core/anomaly/__init__.py +14 -0
- mannf/core/anomaly/enhanced_detector.py +541 -0
- mannf/core/browser/__init__.py +63 -0
- mannf/core/browser/accessibility_scanner.py +424 -0
- mannf/core/browser/discovery_model.py +178 -0
- mannf/core/browser/dom_snapshot.py +349 -0
- mannf/core/browser/ingestor_bridge.py +371 -0
- mannf/core/browser/performance_metrics.py +217 -0
- mannf/core/browser/reflection_analyzer.py +442 -0
- mannf/core/browser/scenario_generator.py +1100 -0
- mannf/core/browser/security_scenario_generator.py +695 -0
- mannf/core/browser/visual_comparer.py +159 -0
- mannf/core/diagnostics/__init__.py +28 -0
- mannf/core/diagnostics/failure_clusterer.py +211 -0
- mannf/core/diagnostics/flake_detector.py +233 -0
- mannf/core/diagnostics/root_cause_analyzer.py +273 -0
- mannf/core/distributed/__init__.py +16 -0
- mannf/core/distributed/endpoint.py +139 -0
- mannf/core/distributed/system_under_test.py +207 -0
- mannf/core/functional_orchestrator.py +428 -0
- mannf/core/messaging/__init__.py +11 -0
- mannf/core/messaging/bus.py +113 -0
- mannf/core/messaging/messages.py +89 -0
- mannf/core/nat_orchestrator.py +342 -0
- mannf/core/neural/__init__.py +183 -0
- mannf/core/orchestrator.py +272 -0
- mannf/core/prioritization/__init__.py +17 -0
- mannf/core/prioritization/adaptive_controller.py +509 -0
- mannf/core/prioritization/belief_prioritizer.py +231 -0
- mannf/core/prioritization/risk_scorer.py +430 -0
- mannf/core/reporting/__init__.py +12 -0
- mannf/core/reporting/unified_report.py +664 -0
- mannf/core/testing/__init__.py +17 -0
- mannf/core/testing/adaptive_controller.py +149 -0
- mannf/core/testing/models.py +179 -0
- mannf/core/validation/__init__.py +10 -0
- mannf/core/validation/self_validation_runner.py +180 -0
- mannf/dashboard/__init__.py +7 -0
- mannf/dashboard/app.py +9 -0
- mannf/dashboard/models.py +9 -0
- mannf/dashboard/static/index.html +2538 -0
- mannf/dashboard/telemetry.py +9 -0
- mannf/distributed/__init__.py +7 -0
- mannf/distributed/endpoint.py +9 -0
- mannf/distributed/system_under_test.py +9 -0
- mannf/healing/__init__.py +7 -0
- mannf/healing/graphql_schema_diff.py +9 -0
- mannf/healing/healer.py +9 -0
- mannf/healing/models.py +9 -0
- mannf/healing/schema_diff.py +9 -0
- mannf/integrations/__init__.py +7 -0
- mannf/integrations/auth.py +9 -0
- mannf/integrations/graphql_parser.py +9 -0
- mannf/integrations/graphql_sut.py +9 -0
- mannf/integrations/http_sut.py +9 -0
- mannf/integrations/openapi_parser.py +9 -0
- mannf/integrations/postman_parser.py +9 -0
- mannf/llm/__init__.py +7 -0
- mannf/llm/anthropic_provider.py +9 -0
- mannf/llm/base.py +9 -0
- mannf/llm/config.py +9 -0
- mannf/llm/factory.py +9 -0
- mannf/llm/openai_provider.py +9 -0
- mannf/llm/prompts.py +9 -0
- mannf/messaging/__init__.py +7 -0
- mannf/messaging/bus.py +9 -0
- mannf/messaging/messages.py +9 -0
- mannf/nat_orchestrator.py +9 -0
- mannf/neural/__init__.py +7 -0
- mannf/orchestrator.py +9 -0
- mannf/prioritization/__init__.py +7 -0
- mannf/prioritization/adaptive_controller.py +9 -0
- mannf/prioritization/belief_prioritizer.py +9 -0
- mannf/prioritization/risk_scorer.py +9 -0
- mannf/product/__init__.py +29 -0
- mannf/product/admin/__init__.py +3 -0
- mannf/product/admin/routes.py +514 -0
- mannf/product/auth/__init__.py +5 -0
- mannf/product/auth/saml.py +212 -0
- mannf/product/billing/__init__.py +5 -0
- mannf/product/billing/audit.py +160 -0
- mannf/product/billing/feature_gates.py +180 -0
- mannf/product/billing/metering.py +179 -0
- mannf/product/billing/notifications.py +181 -0
- mannf/product/billing/plans.py +133 -0
- mannf/product/billing/rate_limits.py +35 -0
- mannf/product/billing/stripe_billing.py +906 -0
- mannf/product/billing/tenant_auth.py +233 -0
- mannf/product/billing/tenant_manager.py +873 -0
- mannf/product/cli.py +3900 -0
- mannf/product/cli_admin.py +408 -0
- mannf/product/dashboard/__init__.py +61 -0
- mannf/product/dashboard/app.py +3567 -0
- mannf/product/dashboard/models.py +460 -0
- mannf/product/dashboard/static/index.html +6347 -0
- mannf/product/dashboard/static/manifest.json +25 -0
- mannf/product/dashboard/static/pwa-icon-192.png +0 -0
- mannf/product/dashboard/static/pwa-icon-512.png +0 -0
- mannf/product/dashboard/static/sw.js +64 -0
- mannf/product/dashboard/telemetry.py +547 -0
- mannf/product/database.py +145 -0
- mannf/product/demo.py +844 -0
- mannf/product/doctor.py +509 -0
- mannf/product/exporters/__init__.py +65 -0
- mannf/product/exporters/azuredevops_exporter.py +257 -0
- mannf/product/exporters/base.py +307 -0
- mannf/product/exporters/bugzilla_exporter.py +200 -0
- mannf/product/exporters/dedup.py +275 -0
- mannf/product/exporters/finding_adapter.py +216 -0
- mannf/product/exporters/github_exporter.py +197 -0
- mannf/product/exporters/gitlab_exporter.py +215 -0
- mannf/product/exporters/jira_exporter.py +180 -0
- mannf/product/exporters/linear_exporter.py +195 -0
- mannf/product/exporters/loader.py +233 -0
- mannf/product/exporters/pagerduty_exporter.py +363 -0
- mannf/product/exporters/sentry_exporter.py +322 -0
- mannf/product/exporters/servicenow_exporter.py +240 -0
- mannf/product/exporters/shortcut_exporter.py +231 -0
- mannf/product/exporters/webhook_exporter.py +383 -0
- mannf/product/formatters/__init__.py +18 -0
- mannf/product/formatters/allure_formatter.py +161 -0
- mannf/product/formatters/ctrf_formatter.py +149 -0
- mannf/product/healing/__init__.py +30 -0
- mannf/product/healing/graphql_schema_diff.py +152 -0
- mannf/product/healing/healer.py +141 -0
- mannf/product/healing/models.py +175 -0
- mannf/product/healing/schema_diff.py +251 -0
- mannf/product/ingestors/__init__.py +77 -0
- mannf/product/ingestors/base.py +256 -0
- mannf/product/ingestors/bgstm_ingestor.py +764 -0
- mannf/product/ingestors/curl_ingestor.py +1019 -0
- mannf/product/ingestors/cypress_ingestor.py +487 -0
- mannf/product/ingestors/gherkin_ingestor.py +967 -0
- mannf/product/ingestors/graphql_ingestor.py +845 -0
- mannf/product/ingestors/grpc_ingestor.py +591 -0
- mannf/product/ingestors/har_ingestor.py +976 -0
- mannf/product/ingestors/loader.py +284 -0
- mannf/product/ingestors/models.py +146 -0
- mannf/product/ingestors/openapi_ingestor.py +606 -0
- mannf/product/ingestors/playwright_ingestor.py +449 -0
- mannf/product/ingestors/postman_ingestor.py +631 -0
- mannf/product/ingestors/traffic_ingestor.py +679 -0
- mannf/product/ingestors/websocket_ingestor.py +526 -0
- mannf/product/integrations/__init__.py +21 -0
- mannf/product/integrations/auth.py +190 -0
- mannf/product/integrations/graphql_parser.py +436 -0
- mannf/product/integrations/graphql_sut.py +247 -0
- mannf/product/integrations/grpc_sut.py +469 -0
- mannf/product/integrations/http_sut.py +237 -0
- mannf/product/integrations/kafka_adapter.py +342 -0
- mannf/product/integrations/openapi_parser.py +513 -0
- mannf/product/integrations/postman_parser.py +467 -0
- mannf/product/integrations/webhook_receiver.py +344 -0
- mannf/product/integrations/websocket_sut.py +434 -0
- mannf/product/llm/__init__.py +25 -0
- mannf/product/llm/anthropic_provider.py +94 -0
- mannf/product/llm/base.py +267 -0
- mannf/product/llm/config.py +48 -0
- mannf/product/llm/factory.py +42 -0
- mannf/product/llm/openai_provider.py +93 -0
- mannf/product/llm/prompts.py +403 -0
- mannf/product/llm/root_cause_service.py +311 -0
- mannf/product/llm/test_plan_models.py +78 -0
- mannf/product/metrics.py +149 -0
- mannf/product/middleware/__init__.py +3 -0
- mannf/product/middleware/audit_middleware.py +112 -0
- mannf/product/middleware/tenant_isolation.py +114 -0
- mannf/product/models.py +347 -0
- mannf/product/notifications/__init__.py +24 -0
- mannf/product/notifications/dispatcher.py +411 -0
- mannf/product/onboarding.py +190 -0
- mannf/product/orchestration/__init__.py +39 -0
- mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
- mannf/product/orchestration/pipeline.py +401 -0
- mannf/product/orchestrator.py +987 -0
- mannf/product/orchestrator_models.py +269 -0
- mannf/product/regression/__init__.py +36 -0
- mannf/product/regression/differ.py +172 -0
- mannf/product/regression/masking.py +100 -0
- mannf/product/regression/models.py +232 -0
- mannf/product/regression/recorder.py +124 -0
- mannf/product/regression/replayer.py +168 -0
- mannf/product/reports/__init__.py +10 -0
- mannf/product/reports/pdf.py +132 -0
- mannf/product/scheduling/__init__.py +57 -0
- mannf/product/scheduling/cron_utils.py +251 -0
- mannf/product/scheduling/engine.py +473 -0
- mannf/product/scheduling/models.py +86 -0
- mannf/product/scheduling/queue.py +894 -0
- mannf/product/scheduling/store.py +235 -0
- mannf/product/security/__init__.py +21 -0
- mannf/product/security/belief_guided.py +143 -0
- mannf/product/security/checks/__init__.py +55 -0
- mannf/product/security/checks/base.py +69 -0
- mannf/product/security/checks/bfla.py +77 -0
- mannf/product/security/checks/bola.py +77 -0
- mannf/product/security/checks/bopla.py +80 -0
- mannf/product/security/checks/broken_auth.py +86 -0
- mannf/product/security/checks/graphql_security.py +299 -0
- mannf/product/security/checks/inventory.py +70 -0
- mannf/product/security/checks/misconfig.py +158 -0
- mannf/product/security/checks/resource_consumption.py +70 -0
- mannf/product/security/checks/sensitive_flows.py +80 -0
- mannf/product/security/checks/ssrf.py +101 -0
- mannf/product/security/checks/unsafe_consumption.py +120 -0
- mannf/product/security/models.py +92 -0
- mannf/product/security/plugin_loader.py +182 -0
- mannf/product/security/reporter.py +92 -0
- mannf/product/security/scanner.py +183 -0
- mannf/product/server.py +6220 -0
- mannf/product/setup_wizard.py +873 -0
- mannf/product/status.py +404 -0
- mannf/product/storage/__init__.py +10 -0
- mannf/product/storage/artifact_store.py +343 -0
- mannf/product/telemetry.py +300 -0
- mannf/product/uninstall.py +169 -0
- mannf/product/upgrade.py +139 -0
- mannf/product/weights/__init__.py +13 -0
- mannf/product/weights/blob_store.py +299 -0
- mannf/product/weights/factory.py +42 -0
- mannf/product/weights/registry.py +159 -0
- mannf/product/weights/store.py +210 -0
- mannf/regression/__init__.py +7 -0
- mannf/regression/differ.py +9 -0
- mannf/regression/masking.py +9 -0
- mannf/regression/models.py +9 -0
- mannf/regression/recorder.py +9 -0
- mannf/regression/replayer.py +9 -0
- mannf/security/__init__.py +7 -0
- mannf/security/belief_guided.py +9 -0
- mannf/security/checks/__init__.py +7 -0
- mannf/security/checks/base.py +9 -0
- mannf/security/checks/bfla.py +9 -0
- mannf/security/checks/bola.py +9 -0
- mannf/security/checks/bopla.py +9 -0
- mannf/security/checks/broken_auth.py +9 -0
- mannf/security/checks/graphql_security.py +9 -0
- mannf/security/checks/inventory.py +9 -0
- mannf/security/checks/misconfig.py +9 -0
- mannf/security/checks/resource_consumption.py +9 -0
- mannf/security/checks/sensitive_flows.py +9 -0
- mannf/security/checks/ssrf.py +9 -0
- mannf/security/checks/unsafe_consumption.py +9 -0
- mannf/security/models.py +9 -0
- mannf/security/reporter.py +9 -0
- mannf/security/scanner.py +9 -0
- mannf/server.py +9 -0
- mannf/testing/__init__.py +7 -0
- mannf/testing/adaptive_controller.py +9 -0
- mannf/testing/models.py +9 -0
- mannf/weights/__init__.py +7 -0
- mannf/weights/registry.py +9 -0
- mannf/weights/store.py +9 -0
- nat_engine-1.dist-info/METADATA +555 -0
- nat_engine-1.dist-info/RECORD +299 -0
- nat_engine-1.dist-info/WHEEL +5 -0
- nat_engine-1.dist-info/entry_points.txt +4 -0
- nat_engine-1.dist-info/licenses/LICENSE +651 -0
- nat_engine-1.dist-info/licenses/NOTICE +178 -0
- nat_engine-1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
# Copyright (C) 2026 Brad Guider
|
|
2
|
+
# This file is part of NAT (Neural Agent Testing Framework).
|
|
3
|
+
# Licensed under the AGPL-3.0. See LICENSE for details.
|
|
4
|
+
# Commercial licensing available — see COMMERCIAL_LICENSE.md.
|
|
5
|
+
|
|
6
|
+
"""Interactive setup wizard for NAT — guides users through full end-to-end configuration.
|
|
7
|
+
|
|
8
|
+
This module implements a 7-phase interactive wizard that configures NAT from
|
|
9
|
+
scratch, covering mode selection, spec ingestion, authentication, exporter
|
|
10
|
+
setup (with live connection tests), and writing the ``.natrc`` config file.
|
|
11
|
+
|
|
12
|
+
Non-interactive mode is also supported via the ``--non-interactive`` flag
|
|
13
|
+
combined with environment variables (see :func:`run_setup_wizard`).
|
|
14
|
+
|
|
15
|
+
Entry point::
|
|
16
|
+
|
|
17
|
+
async def run_setup_wizard(args: argparse.Namespace) -> int:
|
|
18
|
+
...
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import platform
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Constants
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_SEVERITY_CHOICES = ("critical", "high", "medium", "low", "info")
|
|
41
|
+
|
|
42
|
+
_MODE_SAAS = "saas"
|
|
43
|
+
_MODE_SELF_HOSTED = "self-hosted"
|
|
44
|
+
_MODE_CLI = "cli"
|
|
45
|
+
|
|
46
|
+
_AUTH_BEARER = "bearer"
|
|
47
|
+
_AUTH_API_KEY = "api-key"
|
|
48
|
+
_AUTH_OAUTH2 = "oauth2"
|
|
49
|
+
_AUTH_NONE = "none"
|
|
50
|
+
|
|
51
|
+
# Exporter-specific environment variable mappings
|
|
52
|
+
# Maps exporter name -> {config_key: env_var_name}
|
|
53
|
+
_EXPORTER_ENV_VARS: dict[str, dict[str, str]] = {
|
|
54
|
+
"jira": {
|
|
55
|
+
"base_url": "NAT_JIRA_BASE_URL",
|
|
56
|
+
"project_key": "NAT_JIRA_PROJECT_KEY",
|
|
57
|
+
"email": "NAT_JIRA_EMAIL",
|
|
58
|
+
"api_token": "NAT_JIRA_API_TOKEN",
|
|
59
|
+
},
|
|
60
|
+
"github-issues": {
|
|
61
|
+
"token": "NAT_GITHUB_TOKEN",
|
|
62
|
+
"repo": "NAT_GITHUB_REPO",
|
|
63
|
+
},
|
|
64
|
+
"gitlab": {
|
|
65
|
+
"gitlab_url": "NAT_GITLAB_URL",
|
|
66
|
+
"project_id": "NAT_GITLAB_PROJECT_ID",
|
|
67
|
+
"private_token": "NAT_GITLAB_TOKEN",
|
|
68
|
+
},
|
|
69
|
+
"linear": {
|
|
70
|
+
"api_key": "NAT_LINEAR_API_KEY",
|
|
71
|
+
"team_id": "NAT_LINEAR_TEAM_ID",
|
|
72
|
+
},
|
|
73
|
+
"azure-devops": {
|
|
74
|
+
"organization": "NAT_ADO_ORG",
|
|
75
|
+
"project": "NAT_ADO_PROJECT",
|
|
76
|
+
"pat": "NAT_ADO_PAT",
|
|
77
|
+
},
|
|
78
|
+
"shortcut": {
|
|
79
|
+
"api_token": "NAT_SHORTCUT_TOKEN",
|
|
80
|
+
"project_id": "NAT_SHORTCUT_PROJECT_ID",
|
|
81
|
+
},
|
|
82
|
+
"pagerduty": {
|
|
83
|
+
"routing_key": "NAT_PAGERDUTY_ROUTING_KEY",
|
|
84
|
+
},
|
|
85
|
+
"servicenow": {
|
|
86
|
+
"instance_url": "NAT_SERVICENOW_URL",
|
|
87
|
+
"username": "NAT_SERVICENOW_USER",
|
|
88
|
+
"password": "NAT_SERVICENOW_PASSWORD",
|
|
89
|
+
},
|
|
90
|
+
"webhook": {
|
|
91
|
+
"webhook_url": "NAT_WEBHOOK_URL",
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Human-readable prompts for exporter config fields
|
|
96
|
+
_EXPORTER_FIELD_PROMPTS: dict[str, dict[str, str]] = {
|
|
97
|
+
"jira": {
|
|
98
|
+
"base_url": "Jira Cloud base URL (e.g. https://myco.atlassian.net)",
|
|
99
|
+
"project_key": "Jira project key (e.g. SEC)",
|
|
100
|
+
"email": "Atlassian account email",
|
|
101
|
+
"api_token": "Atlassian API token (from id.atlassian.com → Security → API tokens)",
|
|
102
|
+
"issue_type": "Issue type (default: Bug)",
|
|
103
|
+
"component": "Jira component name (optional)",
|
|
104
|
+
},
|
|
105
|
+
"github-issues": {
|
|
106
|
+
"token": "GitHub personal access token (PAT) with 'repo' scope",
|
|
107
|
+
"repo": "Repository in owner/repo format (e.g. myorg/myrepo)",
|
|
108
|
+
"labels": "Comma-separated extra labels (optional)",
|
|
109
|
+
"deduplicate": "Deduplicate issues? [true/false] (default: false)",
|
|
110
|
+
},
|
|
111
|
+
"gitlab": {
|
|
112
|
+
"gitlab_url": "GitLab instance URL (e.g. https://gitlab.com)",
|
|
113
|
+
"project_id": "GitLab project ID or namespace/project slug",
|
|
114
|
+
"private_token": "GitLab Personal Access Token with 'api' scope",
|
|
115
|
+
"labels": "Comma-separated extra labels (optional)",
|
|
116
|
+
},
|
|
117
|
+
"linear": {
|
|
118
|
+
"api_key": "Linear personal API key",
|
|
119
|
+
"team_id": "Linear team ID",
|
|
120
|
+
"project_id": "Linear project ID (optional)",
|
|
121
|
+
},
|
|
122
|
+
"azure-devops": {
|
|
123
|
+
"organization": "Azure DevOps organization name",
|
|
124
|
+
"project": "Azure DevOps project name",
|
|
125
|
+
"pat": "Personal Access Token with Work Items (Read & Write) scope",
|
|
126
|
+
"work_item_type": "Work item type (default: Bug)",
|
|
127
|
+
},
|
|
128
|
+
"shortcut": {
|
|
129
|
+
"api_token": "Shortcut API token",
|
|
130
|
+
"project_id": "Shortcut project ID (integer)",
|
|
131
|
+
"story_type": "Story type: bug, chore, or feature (default: bug)",
|
|
132
|
+
},
|
|
133
|
+
"pagerduty": {
|
|
134
|
+
"routing_key": "PagerDuty integration key (Events API v2 routing key)",
|
|
135
|
+
"source": "Source label (default: nat-security-scan)",
|
|
136
|
+
},
|
|
137
|
+
"servicenow": {
|
|
138
|
+
"instance_url": "ServiceNow instance URL (e.g. https://myco.service-now.com)",
|
|
139
|
+
"username": "ServiceNow username",
|
|
140
|
+
"password": "ServiceNow password",
|
|
141
|
+
"oauth_token": "OAuth bearer token (alternative to username/password, optional)",
|
|
142
|
+
"table": "Table: incident or change_request (default: incident)",
|
|
143
|
+
},
|
|
144
|
+
"webhook": {
|
|
145
|
+
"webhook_url": "Webhook URL (HTTP/HTTPS endpoint to POST findings to)",
|
|
146
|
+
"auth_header": "Authorization header value (optional, e.g. Bearer mytoken)",
|
|
147
|
+
"hmac_secret": "HMAC-SHA256 signing secret (optional)",
|
|
148
|
+
"batch_mode": "Send all findings in one POST? [true/false] (default: false)",
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
# Required fields per exporter (subset of above that must not be empty)
|
|
153
|
+
_EXPORTER_REQUIRED_FIELDS: dict[str, list[str]] = {
|
|
154
|
+
"jira": ["base_url", "project_key", "email", "api_token"],
|
|
155
|
+
"github-issues": ["token", "repo"],
|
|
156
|
+
"gitlab": ["gitlab_url", "project_id", "private_token"],
|
|
157
|
+
"linear": ["api_key", "team_id"],
|
|
158
|
+
"azure-devops": ["organization", "project", "pat"],
|
|
159
|
+
"shortcut": ["api_token", "project_id"],
|
|
160
|
+
"pagerduty": ["routing_key"],
|
|
161
|
+
"servicenow": ["instance_url"],
|
|
162
|
+
"webhook": ["webhook_url"],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# I/O helpers
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _print(msg: str = "") -> None:
|
|
172
|
+
print(msg) # noqa: T201 # lgtm[py/clear-text-logging-sensitive-data]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _prompt(message: str, default: str = "", secret: bool = False) -> str:
|
|
176
|
+
"""Prompt the user for input, optionally with a default value.
|
|
177
|
+
|
|
178
|
+
Parameters
|
|
179
|
+
----------
|
|
180
|
+
message:
|
|
181
|
+
The prompt message to display.
|
|
182
|
+
default:
|
|
183
|
+
Default value shown in brackets. Used if the user presses Enter.
|
|
184
|
+
secret:
|
|
185
|
+
If True, suppress echoing the input (for passwords/tokens).
|
|
186
|
+
"""
|
|
187
|
+
if default:
|
|
188
|
+
display = f"{message} [{default}]: "
|
|
189
|
+
else:
|
|
190
|
+
display = f"{message}: "
|
|
191
|
+
|
|
192
|
+
if secret:
|
|
193
|
+
import getpass # noqa: PLC0415
|
|
194
|
+
value = getpass.getpass(display)
|
|
195
|
+
else:
|
|
196
|
+
value = input(display)
|
|
197
|
+
|
|
198
|
+
return value.strip() or default
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _prompt_choice(message: str, choices: list[str], default: str = "") -> str:
|
|
202
|
+
"""Prompt the user to choose one of *choices*.
|
|
203
|
+
|
|
204
|
+
Accepts the choice number or the value directly. Repeats until valid.
|
|
205
|
+
"""
|
|
206
|
+
for i, choice in enumerate(choices, 1):
|
|
207
|
+
_print(f" {i}. {choice}")
|
|
208
|
+
|
|
209
|
+
while True:
|
|
210
|
+
raw = _prompt(message, default=default)
|
|
211
|
+
# Accept numeric index
|
|
212
|
+
if raw.isdigit():
|
|
213
|
+
idx = int(raw) - 1
|
|
214
|
+
if 0 <= idx < len(choices):
|
|
215
|
+
return choices[idx]
|
|
216
|
+
# Accept the value directly (case-insensitive)
|
|
217
|
+
for choice in choices:
|
|
218
|
+
if raw.lower() == choice.lower():
|
|
219
|
+
return choice
|
|
220
|
+
_print(f" ⚠️ Please enter a number (1–{len(choices)}) or one of {choices}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _prompt_yes_no(message: str, default: bool = True) -> bool:
|
|
224
|
+
"""Prompt for a yes/no answer."""
|
|
225
|
+
hint = "[Y/n]" if default else "[y/N]"
|
|
226
|
+
raw = _prompt(f"{message} {hint}").lower()
|
|
227
|
+
if not raw:
|
|
228
|
+
return default
|
|
229
|
+
return raw in ("y", "yes")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# Environment detection helpers
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _detect_docker() -> bool:
|
|
238
|
+
"""Return True if Docker is available on the system PATH."""
|
|
239
|
+
return shutil.which("docker") is not None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _detect_existing_natrc() -> Path | None:
|
|
243
|
+
"""Return the path to an existing .natrc file, or None."""
|
|
244
|
+
for candidate in (Path.cwd() / ".natrc", Path.home() / ".natrc"):
|
|
245
|
+
if candidate.is_file():
|
|
246
|
+
return candidate
|
|
247
|
+
return None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _detect_python_version() -> str:
|
|
251
|
+
return f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
# ---------------------------------------------------------------------------
|
|
255
|
+
# Phase 1 — Environment detection
|
|
256
|
+
# ---------------------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _phase1_detect_environment() -> dict[str, Any]:
|
|
260
|
+
"""Detect and display system environment information."""
|
|
261
|
+
_print()
|
|
262
|
+
_print("━" * 60)
|
|
263
|
+
_print(" Phase 1 — Environment Detection")
|
|
264
|
+
_print("━" * 60)
|
|
265
|
+
|
|
266
|
+
python_ver = _detect_python_version()
|
|
267
|
+
os_name = f"{platform.system()} {platform.release()}"
|
|
268
|
+
docker_ok = _detect_docker()
|
|
269
|
+
existing_rc = _detect_existing_natrc()
|
|
270
|
+
|
|
271
|
+
_print()
|
|
272
|
+
_print(f" Python: {python_ver}")
|
|
273
|
+
_print(f" OS: {os_name}")
|
|
274
|
+
_print(f" Docker: {'✅ available' if docker_ok else '❌ not found'}")
|
|
275
|
+
_print(
|
|
276
|
+
f" .natrc: {'✅ found at ' + str(existing_rc) if existing_rc else '❌ none found'}"
|
|
277
|
+
)
|
|
278
|
+
_print()
|
|
279
|
+
|
|
280
|
+
return {
|
|
281
|
+
"python_version": python_ver,
|
|
282
|
+
"os": os_name,
|
|
283
|
+
"docker_available": docker_ok,
|
|
284
|
+
"existing_natrc": existing_rc,
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Phase 2 — Mode selection
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _phase2_select_mode(env: dict[str, Any], non_interactive: bool) -> dict[str, Any]:
|
|
294
|
+
"""Prompt user for deployment mode and associated credentials."""
|
|
295
|
+
_print("━" * 60)
|
|
296
|
+
_print(" Phase 2 — Deployment Mode")
|
|
297
|
+
_print("━" * 60)
|
|
298
|
+
_print()
|
|
299
|
+
|
|
300
|
+
mode_config: dict[str, Any] = {}
|
|
301
|
+
|
|
302
|
+
if non_interactive:
|
|
303
|
+
raw_mode = os.environ.get("NAT_MODE", _MODE_CLI).lower()
|
|
304
|
+
if raw_mode not in (_MODE_SAAS, _MODE_SELF_HOSTED, _MODE_CLI):
|
|
305
|
+
logger.warning("Unknown NAT_MODE '%s' — defaulting to cli", raw_mode)
|
|
306
|
+
raw_mode = _MODE_CLI
|
|
307
|
+
mode_config["mode"] = raw_mode
|
|
308
|
+
if raw_mode == _MODE_SAAS:
|
|
309
|
+
mode_config["nat_api_key"] = os.environ.get("NAT_API_KEY", "")
|
|
310
|
+
elif raw_mode == _MODE_SELF_HOSTED:
|
|
311
|
+
mode_config["database_url"] = os.environ.get("DATABASE_URL", "")
|
|
312
|
+
_print(f" Mode: {raw_mode} (from NAT_MODE env var)")
|
|
313
|
+
_print()
|
|
314
|
+
return mode_config
|
|
315
|
+
|
|
316
|
+
_print(" How will you use NAT?")
|
|
317
|
+
mode = _prompt_choice(
|
|
318
|
+
" Choice",
|
|
319
|
+
[_MODE_SAAS, _MODE_SELF_HOSTED, _MODE_CLI],
|
|
320
|
+
default="1",
|
|
321
|
+
)
|
|
322
|
+
mode_config["mode"] = mode
|
|
323
|
+
|
|
324
|
+
if mode == _MODE_SAAS:
|
|
325
|
+
_print()
|
|
326
|
+
_print(" Enter your NAT API key (from app.nat-testing.io → Settings → API Keys):")
|
|
327
|
+
api_key = _prompt(" API key", secret=True)
|
|
328
|
+
mode_config["nat_api_key"] = api_key
|
|
329
|
+
|
|
330
|
+
elif mode == _MODE_SELF_HOSTED:
|
|
331
|
+
_print()
|
|
332
|
+
_print(" Self-hosted mode requires a PostgreSQL (or SQLite) database.")
|
|
333
|
+
db_url = _prompt(
|
|
334
|
+
" DATABASE_URL",
|
|
335
|
+
default=os.environ.get("DATABASE_URL", "sqlite+aiosqlite:///./nat.db"),
|
|
336
|
+
)
|
|
337
|
+
mode_config["database_url"] = db_url
|
|
338
|
+
|
|
339
|
+
_print()
|
|
340
|
+
_print(" 💡 Tip: run 'alembic upgrade head' to apply database migrations.")
|
|
341
|
+
if _prompt_yes_no(" Run Alembic migrations now?", default=False):
|
|
342
|
+
_run_alembic_upgrade()
|
|
343
|
+
|
|
344
|
+
_print()
|
|
345
|
+
return mode_config
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _run_alembic_upgrade() -> None:
|
|
349
|
+
"""Attempt to run 'alembic upgrade head' in a subprocess."""
|
|
350
|
+
try:
|
|
351
|
+
result = subprocess.run( # noqa: S603, S607
|
|
352
|
+
["alembic", "upgrade", "head"],
|
|
353
|
+
capture_output=True,
|
|
354
|
+
text=True,
|
|
355
|
+
timeout=60,
|
|
356
|
+
)
|
|
357
|
+
if result.returncode == 0:
|
|
358
|
+
_print(" ✅ Alembic migrations applied successfully.")
|
|
359
|
+
else:
|
|
360
|
+
_print(f" ⚠️ Alembic exited with code {result.returncode}:")
|
|
361
|
+
_print(f" {result.stderr.strip()}")
|
|
362
|
+
except FileNotFoundError:
|
|
363
|
+
_print(" ⚠️ 'alembic' command not found — run it manually: alembic upgrade head")
|
|
364
|
+
except subprocess.TimeoutExpired:
|
|
365
|
+
_print(" ⚠️ Alembic timed out — run it manually: alembic upgrade head")
|
|
366
|
+
except Exception as exc: # noqa: BLE001
|
|
367
|
+
_print(f" ⚠️ Failed to run Alembic: {exc}")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
# Phase 3 — Spec / ingestion setup
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _phase3_spec_setup(non_interactive: bool) -> dict[str, Any]:
|
|
376
|
+
"""Prompt for API spec location, detect format, count endpoints."""
|
|
377
|
+
_print("━" * 60)
|
|
378
|
+
_print(" Phase 3 — API Spec / Ingestion Setup")
|
|
379
|
+
_print("━" * 60)
|
|
380
|
+
_print()
|
|
381
|
+
|
|
382
|
+
spec_config: dict[str, Any] = {}
|
|
383
|
+
|
|
384
|
+
if non_interactive:
|
|
385
|
+
spec_path = os.environ.get("NAT_SPEC_PATH", "")
|
|
386
|
+
base_url = os.environ.get("NAT_BASE_URL", "")
|
|
387
|
+
else:
|
|
388
|
+
_print(" Where is your API spec?")
|
|
389
|
+
_print(" (Accepts a file path or a URL)")
|
|
390
|
+
spec_path = _prompt(" Spec path or URL")
|
|
391
|
+
base_url = _prompt(" Base URL of your API (e.g. https://api.example.com)")
|
|
392
|
+
|
|
393
|
+
spec_config["spec_path"] = spec_path
|
|
394
|
+
spec_config["base_url"] = base_url
|
|
395
|
+
|
|
396
|
+
if spec_path:
|
|
397
|
+
detected = _try_detect_spec(spec_path)
|
|
398
|
+
if detected:
|
|
399
|
+
format_name = detected.get("format", "unknown")
|
|
400
|
+
endpoint_count = detected.get("endpoint_count", "?")
|
|
401
|
+
_print(
|
|
402
|
+
f" ✅ Detected: {format_name} — {endpoint_count} endpoint(s)"
|
|
403
|
+
)
|
|
404
|
+
spec_config["detected_format"] = format_name
|
|
405
|
+
spec_config["endpoint_count"] = endpoint_count
|
|
406
|
+
else:
|
|
407
|
+
_print(" ⚠️ Could not detect spec format — will attempt auto-detection at scan time.")
|
|
408
|
+
|
|
409
|
+
_print()
|
|
410
|
+
return spec_config
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _try_detect_spec(spec_path: str) -> dict[str, Any] | None:
|
|
414
|
+
"""Attempt to detect the format and count of endpoints in *spec_path*.
|
|
415
|
+
|
|
416
|
+
Returns a dict with ``format`` and ``endpoint_count`` keys, or None.
|
|
417
|
+
"""
|
|
418
|
+
try:
|
|
419
|
+
from mannf.product.ingestors.loader import get_ingestor_for_source # noqa: PLC0415
|
|
420
|
+
from mannf.product.ingestors.models import IngestorSource # noqa: PLC0415
|
|
421
|
+
|
|
422
|
+
# Build a minimal IngestorSource
|
|
423
|
+
if spec_path.startswith(("http://", "https://")):
|
|
424
|
+
source = IngestorSource(format="auto", url=spec_path)
|
|
425
|
+
else:
|
|
426
|
+
source = IngestorSource(format="auto", path=spec_path)
|
|
427
|
+
|
|
428
|
+
ingestor = get_ingestor_for_source(source)
|
|
429
|
+
if ingestor is None:
|
|
430
|
+
return None
|
|
431
|
+
|
|
432
|
+
# Run ingest in a nested event loop call
|
|
433
|
+
result = asyncio.get_event_loop().run_until_complete(
|
|
434
|
+
ingestor.ingest(source, {})
|
|
435
|
+
)
|
|
436
|
+
if result and result.success:
|
|
437
|
+
return {
|
|
438
|
+
"format": ingestor.display_name,
|
|
439
|
+
"endpoint_count": len(result.endpoints),
|
|
440
|
+
}
|
|
441
|
+
except Exception as exc: # noqa: BLE001
|
|
442
|
+
logger.debug("Spec detection failed: %s", exc)
|
|
443
|
+
|
|
444
|
+
return None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
# Phase 4 — Authentication
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _phase4_auth_setup(spec_config: dict[str, Any], non_interactive: bool) -> dict[str, Any]:
|
|
453
|
+
"""Prompt for API authentication credentials."""
|
|
454
|
+
_print("━" * 60)
|
|
455
|
+
_print(" Phase 4 — API Authentication")
|
|
456
|
+
_print("━" * 60)
|
|
457
|
+
_print()
|
|
458
|
+
|
|
459
|
+
auth_config: dict[str, Any] = {}
|
|
460
|
+
|
|
461
|
+
if non_interactive:
|
|
462
|
+
token = os.environ.get("NAT_AUTH_TOKEN", "")
|
|
463
|
+
if token:
|
|
464
|
+
auth_config["auth_type"] = _AUTH_BEARER
|
|
465
|
+
auth_config["token"] = token
|
|
466
|
+
else:
|
|
467
|
+
auth_config["auth_type"] = _AUTH_NONE
|
|
468
|
+
_print(f" Auth type: {auth_config['auth_type']} (from env vars)")
|
|
469
|
+
_print()
|
|
470
|
+
return auth_config
|
|
471
|
+
|
|
472
|
+
_print(" Does your API require authentication?")
|
|
473
|
+
auth_choices = [
|
|
474
|
+
"None — public API",
|
|
475
|
+
"Bearer token",
|
|
476
|
+
"API key header",
|
|
477
|
+
"OAuth2 client credentials",
|
|
478
|
+
]
|
|
479
|
+
auth_choice = _prompt_choice(" Choice", auth_choices, default="1")
|
|
480
|
+
|
|
481
|
+
if "None" in auth_choice:
|
|
482
|
+
auth_config["auth_type"] = _AUTH_NONE
|
|
483
|
+
elif "Bearer" in auth_choice:
|
|
484
|
+
auth_config["auth_type"] = _AUTH_BEARER
|
|
485
|
+
auth_config["token"] = _prompt(" Bearer token", secret=True)
|
|
486
|
+
elif "API key" in auth_choice:
|
|
487
|
+
auth_config["auth_type"] = _AUTH_API_KEY
|
|
488
|
+
auth_config["api_key"] = _prompt(" API key value", secret=True)
|
|
489
|
+
auth_config["api_key_header"] = _prompt(
|
|
490
|
+
" Header name", default="X-API-Key"
|
|
491
|
+
)
|
|
492
|
+
elif "OAuth2" in auth_choice:
|
|
493
|
+
auth_config["auth_type"] = _AUTH_OAUTH2
|
|
494
|
+
auth_config["client_id"] = _prompt(" Client ID")
|
|
495
|
+
auth_config["client_secret"] = _prompt(" Client secret", secret=True)
|
|
496
|
+
auth_config["token_url"] = _prompt(" Token URL (e.g. https://auth.example.com/oauth2/token)")
|
|
497
|
+
|
|
498
|
+
_print()
|
|
499
|
+
return auth_config
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
# Phase 5 — Export destination
|
|
504
|
+
# ---------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _phase5_exporter_setup(non_interactive: bool) -> dict[str, Any]:
|
|
508
|
+
"""Prompt for exporter selection, config, and run a live connection test."""
|
|
509
|
+
_print("━" * 60)
|
|
510
|
+
_print(" Phase 5 — Export Destination")
|
|
511
|
+
_print("━" * 60)
|
|
512
|
+
_print()
|
|
513
|
+
|
|
514
|
+
from mannf.product.exporters import BUILTIN_EXPORTERS # noqa: PLC0415
|
|
515
|
+
|
|
516
|
+
exporters = BUILTIN_EXPORTERS
|
|
517
|
+
|
|
518
|
+
if non_interactive:
|
|
519
|
+
exporter_name = os.environ.get("NAT_EXPORT", "").lower()
|
|
520
|
+
if not exporter_name:
|
|
521
|
+
_print(" No NAT_EXPORT set — skipping exporter configuration.")
|
|
522
|
+
_print()
|
|
523
|
+
return {}
|
|
524
|
+
|
|
525
|
+
exporter = next((e for e in exporters if e.name == exporter_name), None)
|
|
526
|
+
if exporter is None:
|
|
527
|
+
_print(f" ⚠️ Unknown exporter '{exporter_name}' — skipping.")
|
|
528
|
+
_print()
|
|
529
|
+
return {}
|
|
530
|
+
|
|
531
|
+
# Build config from env vars
|
|
532
|
+
env_map = _EXPORTER_ENV_VARS.get(exporter_name, {})
|
|
533
|
+
exporter_config: dict[str, str] = {}
|
|
534
|
+
for key, env_var in env_map.items():
|
|
535
|
+
value = os.environ.get(env_var, "")
|
|
536
|
+
if value:
|
|
537
|
+
exporter_config[key] = value
|
|
538
|
+
|
|
539
|
+
return _finalize_exporter_config(exporter, exporter_config)
|
|
540
|
+
|
|
541
|
+
_print(" Which system should NAT export findings to?")
|
|
542
|
+
exporter_names = [f"{e.display_name} ({e.name})" for e in exporters]
|
|
543
|
+
exporter_names.append("Skip — no exporter")
|
|
544
|
+
|
|
545
|
+
choice = _prompt_choice(" Choice", exporter_names, default="1")
|
|
546
|
+
|
|
547
|
+
if "Skip" in choice:
|
|
548
|
+
_print()
|
|
549
|
+
return {}
|
|
550
|
+
|
|
551
|
+
# Find the selected exporter
|
|
552
|
+
selected_exporter = None
|
|
553
|
+
for exporter in exporters:
|
|
554
|
+
label = f"{exporter.display_name} ({exporter.name})"
|
|
555
|
+
if label == choice:
|
|
556
|
+
selected_exporter = exporter
|
|
557
|
+
break
|
|
558
|
+
|
|
559
|
+
if selected_exporter is None:
|
|
560
|
+
_print(" ⚠️ Could not identify exporter — skipping.")
|
|
561
|
+
_print()
|
|
562
|
+
return {}
|
|
563
|
+
|
|
564
|
+
_print()
|
|
565
|
+
_print(f" Configuring: {selected_exporter.display_name}")
|
|
566
|
+
_print()
|
|
567
|
+
|
|
568
|
+
# Collect required fields
|
|
569
|
+
field_prompts = _EXPORTER_FIELD_PROMPTS.get(selected_exporter.name, {})
|
|
570
|
+
required_fields = _EXPORTER_REQUIRED_FIELDS.get(selected_exporter.name, [])
|
|
571
|
+
exporter_config = {}
|
|
572
|
+
|
|
573
|
+
for field_key, prompt_text in field_prompts.items():
|
|
574
|
+
is_required = field_key in required_fields
|
|
575
|
+
is_secret = any(word in field_key.lower() for word in ("token", "secret", "password", "pat", "key"))
|
|
576
|
+
prefix = " * " if is_required else " "
|
|
577
|
+
value = _prompt(f"{prefix}{prompt_text}", secret=is_secret)
|
|
578
|
+
if value:
|
|
579
|
+
exporter_config[field_key] = value
|
|
580
|
+
|
|
581
|
+
# Validate config
|
|
582
|
+
validation_errors = selected_exporter.validate_config(exporter_config)
|
|
583
|
+
if validation_errors:
|
|
584
|
+
_print()
|
|
585
|
+
_print(" ⚠️ Config validation errors:")
|
|
586
|
+
for err in validation_errors:
|
|
587
|
+
_print(f" • {err}")
|
|
588
|
+
_print(" Proceeding anyway — you can fix these in your .natrc file.")
|
|
589
|
+
|
|
590
|
+
# Severity threshold
|
|
591
|
+
_print()
|
|
592
|
+
_print(" Minimum severity to export:")
|
|
593
|
+
severity = _prompt_choice(" Choice", list(_SEVERITY_CHOICES), default="2")
|
|
594
|
+
exporter_config["min_severity"] = severity
|
|
595
|
+
|
|
596
|
+
# Live connection test
|
|
597
|
+
_print()
|
|
598
|
+
if _prompt_yes_no(" Test connection now?", default=True):
|
|
599
|
+
_run_connection_test(selected_exporter, exporter_config)
|
|
600
|
+
|
|
601
|
+
return _finalize_exporter_config(selected_exporter, exporter_config)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _run_connection_test(exporter: Any, config: dict) -> None:
|
|
605
|
+
"""Run test_connection() on *exporter* and print the result."""
|
|
606
|
+
_print(" 🔌 Testing connection…")
|
|
607
|
+
try:
|
|
608
|
+
loop = asyncio.get_event_loop()
|
|
609
|
+
if loop.is_running():
|
|
610
|
+
# Already inside an async context — schedule as a task
|
|
611
|
+
import concurrent.futures # noqa: PLC0415
|
|
612
|
+
future = asyncio.ensure_future(exporter.test_connection(config))
|
|
613
|
+
result = loop.run_until_complete(future)
|
|
614
|
+
else:
|
|
615
|
+
result = loop.run_until_complete(exporter.test_connection(config))
|
|
616
|
+
except Exception as exc: # noqa: BLE001
|
|
617
|
+
_print(f" ❌ Connection test raised an exception: {exc}")
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
if result.success:
|
|
621
|
+
_print(f" ✅ {result.message}")
|
|
622
|
+
else:
|
|
623
|
+
_print(f" ❌ {result.message}")
|
|
624
|
+
if result.details:
|
|
625
|
+
_print(f" Details: {result.details}")
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _finalize_exporter_config(exporter: Any, config: dict) -> dict[str, Any]:
|
|
629
|
+
"""Wrap the exporter config with its name for storage in .natrc."""
|
|
630
|
+
return {
|
|
631
|
+
"exporter_name": exporter.name,
|
|
632
|
+
"exporter_config": config,
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# ---------------------------------------------------------------------------
|
|
637
|
+
# Phase 6 — Write .natrc
|
|
638
|
+
# ---------------------------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _phase6_write_natrc(
|
|
642
|
+
mode_config: dict[str, Any],
|
|
643
|
+
spec_config: dict[str, Any],
|
|
644
|
+
auth_config: dict[str, Any],
|
|
645
|
+
exporter_data: dict[str, Any],
|
|
646
|
+
non_interactive: bool,
|
|
647
|
+
) -> Path | None:
|
|
648
|
+
"""Serialize collected config to YAML and write .natrc."""
|
|
649
|
+
_print("━" * 60)
|
|
650
|
+
_print(" Phase 6 — Write Configuration")
|
|
651
|
+
_print("━" * 60)
|
|
652
|
+
_print()
|
|
653
|
+
|
|
654
|
+
try:
|
|
655
|
+
import yaml # noqa: PLC0415
|
|
656
|
+
except ImportError:
|
|
657
|
+
_print(" ❌ PyYAML is not installed — cannot write .natrc.")
|
|
658
|
+
_print(" Install it with: pip install pyyaml")
|
|
659
|
+
_print()
|
|
660
|
+
return None
|
|
661
|
+
|
|
662
|
+
# Build the YAML config structure
|
|
663
|
+
config: dict[str, Any] = {}
|
|
664
|
+
|
|
665
|
+
# Mode
|
|
666
|
+
mode = mode_config.get("mode", _MODE_CLI)
|
|
667
|
+
config["mode"] = mode
|
|
668
|
+
if mode == _MODE_SAAS and mode_config.get("nat_api_key"):
|
|
669
|
+
config["nat_api_key"] = mode_config["nat_api_key"]
|
|
670
|
+
elif mode == _MODE_SELF_HOSTED and mode_config.get("database_url"):
|
|
671
|
+
config["database_url"] = mode_config["database_url"]
|
|
672
|
+
|
|
673
|
+
# Spec
|
|
674
|
+
if spec_config.get("spec_path"):
|
|
675
|
+
config["spec"] = spec_config["spec_path"]
|
|
676
|
+
if spec_config.get("base_url"):
|
|
677
|
+
config["base_url"] = spec_config["base_url"]
|
|
678
|
+
|
|
679
|
+
# Auth
|
|
680
|
+
auth_type = auth_config.get("auth_type", _AUTH_NONE)
|
|
681
|
+
if auth_type == _AUTH_BEARER and auth_config.get("token"):
|
|
682
|
+
config["auth"] = {"type": "bearer", "token": auth_config["token"]}
|
|
683
|
+
elif auth_type == _AUTH_API_KEY and auth_config.get("api_key"):
|
|
684
|
+
config["auth"] = {
|
|
685
|
+
"type": "api-key",
|
|
686
|
+
"key": auth_config["api_key"],
|
|
687
|
+
"header": auth_config.get("api_key_header", "X-API-Key"),
|
|
688
|
+
}
|
|
689
|
+
elif auth_type == _AUTH_OAUTH2:
|
|
690
|
+
config["auth"] = {
|
|
691
|
+
"type": "oauth2",
|
|
692
|
+
"client_id": auth_config.get("client_id", ""),
|
|
693
|
+
"client_secret": auth_config.get("client_secret", ""),
|
|
694
|
+
"token_url": auth_config.get("token_url", ""),
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
# Exporter
|
|
698
|
+
if exporter_data.get("exporter_name"):
|
|
699
|
+
exporter_cfg = dict(exporter_data.get("exporter_config", {}))
|
|
700
|
+
# Remove meta keys not part of exporter config
|
|
701
|
+
min_severity = exporter_cfg.pop("min_severity", "high")
|
|
702
|
+
config["export"] = exporter_data["exporter_name"]
|
|
703
|
+
config["export_config"] = exporter_cfg
|
|
704
|
+
config["export_min_severity"] = min_severity
|
|
705
|
+
|
|
706
|
+
yaml_text = yaml.dump(config, default_flow_style=False, allow_unicode=True, sort_keys=True)
|
|
707
|
+
|
|
708
|
+
# Determine destination
|
|
709
|
+
if non_interactive:
|
|
710
|
+
dest = Path.cwd() / ".natrc"
|
|
711
|
+
else:
|
|
712
|
+
_print(" Where should the config be saved?")
|
|
713
|
+
choices = [str(Path.cwd() / ".natrc"), str(Path.home() / ".natrc")]
|
|
714
|
+
dest_str = _prompt_choice(" Choice", choices, default="2")
|
|
715
|
+
dest = Path(dest_str)
|
|
716
|
+
|
|
717
|
+
# Warn about secrets
|
|
718
|
+
has_secrets = any(
|
|
719
|
+
k in config
|
|
720
|
+
for k in ("nat_api_key", "auth")
|
|
721
|
+
) or bool(exporter_data.get("exporter_config"))
|
|
722
|
+
|
|
723
|
+
if has_secrets:
|
|
724
|
+
_print()
|
|
725
|
+
_print(" ⚠️ Your config contains credentials/secrets.")
|
|
726
|
+
_print(" Ensure the .natrc file has restricted permissions:")
|
|
727
|
+
_print(" chmod 600 " + str(dest))
|
|
728
|
+
|
|
729
|
+
# Write the file
|
|
730
|
+
try:
|
|
731
|
+
dest.write_text(yaml_text, encoding="utf-8")
|
|
732
|
+
_print()
|
|
733
|
+
_print(f" ✅ Config written to {dest}")
|
|
734
|
+
except OSError as exc:
|
|
735
|
+
_print(f" ❌ Failed to write config: {exc}")
|
|
736
|
+
_print()
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
_print()
|
|
740
|
+
return dest
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
# ---------------------------------------------------------------------------
|
|
744
|
+
# Phase 7 — Verify + offer first run
|
|
745
|
+
# ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _phase7_verify_and_run(
|
|
749
|
+
natrc_path: Path | None,
|
|
750
|
+
spec_config: dict[str, Any],
|
|
751
|
+
non_interactive: bool,
|
|
752
|
+
) -> int:
|
|
753
|
+
"""Run validation checks and optionally kick off a first scan."""
|
|
754
|
+
_print("━" * 60)
|
|
755
|
+
_print(" Phase 7 — Verify & First Run")
|
|
756
|
+
_print("━" * 60)
|
|
757
|
+
_print()
|
|
758
|
+
|
|
759
|
+
all_ok = True
|
|
760
|
+
|
|
761
|
+
# Check 1: .natrc exists
|
|
762
|
+
if natrc_path and natrc_path.is_file():
|
|
763
|
+
_print(f" ✅ Config file: {natrc_path}")
|
|
764
|
+
else:
|
|
765
|
+
_print(" ⚠️ No .natrc file was written.")
|
|
766
|
+
all_ok = False
|
|
767
|
+
|
|
768
|
+
# Check 2: spec provided
|
|
769
|
+
spec_path = spec_config.get("spec_path", "")
|
|
770
|
+
if spec_path:
|
|
771
|
+
_print(f" ✅ Spec path: {spec_path}")
|
|
772
|
+
else:
|
|
773
|
+
_print(" ⚠️ No spec path configured — provide --spec when running nat scan.")
|
|
774
|
+
all_ok = False
|
|
775
|
+
|
|
776
|
+
# Check 3: base URL
|
|
777
|
+
base_url = spec_config.get("base_url", "")
|
|
778
|
+
if base_url:
|
|
779
|
+
_print(f" ✅ Base URL: {base_url}")
|
|
780
|
+
else:
|
|
781
|
+
_print(" ⚠️ No base URL configured — provide --base-url when running nat scan.")
|
|
782
|
+
|
|
783
|
+
_print()
|
|
784
|
+
|
|
785
|
+
if all_ok and not non_interactive:
|
|
786
|
+
if _prompt_yes_no(" Run a scan now?", default=False):
|
|
787
|
+
_print()
|
|
788
|
+
_print(" 🚀 Launching nat security-scan…")
|
|
789
|
+
_print()
|
|
790
|
+
cmd = ["nat", "security-scan"]
|
|
791
|
+
if spec_path:
|
|
792
|
+
cmd += ["--spec", spec_path]
|
|
793
|
+
if base_url:
|
|
794
|
+
cmd += ["--base-url", base_url]
|
|
795
|
+
try:
|
|
796
|
+
subprocess.run(cmd, check=False) # noqa: S603
|
|
797
|
+
except FileNotFoundError:
|
|
798
|
+
_print(" ❌ 'nat' command not found — install nat-engine with pip.")
|
|
799
|
+
return 1
|
|
800
|
+
|
|
801
|
+
_print()
|
|
802
|
+
_print(" 🎉 Setup complete!")
|
|
803
|
+
_print(" Run 'nat security-scan --help' to see all available options.")
|
|
804
|
+
_print()
|
|
805
|
+
return 0
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
# ---------------------------------------------------------------------------
|
|
809
|
+
# Main entry point
|
|
810
|
+
# ---------------------------------------------------------------------------
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
async def run_setup_wizard(args: argparse.Namespace) -> int:
|
|
814
|
+
"""Main entry point for the interactive setup wizard.
|
|
815
|
+
|
|
816
|
+
Parameters
|
|
817
|
+
----------
|
|
818
|
+
args:
|
|
819
|
+
Parsed CLI arguments. The following attributes are read:
|
|
820
|
+
|
|
821
|
+
- ``non_interactive`` (bool) — skip all prompts, read from env vars.
|
|
822
|
+
|
|
823
|
+
Returns
|
|
824
|
+
-------
|
|
825
|
+
int
|
|
826
|
+
Exit code (0 = success, non-zero = error).
|
|
827
|
+
"""
|
|
828
|
+
from mannf._version import __version__ # noqa: PLC0415
|
|
829
|
+
|
|
830
|
+
non_interactive: bool = getattr(args, "non_interactive", False)
|
|
831
|
+
|
|
832
|
+
_print()
|
|
833
|
+
_print("┏" + "━" * 58 + "┓")
|
|
834
|
+
_print(f" 🚀 NAT Setup Wizard (nat-engine v{__version__})")
|
|
835
|
+
_print("┗" + "━" * 58 + "┛")
|
|
836
|
+
|
|
837
|
+
# Phase 1 — Detect environment
|
|
838
|
+
env = _phase1_detect_environment()
|
|
839
|
+
|
|
840
|
+
# Warn if an existing .natrc will be overwritten
|
|
841
|
+
if env.get("existing_natrc") and not non_interactive:
|
|
842
|
+
_print(f" ⚠️ Existing config found at {env['existing_natrc']}")
|
|
843
|
+
if not _prompt_yes_no(" Overwrite existing config?", default=False):
|
|
844
|
+
_print(" Setup cancelled. Existing config unchanged.")
|
|
845
|
+
_print()
|
|
846
|
+
return 0
|
|
847
|
+
_print()
|
|
848
|
+
|
|
849
|
+
# Phase 2 — Mode
|
|
850
|
+
mode_config = _phase2_select_mode(env, non_interactive)
|
|
851
|
+
|
|
852
|
+
# Phase 3 — Spec setup
|
|
853
|
+
spec_config = _phase3_spec_setup(non_interactive)
|
|
854
|
+
|
|
855
|
+
# Phase 4 — Auth
|
|
856
|
+
auth_config = _phase4_auth_setup(spec_config, non_interactive)
|
|
857
|
+
|
|
858
|
+
# Phase 5 — Exporter
|
|
859
|
+
exporter_data = _phase5_exporter_setup(non_interactive)
|
|
860
|
+
|
|
861
|
+
# Phase 6 — Write .natrc (run synchronously within async context)
|
|
862
|
+
natrc_path = await asyncio.get_event_loop().run_in_executor(
|
|
863
|
+
None,
|
|
864
|
+
_phase6_write_natrc,
|
|
865
|
+
mode_config,
|
|
866
|
+
spec_config,
|
|
867
|
+
auth_config,
|
|
868
|
+
exporter_data,
|
|
869
|
+
non_interactive,
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
# Phase 7 — Verify + optional first run
|
|
873
|
+
return _phase7_verify_and_run(natrc_path, spec_config, non_interactive)
|