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,383 @@
|
|
|
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
|
+
"""Generic Webhook exporter plugin — POSTs NAT findings as JSON to any HTTP endpoint.
|
|
7
|
+
|
|
8
|
+
This exporter enables integration with Zapier, n8n, Make, or any custom HTTP
|
|
9
|
+
endpoint without needing a dedicated plugin.
|
|
10
|
+
|
|
11
|
+
Usage
|
|
12
|
+
-----
|
|
13
|
+
Export findings to a webhook endpoint by supplying the following config keys
|
|
14
|
+
via ``--export-config`` on the CLI::
|
|
15
|
+
|
|
16
|
+
nat security-scan --spec api.yaml --base-url http://localhost \\
|
|
17
|
+
--export webhook \\
|
|
18
|
+
--export-config webhook_url=https://hooks.example.com/nat \\
|
|
19
|
+
--export-config hmac_secret=$WEBHOOK_SECRET
|
|
20
|
+
|
|
21
|
+
Example with batch mode::
|
|
22
|
+
|
|
23
|
+
nat security-scan --spec api.yaml --base-url http://localhost \\
|
|
24
|
+
--export webhook \\
|
|
25
|
+
--export-config webhook_url=https://hooks.zapier.com/hooks/catch/... \\
|
|
26
|
+
--export-config batch_mode=true
|
|
27
|
+
|
|
28
|
+
Required config keys
|
|
29
|
+
--------------------
|
|
30
|
+
webhook_url: The HTTP(S) URL to POST findings to.
|
|
31
|
+
|
|
32
|
+
Optional config keys
|
|
33
|
+
--------------------
|
|
34
|
+
auth_header: Value for the ``Authorization`` header (e.g.,
|
|
35
|
+
``"Bearer mytoken"`` or ``"Basic abc123"``).
|
|
36
|
+
custom_headers: Dict of additional HTTP headers to include in every
|
|
37
|
+
request.
|
|
38
|
+
hmac_secret: If provided, compute HMAC-SHA256 of the JSON payload
|
|
39
|
+
body and include it as ``X-NAT-Signature: sha256=<hex>``
|
|
40
|
+
header. Use this to verify the webhook origin on the
|
|
41
|
+
receiving end.
|
|
42
|
+
include_report_context: Whether to include scan-level context (scan_id,
|
|
43
|
+
timestamp, target_url) in each finding payload.
|
|
44
|
+
Defaults to ``true``.
|
|
45
|
+
batch_mode: If ``true``, send all findings in a single POST as a
|
|
46
|
+
JSON array. If ``false`` (default), send one POST per
|
|
47
|
+
finding.
|
|
48
|
+
timeout: HTTP request timeout in seconds (default ``30``).
|
|
49
|
+
|
|
50
|
+
Security considerations
|
|
51
|
+
-----------------------
|
|
52
|
+
* Always use HTTPS webhook URLs so payloads are encrypted in transit.
|
|
53
|
+
* Use ``hmac_secret`` to authenticate the source of the webhook. On the
|
|
54
|
+
receiving end, recompute HMAC-SHA256 of the raw body with the shared secret
|
|
55
|
+
and compare it to the ``X-NAT-Signature`` header value.
|
|
56
|
+
* Store the HMAC secret in an environment variable rather than hard-coding it.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import hashlib
|
|
62
|
+
import hmac
|
|
63
|
+
import json
|
|
64
|
+
import logging
|
|
65
|
+
from datetime import datetime, timezone
|
|
66
|
+
|
|
67
|
+
import httpx
|
|
68
|
+
|
|
69
|
+
from mannf.product.exporters.base import ExportResult, ExportSummary, ExporterPlugin, TestConnectionResult
|
|
70
|
+
from mannf.product.security.models import SecurityFinding, SecurityReport
|
|
71
|
+
|
|
72
|
+
logger = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class WebhookExporter(ExporterPlugin):
|
|
76
|
+
"""Exports NAT security findings as JSON payloads to any HTTP webhook endpoint.
|
|
77
|
+
|
|
78
|
+
Required config keys:
|
|
79
|
+
webhook_url: The HTTP(S) URL to POST findings to.
|
|
80
|
+
|
|
81
|
+
Optional config keys:
|
|
82
|
+
auth_header: ``Authorization`` header value.
|
|
83
|
+
custom_headers: Dict of additional HTTP headers.
|
|
84
|
+
hmac_secret: Shared secret for HMAC-SHA256 request signing.
|
|
85
|
+
include_report_context: Include scan-level context in each payload
|
|
86
|
+
(default ``true``).
|
|
87
|
+
batch_mode: Send all findings in one POST as a JSON array
|
|
88
|
+
(default ``false``).
|
|
89
|
+
timeout: HTTP timeout in seconds (default ``30``).
|
|
90
|
+
|
|
91
|
+
Example CLI usage::
|
|
92
|
+
|
|
93
|
+
nat security-scan --spec api.yaml --base-url http://localhost \\
|
|
94
|
+
--export webhook \\
|
|
95
|
+
--export-config webhook_url=https://hooks.example.com/nat \\
|
|
96
|
+
--export-config hmac_secret=$WEBHOOK_SECRET
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
name = "webhook"
|
|
100
|
+
display_name = "Generic Webhook"
|
|
101
|
+
description = "Export NAT findings as JSON payloads to any HTTP webhook endpoint."
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Config validation
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
108
|
+
errors: list[str] = []
|
|
109
|
+
|
|
110
|
+
webhook_url = config.get("webhook_url", "")
|
|
111
|
+
if not webhook_url:
|
|
112
|
+
errors.append("'webhook_url' is required")
|
|
113
|
+
elif not (webhook_url.startswith("http://") or webhook_url.startswith("https://")):
|
|
114
|
+
errors.append("'webhook_url' must start with 'http://' or 'https://'")
|
|
115
|
+
|
|
116
|
+
timeout = config.get("timeout")
|
|
117
|
+
if timeout is not None:
|
|
118
|
+
try:
|
|
119
|
+
t = float(timeout)
|
|
120
|
+
if t <= 0:
|
|
121
|
+
errors.append("'timeout' must be a positive number")
|
|
122
|
+
except (TypeError, ValueError):
|
|
123
|
+
errors.append("'timeout' must be a positive number")
|
|
124
|
+
|
|
125
|
+
custom_headers = config.get("custom_headers")
|
|
126
|
+
if custom_headers is not None and not isinstance(custom_headers, dict):
|
|
127
|
+
errors.append("'custom_headers' must be a dict")
|
|
128
|
+
|
|
129
|
+
return errors
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Connection test
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
async def test_connection(self, config: dict) -> TestConnectionResult:
|
|
136
|
+
"""Test connectivity via HEAD (or OPTIONS) on the webhook_url — confirms URL is reachable."""
|
|
137
|
+
errors = self.validate_config(config)
|
|
138
|
+
if errors:
|
|
139
|
+
return TestConnectionResult(
|
|
140
|
+
success=False,
|
|
141
|
+
message=f"Config validation failed: {'; '.join(errors)}",
|
|
142
|
+
details={"errors": errors},
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
webhook_url = config["webhook_url"]
|
|
146
|
+
timeout = self._get_timeout(config)
|
|
147
|
+
|
|
148
|
+
# Build minimal headers (auth only — no HMAC needed for a probe)
|
|
149
|
+
probe_headers: dict[str, str] = {}
|
|
150
|
+
if config.get("auth_header"):
|
|
151
|
+
probe_headers["Authorization"] = config["auth_header"]
|
|
152
|
+
if isinstance(config.get("custom_headers"), dict):
|
|
153
|
+
probe_headers.update(config["custom_headers"])
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
157
|
+
# Try HEAD first; fall back to OPTIONS if HEAD is not allowed
|
|
158
|
+
resp = await client.head(webhook_url, headers=probe_headers)
|
|
159
|
+
if resp.status_code == 405:
|
|
160
|
+
resp = await client.options(webhook_url, headers=probe_headers)
|
|
161
|
+
# Treat any 2xx or 3xx as reachable; even 4xx means the URL exists
|
|
162
|
+
reachable = resp.status_code < 500
|
|
163
|
+
except httpx.RequestError as exc:
|
|
164
|
+
return TestConnectionResult(
|
|
165
|
+
success=False,
|
|
166
|
+
message=f"Webhook URL unreachable: {exc}",
|
|
167
|
+
details={"webhook_url": webhook_url},
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if not reachable:
|
|
171
|
+
return TestConnectionResult(
|
|
172
|
+
success=False,
|
|
173
|
+
message=f"Webhook URL returned server error {resp.status_code}",
|
|
174
|
+
details={"webhook_url": webhook_url, "status_code": resp.status_code},
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
return TestConnectionResult(
|
|
178
|
+
success=True,
|
|
179
|
+
message=f"Webhook URL is reachable (HTTP {resp.status_code})",
|
|
180
|
+
details={"webhook_url": webhook_url, "status_code": resp.status_code},
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------
|
|
184
|
+
# Internal helpers
|
|
185
|
+
# ------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _build_headers(self, body_bytes: bytes, config: dict) -> dict[str, str]:
|
|
188
|
+
"""Build the full HTTP headers dict for a webhook request."""
|
|
189
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
190
|
+
|
|
191
|
+
auth_header = config.get("auth_header")
|
|
192
|
+
if auth_header:
|
|
193
|
+
headers["Authorization"] = auth_header
|
|
194
|
+
|
|
195
|
+
custom_headers = config.get("custom_headers")
|
|
196
|
+
if isinstance(custom_headers, dict):
|
|
197
|
+
headers.update(custom_headers)
|
|
198
|
+
|
|
199
|
+
hmac_secret = config.get("hmac_secret")
|
|
200
|
+
if hmac_secret:
|
|
201
|
+
secret_bytes = (
|
|
202
|
+
hmac_secret.encode() if isinstance(hmac_secret, str) else hmac_secret
|
|
203
|
+
)
|
|
204
|
+
digest = hmac.new(secret_bytes, body_bytes, hashlib.sha256).hexdigest()
|
|
205
|
+
headers["X-NAT-Signature"] = f"sha256={digest}"
|
|
206
|
+
|
|
207
|
+
return headers
|
|
208
|
+
|
|
209
|
+
def _build_finding_payload(
|
|
210
|
+
self,
|
|
211
|
+
finding: SecurityFinding,
|
|
212
|
+
report: SecurityReport,
|
|
213
|
+
config: dict,
|
|
214
|
+
) -> dict:
|
|
215
|
+
"""Build the JSON payload dict for a single finding."""
|
|
216
|
+
include_context = str(config.get("include_report_context", "true")).lower() not in (
|
|
217
|
+
"false",
|
|
218
|
+
"0",
|
|
219
|
+
"no",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
payload: dict = {
|
|
223
|
+
"check_id": finding.check_id,
|
|
224
|
+
"check_name": finding.check_name,
|
|
225
|
+
"severity": finding.severity,
|
|
226
|
+
"endpoint": finding.endpoint,
|
|
227
|
+
"title": finding.title,
|
|
228
|
+
"description": finding.description,
|
|
229
|
+
"evidence": finding.evidence,
|
|
230
|
+
"remediation": finding.remediation,
|
|
231
|
+
"cwe_id": finding.cwe_id,
|
|
232
|
+
"confidence": finding.confidence,
|
|
233
|
+
"exported_at": datetime.now(tz=timezone.utc).isoformat(),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if include_context:
|
|
237
|
+
payload["scan_id"] = report.scan_id
|
|
238
|
+
payload["timestamp"] = report.timestamp
|
|
239
|
+
payload["target_url"] = report.target_url
|
|
240
|
+
|
|
241
|
+
return payload
|
|
242
|
+
|
|
243
|
+
def _get_timeout(self, config: dict) -> float:
|
|
244
|
+
"""Return the configured timeout, falling back to 30 seconds."""
|
|
245
|
+
timeout = config.get("timeout", 30)
|
|
246
|
+
try:
|
|
247
|
+
return float(timeout)
|
|
248
|
+
except (TypeError, ValueError):
|
|
249
|
+
return 30.0
|
|
250
|
+
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# Single-finding export
|
|
253
|
+
# ------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
async def export_finding(
|
|
256
|
+
self,
|
|
257
|
+
finding: SecurityFinding,
|
|
258
|
+
report: SecurityReport,
|
|
259
|
+
config: dict,
|
|
260
|
+
) -> ExportResult:
|
|
261
|
+
webhook_url = config["webhook_url"]
|
|
262
|
+
timeout = self._get_timeout(config)
|
|
263
|
+
|
|
264
|
+
payload = self._build_finding_payload(finding, report, config)
|
|
265
|
+
body_bytes = json.dumps(payload).encode()
|
|
266
|
+
headers = self._build_headers(body_bytes, config)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
270
|
+
resp = await client.post(
|
|
271
|
+
webhook_url,
|
|
272
|
+
content=body_bytes,
|
|
273
|
+
headers=headers,
|
|
274
|
+
)
|
|
275
|
+
resp.raise_for_status()
|
|
276
|
+
except httpx.HTTPStatusError as exc:
|
|
277
|
+
error_msg = f"Webhook error {exc.response.status_code}: {exc.response.text}"
|
|
278
|
+
logger.warning("WebhookExporter: %s", error_msg)
|
|
279
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
280
|
+
except httpx.RequestError as exc:
|
|
281
|
+
error_msg = f"Webhook network error: {exc}"
|
|
282
|
+
logger.warning("WebhookExporter: %s", error_msg)
|
|
283
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
284
|
+
|
|
285
|
+
return ExportResult(
|
|
286
|
+
finding=finding,
|
|
287
|
+
success=True,
|
|
288
|
+
external_id=str(resp.status_code),
|
|
289
|
+
external_url=webhook_url,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# Batch export override
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
async def export_findings(
|
|
297
|
+
self,
|
|
298
|
+
findings: list[SecurityFinding],
|
|
299
|
+
report: SecurityReport,
|
|
300
|
+
config: dict,
|
|
301
|
+
min_severity: str = "low",
|
|
302
|
+
) -> ExportSummary:
|
|
303
|
+
"""Override to support batch_mode: send all findings in one POST."""
|
|
304
|
+
batch_mode = str(config.get("batch_mode", "false")).lower() in ("true", "1", "yes")
|
|
305
|
+
|
|
306
|
+
if not batch_mode:
|
|
307
|
+
return await super().export_findings(findings, report, config, min_severity)
|
|
308
|
+
|
|
309
|
+
# --- batch mode: filter by severity, then POST once as a JSON array ---
|
|
310
|
+
from mannf.product.exporters.base import _SEVERITY_ORDER # noqa: PLC0415
|
|
311
|
+
|
|
312
|
+
min_order = _SEVERITY_ORDER.get(min_severity.lower(), _SEVERITY_ORDER["low"])
|
|
313
|
+
|
|
314
|
+
eligible: list[SecurityFinding] = []
|
|
315
|
+
skipped = 0
|
|
316
|
+
for f in findings:
|
|
317
|
+
f_order = _SEVERITY_ORDER.get(f.severity.lower(), _SEVERITY_ORDER["info"])
|
|
318
|
+
if f_order > min_order:
|
|
319
|
+
skipped += 1
|
|
320
|
+
else:
|
|
321
|
+
eligible.append(f)
|
|
322
|
+
|
|
323
|
+
if not eligible:
|
|
324
|
+
return ExportSummary(
|
|
325
|
+
total=len(findings),
|
|
326
|
+
succeeded=0,
|
|
327
|
+
failed=0,
|
|
328
|
+
skipped=skipped,
|
|
329
|
+
results=[],
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
webhook_url = config["webhook_url"]
|
|
333
|
+
timeout = self._get_timeout(config)
|
|
334
|
+
|
|
335
|
+
payloads = [self._build_finding_payload(f, report, config) for f in eligible]
|
|
336
|
+
body_bytes = json.dumps(payloads).encode()
|
|
337
|
+
headers = self._build_headers(body_bytes, config)
|
|
338
|
+
|
|
339
|
+
results: list[ExportResult] = []
|
|
340
|
+
succeeded = 0
|
|
341
|
+
failed = 0
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
345
|
+
resp = await client.post(
|
|
346
|
+
webhook_url,
|
|
347
|
+
content=body_bytes,
|
|
348
|
+
headers=headers,
|
|
349
|
+
)
|
|
350
|
+
resp.raise_for_status()
|
|
351
|
+
|
|
352
|
+
for f in eligible:
|
|
353
|
+
results.append(
|
|
354
|
+
ExportResult(
|
|
355
|
+
finding=f,
|
|
356
|
+
success=True,
|
|
357
|
+
external_id=str(resp.status_code),
|
|
358
|
+
external_url=webhook_url,
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
succeeded = len(eligible)
|
|
362
|
+
|
|
363
|
+
except httpx.HTTPStatusError as exc:
|
|
364
|
+
error_msg = f"Webhook batch error {exc.response.status_code}: {exc.response.text}"
|
|
365
|
+
logger.warning("WebhookExporter: %s", error_msg)
|
|
366
|
+
for f in eligible:
|
|
367
|
+
results.append(ExportResult(finding=f, success=False, error=error_msg))
|
|
368
|
+
failed = len(eligible)
|
|
369
|
+
|
|
370
|
+
except httpx.RequestError as exc:
|
|
371
|
+
error_msg = f"Webhook batch network error: {exc}"
|
|
372
|
+
logger.warning("WebhookExporter: %s", error_msg)
|
|
373
|
+
for f in eligible:
|
|
374
|
+
results.append(ExportResult(finding=f, success=False, error=error_msg))
|
|
375
|
+
failed = len(eligible)
|
|
376
|
+
|
|
377
|
+
return ExportSummary(
|
|
378
|
+
total=len(findings),
|
|
379
|
+
succeeded=succeeded,
|
|
380
|
+
failed=failed,
|
|
381
|
+
skipped=skipped,
|
|
382
|
+
results=results,
|
|
383
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
"""NAT output formatters package.
|
|
7
|
+
|
|
8
|
+
Provides pluggable report formatters beyond the built-in text/json/html/junit
|
|
9
|
+
formats. Current formatters:
|
|
10
|
+
|
|
11
|
+
- :mod:`allure_formatter` — Allure JSON result files (one per test case)
|
|
12
|
+
- :mod:`ctrf_formatter` — Common Test Results Format (CTRF) JSON report
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from mannf.product.formatters.allure_formatter import write_allure_results
|
|
16
|
+
from mannf.product.formatters.ctrf_formatter import build_ctrf_report
|
|
17
|
+
|
|
18
|
+
__all__ = ["write_allure_results", "build_ctrf_report"]
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
"""Allure JSON result formatter for NAT scan output.
|
|
7
|
+
|
|
8
|
+
Converts a NAT :class:`~mannf.core.testing.models.TestSuite` and its
|
|
9
|
+
accompanying report dict into Allure's ``*-result.json`` file format (one
|
|
10
|
+
JSON file per test case), written to a caller-specified output directory.
|
|
11
|
+
|
|
12
|
+
Usage::
|
|
13
|
+
|
|
14
|
+
from mannf.product.formatters.allure_formatter import write_allure_results
|
|
15
|
+
|
|
16
|
+
write_allure_results(report, suite, output_dir="/tmp/allure-results")
|
|
17
|
+
|
|
18
|
+
The produced files are compatible with ``allure generate`` and the Allure
|
|
19
|
+
GitHub Actions step (``simple-elf/allure-report-action``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import uuid
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING, Any
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from mannf.core.testing.models import TestSuite
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Internal helpers
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
_ALLURE_STATUS_MAP = {
|
|
38
|
+
True: "passed",
|
|
39
|
+
False: "failed",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _allure_timestamp_ms() -> int:
|
|
44
|
+
"""Return the current UTC time as milliseconds since epoch."""
|
|
45
|
+
return int(datetime.now(timezone.utc).timestamp() * 1000)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _result_to_allure(
|
|
49
|
+
result: Any,
|
|
50
|
+
suite_name: str,
|
|
51
|
+
base_url: str,
|
|
52
|
+
start_ms: int,
|
|
53
|
+
) -> dict[str, Any]:
|
|
54
|
+
"""Convert a single :class:`~mannf.core.testing.models.TestResult` to an
|
|
55
|
+
Allure result dict.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
result:
|
|
60
|
+
The :class:`TestResult` object from a :class:`TestSuite`.
|
|
61
|
+
suite_name:
|
|
62
|
+
Human-readable name of the containing test suite.
|
|
63
|
+
base_url:
|
|
64
|
+
Base URL of the API under test — used to build a full URL label.
|
|
65
|
+
start_ms:
|
|
66
|
+
Suite start timestamp in milliseconds since epoch (used when the
|
|
67
|
+
result does not carry its own timestamp).
|
|
68
|
+
"""
|
|
69
|
+
tc_id = getattr(result, "test_case_id", None) or "unknown"
|
|
70
|
+
passed = getattr(result, "passed", True)
|
|
71
|
+
error = getattr(result, "error", None)
|
|
72
|
+
exec_ms = getattr(result, "execution_time_ms", None) or 0.0
|
|
73
|
+
|
|
74
|
+
stop_ms = start_ms + int(exec_ms)
|
|
75
|
+
|
|
76
|
+
status: str
|
|
77
|
+
if getattr(result, "error", None) and not passed:
|
|
78
|
+
status = "broken"
|
|
79
|
+
elif not passed:
|
|
80
|
+
status = "failed"
|
|
81
|
+
else:
|
|
82
|
+
status = "passed"
|
|
83
|
+
|
|
84
|
+
doc: dict[str, Any] = {
|
|
85
|
+
"uuid": str(uuid.uuid4()),
|
|
86
|
+
"historyId": tc_id,
|
|
87
|
+
"testCaseId": tc_id,
|
|
88
|
+
"fullName": f"{suite_name}.{tc_id}",
|
|
89
|
+
"name": tc_id,
|
|
90
|
+
"status": status,
|
|
91
|
+
"start": start_ms,
|
|
92
|
+
"stop": stop_ms,
|
|
93
|
+
"labels": [
|
|
94
|
+
{"name": "suite", "value": suite_name},
|
|
95
|
+
{"name": "framework", "value": "NAT"},
|
|
96
|
+
{"name": "language", "value": "Python"},
|
|
97
|
+
],
|
|
98
|
+
"links": [],
|
|
99
|
+
"parameters": [],
|
|
100
|
+
"attachments": [],
|
|
101
|
+
"statusDetails": {},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if base_url:
|
|
105
|
+
doc["labels"].append({"name": "host", "value": base_url})
|
|
106
|
+
|
|
107
|
+
if error:
|
|
108
|
+
doc["statusDetails"] = {
|
|
109
|
+
"message": str(error),
|
|
110
|
+
"trace": str(error),
|
|
111
|
+
"known": False,
|
|
112
|
+
"muted": False,
|
|
113
|
+
"flaky": False,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return doc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Public API
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def write_allure_results(
|
|
125
|
+
report: dict[str, Any],
|
|
126
|
+
suite: "TestSuite",
|
|
127
|
+
output_dir: str,
|
|
128
|
+
) -> list[str]:
|
|
129
|
+
"""Write Allure ``*-result.json`` files for every test case in *suite*.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
report:
|
|
134
|
+
The NAT scan report dict (as produced by ``_run_scan``).
|
|
135
|
+
suite:
|
|
136
|
+
The :class:`~mannf.core.testing.models.TestSuite` that ran the tests.
|
|
137
|
+
output_dir:
|
|
138
|
+
Directory to write result files into. Will be created if it does not
|
|
139
|
+
exist.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
list[str]
|
|
144
|
+
Absolute paths to the written result files.
|
|
145
|
+
"""
|
|
146
|
+
out = Path(output_dir)
|
|
147
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
|
|
149
|
+
suite_name = getattr(suite, "name", report.get("spec", "NAT Scan"))
|
|
150
|
+
base_url = report.get("base_url", report.get("endpoint", ""))
|
|
151
|
+
start_ms = _allure_timestamp_ms()
|
|
152
|
+
|
|
153
|
+
written: list[str] = []
|
|
154
|
+
for result in getattr(suite, "results", []):
|
|
155
|
+
allure_doc = _result_to_allure(result, suite_name, base_url, start_ms)
|
|
156
|
+
filename = f"{allure_doc['uuid']}-result.json"
|
|
157
|
+
dest = out / filename
|
|
158
|
+
dest.write_text(json.dumps(allure_doc, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
159
|
+
written.append(str(dest))
|
|
160
|
+
|
|
161
|
+
return written
|