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,200 @@
|
|
|
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
|
+
"""Bugzilla exporter plugin — creates bugs from NAT security findings via the Bugzilla REST API."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from mannf.product.exporters.base import ExportResult, ExporterPlugin, TestConnectionResult
|
|
15
|
+
from mannf.product.security.models import SecurityFinding, SecurityReport
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# NAT severity → Bugzilla (severity, priority) pairs
|
|
20
|
+
_SEVERITY_MAP: dict[str, tuple[str, str]] = {
|
|
21
|
+
"critical": ("critical", "P1"),
|
|
22
|
+
"high": ("major", "P2"),
|
|
23
|
+
"medium": ("normal", "P3"),
|
|
24
|
+
"low": ("minor", "P4"),
|
|
25
|
+
"info": ("trivial", "P5"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BugzillaExporter(ExporterPlugin):
|
|
30
|
+
"""Exports NAT security findings as Bugzilla bugs via the Bugzilla REST API.
|
|
31
|
+
|
|
32
|
+
Required config keys:
|
|
33
|
+
bugzilla_url: Bugzilla instance URL, e.g. ``https://bugzilla.example.com``.
|
|
34
|
+
api_key: Bugzilla API key.
|
|
35
|
+
product: Bugzilla product name to file bugs against.
|
|
36
|
+
component: Bugzilla component name.
|
|
37
|
+
|
|
38
|
+
Optional config keys:
|
|
39
|
+
version: Product version string (default ``"unspecified"``).
|
|
40
|
+
field_mapping: ``dict[str, Any]`` of extra field overrides merged into the
|
|
41
|
+
bug payload before posting.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
name = "bugzilla"
|
|
45
|
+
display_name = "Bugzilla"
|
|
46
|
+
description = "Creates Bugzilla bugs for NAT security findings via the Bugzilla REST API."
|
|
47
|
+
|
|
48
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
49
|
+
required = ("bugzilla_url", "api_key", "product", "component")
|
|
50
|
+
return [f"'{k}' is required" for k in required if not config.get(k)]
|
|
51
|
+
|
|
52
|
+
async def test_connection(self, config: dict) -> TestConnectionResult:
|
|
53
|
+
"""Test connection via GET /rest/version and GET /rest/product/{product}."""
|
|
54
|
+
errors = self.validate_config(config)
|
|
55
|
+
if errors:
|
|
56
|
+
return TestConnectionResult(
|
|
57
|
+
success=False,
|
|
58
|
+
message=f"Config validation failed: {'; '.join(errors)}",
|
|
59
|
+
details={"errors": errors},
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
base_url = config["bugzilla_url"].rstrip("/")
|
|
63
|
+
api_key = config["api_key"]
|
|
64
|
+
product = config["product"]
|
|
65
|
+
headers = {"Content-Type": "application/json"}
|
|
66
|
+
params_base = {"api_key": api_key}
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
70
|
+
# Step 1: confirm instance is reachable and API key works
|
|
71
|
+
version_resp = await client.get(
|
|
72
|
+
f"{base_url}/rest/version",
|
|
73
|
+
headers=headers,
|
|
74
|
+
params=params_base,
|
|
75
|
+
)
|
|
76
|
+
version_resp.raise_for_status()
|
|
77
|
+
version_data = version_resp.json()
|
|
78
|
+
|
|
79
|
+
# Step 2: confirm the product exists and the API key has access
|
|
80
|
+
product_resp = await client.get(
|
|
81
|
+
f"{base_url}/rest/product/{product}",
|
|
82
|
+
headers=headers,
|
|
83
|
+
params=params_base,
|
|
84
|
+
)
|
|
85
|
+
product_resp.raise_for_status()
|
|
86
|
+
except httpx.HTTPStatusError as exc:
|
|
87
|
+
status = exc.response.status_code
|
|
88
|
+
if status == 401:
|
|
89
|
+
msg = "Bugzilla authentication failed — check your api_key"
|
|
90
|
+
elif status == 403:
|
|
91
|
+
msg = "Bugzilla access forbidden — check api_key permissions"
|
|
92
|
+
elif status == 404:
|
|
93
|
+
url_used = str(exc.request.url)
|
|
94
|
+
if "product" in url_used:
|
|
95
|
+
msg = f"Bugzilla product '{product}' not found — check the product name"
|
|
96
|
+
else:
|
|
97
|
+
msg = f"Bugzilla instance not found at '{base_url}'"
|
|
98
|
+
else:
|
|
99
|
+
msg = f"Bugzilla API error {status}: {exc.response.text}"
|
|
100
|
+
return TestConnectionResult(success=False, message=msg, details={})
|
|
101
|
+
except httpx.RequestError as exc:
|
|
102
|
+
return TestConnectionResult(
|
|
103
|
+
success=False,
|
|
104
|
+
message=f"Bugzilla network error: {exc}",
|
|
105
|
+
details={},
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
bz_version = version_data.get("version", "unknown")
|
|
109
|
+
return TestConnectionResult(
|
|
110
|
+
success=True,
|
|
111
|
+
message=f"Connected to Bugzilla {bz_version} — product '{product}' is accessible",
|
|
112
|
+
details={"bugzilla_version": bz_version, "product": product},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
def _severity_to_priority(self, severity: str) -> str:
|
|
116
|
+
"""Map NAT severity to a Bugzilla priority string."""
|
|
117
|
+
_, priority = _SEVERITY_MAP.get(severity.lower(), ("normal", "P3"))
|
|
118
|
+
return priority
|
|
119
|
+
|
|
120
|
+
def _severity_to_bugzilla_severity(self, severity: str) -> str:
|
|
121
|
+
"""Map NAT severity to a Bugzilla severity string."""
|
|
122
|
+
bz_severity, _ = _SEVERITY_MAP.get(severity.lower(), ("normal", "P3"))
|
|
123
|
+
return bz_severity
|
|
124
|
+
|
|
125
|
+
def _build_payload(
|
|
126
|
+
self,
|
|
127
|
+
finding: SecurityFinding,
|
|
128
|
+
report: SecurityReport,
|
|
129
|
+
config: dict,
|
|
130
|
+
) -> dict:
|
|
131
|
+
"""Construct the Bugzilla bug creation payload."""
|
|
132
|
+
product = config["product"]
|
|
133
|
+
component = config["component"]
|
|
134
|
+
version = config.get("version", "unspecified")
|
|
135
|
+
body_text = self._format_finding_body(finding, report)
|
|
136
|
+
bz_severity = self._severity_to_bugzilla_severity(finding.severity)
|
|
137
|
+
priority = self._severity_to_priority(finding.severity)
|
|
138
|
+
|
|
139
|
+
payload: dict = {
|
|
140
|
+
"product": product,
|
|
141
|
+
"component": component,
|
|
142
|
+
"version": version,
|
|
143
|
+
"summary": f"[NAT] {finding.title}",
|
|
144
|
+
"description": body_text,
|
|
145
|
+
"severity": bz_severity,
|
|
146
|
+
"priority": priority,
|
|
147
|
+
"type": "defect",
|
|
148
|
+
"keywords": ["nat-security", finding.check_id],
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# Merge any caller-supplied field overrides last so they take precedence.
|
|
152
|
+
extra: dict = config.get("field_mapping") or {}
|
|
153
|
+
payload.update(extra)
|
|
154
|
+
|
|
155
|
+
return payload
|
|
156
|
+
|
|
157
|
+
async def export_finding(
|
|
158
|
+
self,
|
|
159
|
+
finding: SecurityFinding,
|
|
160
|
+
report: SecurityReport,
|
|
161
|
+
config: dict,
|
|
162
|
+
) -> ExportResult:
|
|
163
|
+
base_url = config["bugzilla_url"].rstrip("/")
|
|
164
|
+
api_key = config["api_key"]
|
|
165
|
+
url = f"{base_url}/rest/bug"
|
|
166
|
+
headers = {"Content-Type": "application/json"}
|
|
167
|
+
payload = self._build_payload(finding, report, config)
|
|
168
|
+
# Bugzilla REST API accepts api_key as a top-level body field
|
|
169
|
+
payload["api_key"] = api_key
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
173
|
+
resp = await client.post(url, json=payload, headers=headers)
|
|
174
|
+
resp.raise_for_status()
|
|
175
|
+
data = resp.json()
|
|
176
|
+
except httpx.HTTPStatusError as exc:
|
|
177
|
+
status = exc.response.status_code
|
|
178
|
+
if status == 401:
|
|
179
|
+
error_msg = "Bugzilla authentication failed — check your api_key"
|
|
180
|
+
elif status == 403:
|
|
181
|
+
error_msg = "Bugzilla access forbidden — check api_key permissions"
|
|
182
|
+
else:
|
|
183
|
+
error_msg = f"Bugzilla API error {status}: {exc.response.text}"
|
|
184
|
+
logger.warning("BugzillaExporter: %s", error_msg)
|
|
185
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
186
|
+
except httpx.RequestError as exc:
|
|
187
|
+
error_msg = f"Bugzilla network error: {exc}"
|
|
188
|
+
logger.warning("BugzillaExporter: %s", error_msg)
|
|
189
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
190
|
+
|
|
191
|
+
bug_id: str = str(data.get("id", ""))
|
|
192
|
+
bug_url: str | None = (
|
|
193
|
+
f"{base_url}/show_bug.cgi?id={bug_id}" if bug_id else None
|
|
194
|
+
)
|
|
195
|
+
return ExportResult(
|
|
196
|
+
finding=finding,
|
|
197
|
+
success=True,
|
|
198
|
+
external_id=bug_id or None,
|
|
199
|
+
external_url=bug_url,
|
|
200
|
+
)
|
|
@@ -0,0 +1,275 @@
|
|
|
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
|
+
"""Deduplication helpers for the auto-bug-filing pipeline.
|
|
7
|
+
|
|
8
|
+
Provides:
|
|
9
|
+
|
|
10
|
+
* :func:`make_failure_fingerprint` — deterministic ``sha256`` fingerprint for
|
|
11
|
+
a failure dict. The same failure across runs always produces the same hash,
|
|
12
|
+
enabling reliable deduplication across Jira, GitHub, and webhook targets.
|
|
13
|
+
* :func:`find_existing_jira_issue` — query Jira via JQL for an open issue that
|
|
14
|
+
carries the fingerprint in its labels.
|
|
15
|
+
* :func:`find_existing_github_issue` — search GitHub Issues for an open issue
|
|
16
|
+
whose title / body embeds the fingerprint.
|
|
17
|
+
* :func:`add_jira_comment` / :func:`add_github_comment` — append a new-occurrence
|
|
18
|
+
comment to an existing ticket rather than creating a duplicate.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import hashlib
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Any, Dict, Optional
|
|
27
|
+
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
# Label / tag prefix embedded in every auto-filed ticket so it can be found
|
|
33
|
+
# by the dedup search queries.
|
|
34
|
+
FINGERPRINT_LABEL_PREFIX = "nat-fp-"
|
|
35
|
+
AUTO_FILED_LABEL = "nat-auto-filed"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Fingerprint generation
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def make_failure_fingerprint(failure: Dict[str, Any]) -> str:
|
|
44
|
+
"""Return a stable ``sha256`` hex-digest fingerprint for *failure*.
|
|
45
|
+
|
|
46
|
+
The fingerprint is derived from three stable, identifying attributes:
|
|
47
|
+
|
|
48
|
+
* ``page`` — normalised page path (e.g. ``/checkout``).
|
|
49
|
+
* ``error`` — the first 200 characters of the error message, lower-cased
|
|
50
|
+
and stripped of surrounding whitespace. Trimming prevents minor
|
|
51
|
+
formatting differences from producing different fingerprints.
|
|
52
|
+
* ``selector`` — the CSS/XPath selector that failed (empty string if absent).
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
failure:
|
|
57
|
+
A failure dict from ``AutonomousRunReport.unique_failures``.
|
|
58
|
+
|
|
59
|
+
Returns
|
|
60
|
+
-------
|
|
61
|
+
str
|
|
62
|
+
A 64-character lowercase hex string (``sha256``).
|
|
63
|
+
"""
|
|
64
|
+
page = (failure.get("page") or failure.get("url") or "").strip()
|
|
65
|
+
error = (failure.get("error") or "").strip().lower()[:200]
|
|
66
|
+
selector = (failure.get("selector") or "").strip()
|
|
67
|
+
|
|
68
|
+
raw = f"{page}||{error}||{selector}"
|
|
69
|
+
return hashlib.sha256(raw.encode()).hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fingerprint_label(fingerprint: str) -> str:
|
|
73
|
+
"""Return the label/tag string used to mark a ticket with this fingerprint.
|
|
74
|
+
|
|
75
|
+
The label is truncated to 50 characters so it fits within common tracker
|
|
76
|
+
label length limits.
|
|
77
|
+
"""
|
|
78
|
+
return f"{FINGERPRINT_LABEL_PREFIX}{fingerprint[:40]}"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Jira deduplication
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def find_existing_jira_issue(
|
|
87
|
+
fingerprint: str,
|
|
88
|
+
*,
|
|
89
|
+
base_url: str,
|
|
90
|
+
project_key: str,
|
|
91
|
+
email: str,
|
|
92
|
+
api_token: str,
|
|
93
|
+
) -> Optional[str]:
|
|
94
|
+
"""Search Jira for an *open* issue with the given fingerprint label.
|
|
95
|
+
|
|
96
|
+
Uses the Jira Cloud REST API v3 ``/search`` endpoint with a JQL query::
|
|
97
|
+
|
|
98
|
+
project = "<project_key>" AND labels = "nat-fp-<fingerprint>"
|
|
99
|
+
AND statusCategory != Done
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
fingerprint:
|
|
104
|
+
The hex fingerprint produced by :func:`make_failure_fingerprint`.
|
|
105
|
+
base_url:
|
|
106
|
+
Jira Cloud base URL, e.g. ``https://myco.atlassian.net``.
|
|
107
|
+
project_key:
|
|
108
|
+
Jira project key, e.g. ``"NAT"``.
|
|
109
|
+
email:
|
|
110
|
+
Atlassian account email for Basic auth.
|
|
111
|
+
api_token:
|
|
112
|
+
Atlassian API token for Basic auth.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
str | None
|
|
117
|
+
The issue key (e.g. ``"NAT-42"``) if a matching open issue exists,
|
|
118
|
+
otherwise ``None``.
|
|
119
|
+
"""
|
|
120
|
+
label = fingerprint_label(fingerprint)
|
|
121
|
+
jql = (
|
|
122
|
+
f'project = "{project_key}" AND labels = "{label}" '
|
|
123
|
+
f'AND statusCategory != Done'
|
|
124
|
+
)
|
|
125
|
+
credentials = base64.b64encode(f"{email}:{api_token}".encode()).decode()
|
|
126
|
+
headers = {
|
|
127
|
+
"Authorization": f"Basic {credentials}",
|
|
128
|
+
"Accept": "application/json",
|
|
129
|
+
}
|
|
130
|
+
url = f"{base_url.rstrip('/')}/rest/api/3/search"
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
async with httpx.AsyncClient(headers=headers, timeout=15) as client:
|
|
134
|
+
resp = await client.get(url, params={"jql": jql, "maxResults": 1, "fields": "key"})
|
|
135
|
+
resp.raise_for_status()
|
|
136
|
+
data = resp.json()
|
|
137
|
+
issues = data.get("issues", [])
|
|
138
|
+
if issues:
|
|
139
|
+
return issues[0]["key"]
|
|
140
|
+
except Exception as exc: # noqa: BLE001
|
|
141
|
+
logger.warning("Jira dedup search failed (non-fatal): %s", exc)
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def add_jira_comment(
|
|
146
|
+
issue_key: str,
|
|
147
|
+
comment_text: str,
|
|
148
|
+
*,
|
|
149
|
+
base_url: str,
|
|
150
|
+
email: str,
|
|
151
|
+
api_token: str,
|
|
152
|
+
) -> bool:
|
|
153
|
+
"""Append *comment_text* to an existing Jira issue.
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
bool
|
|
158
|
+
``True`` on success, ``False`` on error.
|
|
159
|
+
"""
|
|
160
|
+
credentials = base64.b64encode(f"{email}:{api_token}".encode()).decode()
|
|
161
|
+
headers = {
|
|
162
|
+
"Authorization": f"Basic {credentials}",
|
|
163
|
+
"Accept": "application/json",
|
|
164
|
+
"Content-Type": "application/json",
|
|
165
|
+
}
|
|
166
|
+
url = f"{base_url.rstrip('/')}/rest/api/3/issue/{issue_key}/comment"
|
|
167
|
+
# ADF plain text paragraph
|
|
168
|
+
body = {
|
|
169
|
+
"body": {
|
|
170
|
+
"type": "doc",
|
|
171
|
+
"version": 1,
|
|
172
|
+
"content": [
|
|
173
|
+
{
|
|
174
|
+
"type": "paragraph",
|
|
175
|
+
"content": [{"type": "text", "text": comment_text}],
|
|
176
|
+
}
|
|
177
|
+
],
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
try:
|
|
181
|
+
async with httpx.AsyncClient(headers=headers, timeout=15) as client:
|
|
182
|
+
resp = await client.post(url, json=body)
|
|
183
|
+
resp.raise_for_status()
|
|
184
|
+
return True
|
|
185
|
+
except Exception as exc: # noqa: BLE001
|
|
186
|
+
logger.warning("Jira add_comment failed for %s (non-fatal): %s", issue_key, exc)
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# GitHub deduplication
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
_GITHUB_API_BASE = "https://api.github.com"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def find_existing_github_issue(
|
|
198
|
+
fingerprint: str,
|
|
199
|
+
*,
|
|
200
|
+
token: str,
|
|
201
|
+
repo: str,
|
|
202
|
+
) -> Optional[int]:
|
|
203
|
+
"""Search GitHub Issues for an open issue carrying the fingerprint label.
|
|
204
|
+
|
|
205
|
+
Uses the GitHub Search API::
|
|
206
|
+
|
|
207
|
+
repo:<owner>/<repo> is:issue is:open label:nat-fp-<fingerprint>
|
|
208
|
+
|
|
209
|
+
Parameters
|
|
210
|
+
----------
|
|
211
|
+
fingerprint:
|
|
212
|
+
The hex fingerprint produced by :func:`make_failure_fingerprint`.
|
|
213
|
+
token:
|
|
214
|
+
GitHub personal access token.
|
|
215
|
+
repo:
|
|
216
|
+
Repository in ``owner/repo`` format.
|
|
217
|
+
|
|
218
|
+
Returns
|
|
219
|
+
-------
|
|
220
|
+
int | None
|
|
221
|
+
The issue number if a matching open issue exists, otherwise ``None``.
|
|
222
|
+
"""
|
|
223
|
+
label = fingerprint_label(fingerprint)
|
|
224
|
+
query = f"repo:{repo} is:issue is:open label:{label}"
|
|
225
|
+
headers = {
|
|
226
|
+
"Authorization": f"Bearer {token}",
|
|
227
|
+
"Accept": "application/vnd.github+json",
|
|
228
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
229
|
+
}
|
|
230
|
+
try:
|
|
231
|
+
async with httpx.AsyncClient(headers=headers, timeout=15) as client:
|
|
232
|
+
resp = await client.get(
|
|
233
|
+
f"{_GITHUB_API_BASE}/search/issues",
|
|
234
|
+
params={"q": query, "per_page": 1},
|
|
235
|
+
)
|
|
236
|
+
resp.raise_for_status()
|
|
237
|
+
items = resp.json().get("items", [])
|
|
238
|
+
if items:
|
|
239
|
+
return int(items[0]["number"])
|
|
240
|
+
except Exception as exc: # noqa: BLE001
|
|
241
|
+
logger.warning("GitHub dedup search failed (non-fatal): %s", exc)
|
|
242
|
+
return None
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
async def add_github_comment(
|
|
246
|
+
issue_number: int,
|
|
247
|
+
comment_body: str,
|
|
248
|
+
*,
|
|
249
|
+
token: str,
|
|
250
|
+
repo: str,
|
|
251
|
+
) -> bool:
|
|
252
|
+
"""Append *comment_body* to an existing GitHub issue.
|
|
253
|
+
|
|
254
|
+
Returns
|
|
255
|
+
-------
|
|
256
|
+
bool
|
|
257
|
+
``True`` on success, ``False`` on error.
|
|
258
|
+
"""
|
|
259
|
+
owner, repo_name = repo.split("/", 1)
|
|
260
|
+
headers = {
|
|
261
|
+
"Authorization": f"Bearer {token}",
|
|
262
|
+
"Accept": "application/vnd.github+json",
|
|
263
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
264
|
+
}
|
|
265
|
+
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo_name}/issues/{issue_number}/comments"
|
|
266
|
+
try:
|
|
267
|
+
async with httpx.AsyncClient(headers=headers, timeout=15) as client:
|
|
268
|
+
resp = await client.post(url, json={"body": comment_body})
|
|
269
|
+
resp.raise_for_status()
|
|
270
|
+
return True
|
|
271
|
+
except Exception as exc: # noqa: BLE001
|
|
272
|
+
logger.warning(
|
|
273
|
+
"GitHub add_comment failed for issue #%d (non-fatal): %s", issue_number, exc
|
|
274
|
+
)
|
|
275
|
+
return False
|
|
@@ -0,0 +1,216 @@
|
|
|
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
|
+
"""Adapter that converts autonomous-loop failure dicts into SecurityFinding objects.
|
|
7
|
+
|
|
8
|
+
The autonomous loop records failures as plain dicts with keys:
|
|
9
|
+
``url``, ``page``, ``error``, ``task_id`` (and optionally ``selector``,
|
|
10
|
+
``screenshot_url``, ``dom_snapshot``, ``steps``).
|
|
11
|
+
|
|
12
|
+
This module bridges that format into the ``SecurityFinding`` schema so that all
|
|
13
|
+
existing exporter plugins (Jira, GitHub Issues, webhook, etc.) can handle
|
|
14
|
+
functional test failures without modification.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from mannf.product.security.models import SecurityFinding, SecurityReport
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Mapping from simple error keywords to a NAT severity level.
|
|
27
|
+
_ERROR_SEVERITY_MAP: list[tuple[str, str]] = [
|
|
28
|
+
# Critical — security-adjacent or data-loss errors
|
|
29
|
+
("security", "critical"),
|
|
30
|
+
("exploit", "critical"),
|
|
31
|
+
("injection", "critical"),
|
|
32
|
+
("xss", "critical"),
|
|
33
|
+
("sql", "critical"),
|
|
34
|
+
("auth", "high"),
|
|
35
|
+
("unauthori", "high"),
|
|
36
|
+
("forbidden", "high"),
|
|
37
|
+
("500", "high"),
|
|
38
|
+
("crash", "high"),
|
|
39
|
+
("exception", "high"),
|
|
40
|
+
("timeout", "medium"),
|
|
41
|
+
("404", "medium"),
|
|
42
|
+
("not found", "medium"),
|
|
43
|
+
("assertion", "medium"),
|
|
44
|
+
("failed", "low"),
|
|
45
|
+
("error", "low"),
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
_DEFAULT_SEVERITY = "low"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _infer_severity(error: str) -> str:
|
|
52
|
+
"""Infer a severity level from a free-text error message."""
|
|
53
|
+
lower = error.lower()
|
|
54
|
+
for keyword, severity in _ERROR_SEVERITY_MAP:
|
|
55
|
+
if keyword in lower:
|
|
56
|
+
return severity
|
|
57
|
+
return _DEFAULT_SEVERITY
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_check_id(task_id: str) -> str:
|
|
61
|
+
"""Generate a stable check ID for a functional failure."""
|
|
62
|
+
prefix = task_id[:8] if task_id else "unknown"
|
|
63
|
+
return f"FUNC-{prefix.upper()}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class FunctionalFindingAdapter:
|
|
67
|
+
"""Converts an autonomous-loop *unique_failure* dict to a :class:`SecurityFinding`.
|
|
68
|
+
|
|
69
|
+
Each failure dict produced by the autonomous loop has the following schema::
|
|
70
|
+
|
|
71
|
+
{
|
|
72
|
+
"url": str, # full URL where the failure occurred
|
|
73
|
+
"page": str, # page path key (e.g. "/checkout")
|
|
74
|
+
"error": str, # error message / exception text
|
|
75
|
+
"task_id": str, # stable task identifier
|
|
76
|
+
# optional
|
|
77
|
+
"selector": str | None, # CSS / XPath selector that failed
|
|
78
|
+
"screenshot_url": str | None, # screenshot artifact URL
|
|
79
|
+
"dom_snapshot": str | None, # DOM snapshot text / URL
|
|
80
|
+
"steps": list | None, # ordered scenario step dicts
|
|
81
|
+
}
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def from_unique_failure(
|
|
86
|
+
failure: Dict[str, Any],
|
|
87
|
+
*,
|
|
88
|
+
run_id: str = "",
|
|
89
|
+
target_url: str = "",
|
|
90
|
+
severity_override: Optional[str] = None,
|
|
91
|
+
) -> SecurityFinding:
|
|
92
|
+
"""Convert a single failure dict to a :class:`SecurityFinding`.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
failure:
|
|
97
|
+
A failure dict from ``AutonomousRunReport.unique_failures``.
|
|
98
|
+
run_id:
|
|
99
|
+
Optional autonomous run ID for additional context in the body.
|
|
100
|
+
target_url:
|
|
101
|
+
Root URL of the application under test.
|
|
102
|
+
severity_override:
|
|
103
|
+
If provided, this severity is used instead of the inferred one.
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
SecurityFinding
|
|
108
|
+
A finding object compatible with all exporter plugins.
|
|
109
|
+
"""
|
|
110
|
+
url = failure.get("url", target_url or "unknown")
|
|
111
|
+
page = failure.get("page", url)
|
|
112
|
+
error = failure.get("error", "Unknown error")
|
|
113
|
+
task_id = failure.get("task_id", "")
|
|
114
|
+
selector = failure.get("selector", "")
|
|
115
|
+
screenshot_url = failure.get("screenshot_url", "")
|
|
116
|
+
dom_snapshot = failure.get("dom_snapshot", "")
|
|
117
|
+
steps: List[Dict[str, Any]] = failure.get("steps") or []
|
|
118
|
+
|
|
119
|
+
severity = severity_override or _infer_severity(error)
|
|
120
|
+
check_id = _build_check_id(task_id)
|
|
121
|
+
|
|
122
|
+
# Build a human-readable description
|
|
123
|
+
description_parts = [
|
|
124
|
+
f"Functional test failure detected on page **{page}**.",
|
|
125
|
+
"",
|
|
126
|
+
f"**Error:** {error}",
|
|
127
|
+
]
|
|
128
|
+
if selector:
|
|
129
|
+
description_parts.append(f"**Selector:** `{selector}`")
|
|
130
|
+
if url:
|
|
131
|
+
description_parts.append(f"**URL:** {url}")
|
|
132
|
+
description = "\n".join(description_parts)
|
|
133
|
+
|
|
134
|
+
# Build evidence block
|
|
135
|
+
evidence_parts = [f"Task ID: `{task_id}`" if task_id else ""]
|
|
136
|
+
if run_id:
|
|
137
|
+
evidence_parts.append(f"Run ID: `{run_id}`")
|
|
138
|
+
if screenshot_url:
|
|
139
|
+
evidence_parts.append(f"Screenshot: {screenshot_url}")
|
|
140
|
+
if dom_snapshot:
|
|
141
|
+
snapshot_preview = dom_snapshot[:500] + "…" if len(dom_snapshot) > 500 else dom_snapshot
|
|
142
|
+
evidence_parts.append(f"DOM Snapshot:\n```\n{snapshot_preview}\n```")
|
|
143
|
+
if steps:
|
|
144
|
+
steps_text = "\n".join(
|
|
145
|
+
f"{i + 1}. {s.get('action', s.get('type', '?'))}: {s.get('selector', s.get('value', ''))}"
|
|
146
|
+
for i, s in enumerate(steps[:10])
|
|
147
|
+
)
|
|
148
|
+
evidence_parts.append(f"Reproduction steps:\n{steps_text}")
|
|
149
|
+
evidence = "\n".join(p for p in evidence_parts if p)
|
|
150
|
+
|
|
151
|
+
return SecurityFinding(
|
|
152
|
+
check_id=check_id,
|
|
153
|
+
check_name="Autonomous Functional Test Failure",
|
|
154
|
+
severity=severity,
|
|
155
|
+
endpoint=page,
|
|
156
|
+
title=f"Functional failure on {page}: {error[:80]}",
|
|
157
|
+
description=description,
|
|
158
|
+
evidence=evidence or "No additional evidence available.",
|
|
159
|
+
remediation=(
|
|
160
|
+
"Review the reproduction steps and error message. "
|
|
161
|
+
"Investigate the affected page for broken interactions or regressions."
|
|
162
|
+
),
|
|
163
|
+
cwe_id="",
|
|
164
|
+
confidence=0.85,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
@classmethod
|
|
168
|
+
def from_unique_failures(
|
|
169
|
+
cls,
|
|
170
|
+
failures: List[Dict[str, Any]],
|
|
171
|
+
*,
|
|
172
|
+
run_id: str = "",
|
|
173
|
+
target_url: str = "",
|
|
174
|
+
severity_override: Optional[str] = None,
|
|
175
|
+
) -> List[SecurityFinding]:
|
|
176
|
+
"""Convert a list of failure dicts to a list of :class:`SecurityFinding` objects."""
|
|
177
|
+
findings: List[SecurityFinding] = []
|
|
178
|
+
for failure in failures:
|
|
179
|
+
try:
|
|
180
|
+
findings.append(
|
|
181
|
+
cls.from_unique_failure(
|
|
182
|
+
failure,
|
|
183
|
+
run_id=run_id,
|
|
184
|
+
target_url=target_url,
|
|
185
|
+
severity_override=severity_override,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
except Exception as exc: # noqa: BLE001
|
|
189
|
+
logger.warning("FunctionalFindingAdapter: failed to convert failure dict: %s", exc)
|
|
190
|
+
return findings
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def make_report(
|
|
194
|
+
run_id: str,
|
|
195
|
+
target_url: str,
|
|
196
|
+
total_failures: int,
|
|
197
|
+
) -> SecurityReport:
|
|
198
|
+
"""Create a minimal :class:`SecurityReport` to satisfy exporter signatures.
|
|
199
|
+
|
|
200
|
+
Parameters
|
|
201
|
+
----------
|
|
202
|
+
run_id:
|
|
203
|
+
The autonomous run ID.
|
|
204
|
+
target_url:
|
|
205
|
+
Root URL of the application under test.
|
|
206
|
+
total_failures:
|
|
207
|
+
Total number of failures in the run (used as ``total_endpoints_scanned``).
|
|
208
|
+
"""
|
|
209
|
+
from datetime import datetime, timezone # noqa: PLC0415
|
|
210
|
+
|
|
211
|
+
return SecurityReport(
|
|
212
|
+
scan_id=run_id,
|
|
213
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
214
|
+
target_url=target_url,
|
|
215
|
+
total_endpoints_scanned=total_failures,
|
|
216
|
+
)
|