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,257 @@
|
|
|
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
|
+
"""Azure DevOps Work Items exporter plugin — creates Azure Boards work items from NAT findings.
|
|
7
|
+
|
|
8
|
+
Usage
|
|
9
|
+
-----
|
|
10
|
+
Export findings to Azure Boards by supplying the following config keys via
|
|
11
|
+
``--export-config`` on the CLI::
|
|
12
|
+
|
|
13
|
+
nat security-scan --spec api.yaml --base-url http://localhost \\
|
|
14
|
+
--export azure-devops \\
|
|
15
|
+
--export-config organization=myorg \\
|
|
16
|
+
--export-config project=MyProject \\
|
|
17
|
+
--export-config pat=$AZURE_DEVOPS_PAT
|
|
18
|
+
|
|
19
|
+
Obtaining an Azure DevOps Personal Access Token
|
|
20
|
+
------------------------------------------------
|
|
21
|
+
1. Sign in to ``https://dev.azure.com/{organization}``.
|
|
22
|
+
2. Go to *User Settings → Personal Access Tokens*.
|
|
23
|
+
3. Create a token with the **Work Items (Read & Write)** scope.
|
|
24
|
+
4. Copy the token value — it is shown only once.
|
|
25
|
+
|
|
26
|
+
Required config keys
|
|
27
|
+
--------------------
|
|
28
|
+
organization: Azure DevOps organization name (the ``{organization}`` segment
|
|
29
|
+
in ``https://dev.azure.com/{organization}``).
|
|
30
|
+
project: Azure DevOps project name.
|
|
31
|
+
pat: Personal Access Token with Work Items (Read & Write) scope.
|
|
32
|
+
|
|
33
|
+
Optional config keys
|
|
34
|
+
--------------------
|
|
35
|
+
work_item_type: Work item type to create (default ``"Bug"``).
|
|
36
|
+
area_path: Area path for the work item.
|
|
37
|
+
iteration_path: Iteration path for the work item.
|
|
38
|
+
assignee: E-mail or display name of the assignee.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import base64
|
|
44
|
+
import logging
|
|
45
|
+
|
|
46
|
+
import httpx
|
|
47
|
+
|
|
48
|
+
from mannf.product.exporters.base import ExportResult, ExporterPlugin, TestConnectionResult
|
|
49
|
+
from mannf.product.security.models import SecurityFinding, SecurityReport
|
|
50
|
+
|
|
51
|
+
logger = logging.getLogger(__name__)
|
|
52
|
+
|
|
53
|
+
_TITLE_MAX_LENGTH = 255
|
|
54
|
+
|
|
55
|
+
# ADO severity label format
|
|
56
|
+
_ADO_SEVERITY_MAP: dict[str, str] = {
|
|
57
|
+
"critical": "1 - Critical",
|
|
58
|
+
"high": "2 - High",
|
|
59
|
+
"medium": "3 - Medium",
|
|
60
|
+
"low": "4 - Low",
|
|
61
|
+
"info": "4 - Low",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
# ADO priority integers (as strings to satisfy base class signature)
|
|
65
|
+
_ADO_PRIORITY_MAP: dict[str, str] = {
|
|
66
|
+
"critical": "1",
|
|
67
|
+
"high": "1",
|
|
68
|
+
"medium": "2",
|
|
69
|
+
"low": "3",
|
|
70
|
+
"info": "4",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AzureDevOpsExporter(ExporterPlugin):
|
|
75
|
+
"""Exports NAT security findings as Azure DevOps work items via the Azure Boards REST API.
|
|
76
|
+
|
|
77
|
+
Required config keys:
|
|
78
|
+
organization: Azure DevOps organization name.
|
|
79
|
+
project: Azure DevOps project name.
|
|
80
|
+
pat: Personal Access Token with Work Items (Read & Write) scope.
|
|
81
|
+
|
|
82
|
+
Optional config keys:
|
|
83
|
+
work_item_type: Work item type (default ``"Bug"``).
|
|
84
|
+
area_path: Area path for the work item.
|
|
85
|
+
iteration_path: Iteration path for the work item.
|
|
86
|
+
assignee: E-mail or display name of the assignee.
|
|
87
|
+
|
|
88
|
+
Example CLI usage::
|
|
89
|
+
|
|
90
|
+
nat security-scan --spec api.yaml --base-url http://localhost \\
|
|
91
|
+
--export azure-devops \\
|
|
92
|
+
--export-config organization=myorg \\
|
|
93
|
+
--export-config project=MyProject \\
|
|
94
|
+
--export-config pat=$AZURE_DEVOPS_PAT
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
name = "azure-devops"
|
|
98
|
+
display_name = "Azure DevOps Work Items"
|
|
99
|
+
description = "Export NAT findings as Azure DevOps work items via the Azure Boards REST API."
|
|
100
|
+
|
|
101
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
102
|
+
errors: list[str] = []
|
|
103
|
+
if not config.get("organization"):
|
|
104
|
+
errors.append("'organization' is required")
|
|
105
|
+
if not config.get("project"):
|
|
106
|
+
errors.append("'project' is required")
|
|
107
|
+
if not config.get("pat"):
|
|
108
|
+
errors.append("'pat' is required")
|
|
109
|
+
return errors
|
|
110
|
+
|
|
111
|
+
async def test_connection(self, config: dict) -> TestConnectionResult:
|
|
112
|
+
"""Test connection via work item types list — confirms PAT + org + project."""
|
|
113
|
+
errors = self.validate_config(config)
|
|
114
|
+
if errors:
|
|
115
|
+
return TestConnectionResult(
|
|
116
|
+
success=False,
|
|
117
|
+
message=f"Config validation failed: {'; '.join(errors)}",
|
|
118
|
+
details={"errors": errors},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
organization = config["organization"]
|
|
122
|
+
project = config["project"]
|
|
123
|
+
pat = config["pat"]
|
|
124
|
+
url = (
|
|
125
|
+
f"https://dev.azure.com/{organization}/{project}"
|
|
126
|
+
"/_apis/wit/workitemtypes?api-version=7.1"
|
|
127
|
+
)
|
|
128
|
+
headers = {
|
|
129
|
+
"Authorization": self._build_auth_header(pat),
|
|
130
|
+
"Accept": "application/json",
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
135
|
+
resp = await client.get(url, headers=headers)
|
|
136
|
+
resp.raise_for_status()
|
|
137
|
+
data = resp.json()
|
|
138
|
+
except httpx.HTTPStatusError as exc:
|
|
139
|
+
status = exc.response.status_code
|
|
140
|
+
if status == 401:
|
|
141
|
+
msg = "Azure DevOps authentication failed — check your PAT"
|
|
142
|
+
elif status == 404:
|
|
143
|
+
msg = f"Azure DevOps project '{project}' not found in org '{organization}'"
|
|
144
|
+
else:
|
|
145
|
+
msg = f"Azure DevOps API error {status}: {exc.response.text}"
|
|
146
|
+
return TestConnectionResult(success=False, message=msg, details={})
|
|
147
|
+
except httpx.RequestError as exc:
|
|
148
|
+
return TestConnectionResult(
|
|
149
|
+
success=False,
|
|
150
|
+
message=f"Azure DevOps network error: {exc}",
|
|
151
|
+
details={},
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
count = data.get("count", 0)
|
|
155
|
+
return TestConnectionResult(
|
|
156
|
+
success=True,
|
|
157
|
+
message=f"Connected to Azure DevOps — project '{project}' found ({count} work item types)",
|
|
158
|
+
details={"organization": organization, "project": project, "workItemTypeCount": count},
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def _severity_to_priority(self, severity: str) -> str:
|
|
162
|
+
"""Return ADO priority integer (as a string) for the given NAT severity."""
|
|
163
|
+
return _ADO_PRIORITY_MAP.get(severity.lower(), "3")
|
|
164
|
+
|
|
165
|
+
def _build_auth_header(self, pat: str) -> str:
|
|
166
|
+
"""Build a Basic auth header value from an Azure DevOps PAT.
|
|
167
|
+
|
|
168
|
+
Azure DevOps Basic auth uses an empty username and the PAT as the
|
|
169
|
+
password, base64-encoded as ``:<PAT>``.
|
|
170
|
+
"""
|
|
171
|
+
encoded = base64.b64encode(f":{pat}".encode()).decode()
|
|
172
|
+
return f"Basic {encoded}"
|
|
173
|
+
|
|
174
|
+
def _build_patch_document(
|
|
175
|
+
self,
|
|
176
|
+
finding: SecurityFinding,
|
|
177
|
+
report: SecurityReport,
|
|
178
|
+
config: dict,
|
|
179
|
+
) -> list[dict]:
|
|
180
|
+
"""Build the JSON Patch operations array for the Azure Boards API."""
|
|
181
|
+
severity = finding.severity.lower()
|
|
182
|
+
ado_severity = _ADO_SEVERITY_MAP.get(severity, "4 - Low")
|
|
183
|
+
ado_priority = int(self._severity_to_priority(severity))
|
|
184
|
+
|
|
185
|
+
cwe_part = finding.cwe_id if finding.cwe_id else "unknown"
|
|
186
|
+
owasp_part = finding.check_id
|
|
187
|
+
tags = f"nat-security; owasp:{owasp_part}; severity:{severity}; cwe:{cwe_part}"
|
|
188
|
+
|
|
189
|
+
raw_title = f"[NAT] {finding.title}"
|
|
190
|
+
title = raw_title[:_TITLE_MAX_LENGTH]
|
|
191
|
+
|
|
192
|
+
body_md = self._format_finding_body(finding, report)
|
|
193
|
+
description_html = f"<div>{body_md}</div>"
|
|
194
|
+
|
|
195
|
+
ops = [
|
|
196
|
+
{"op": "add", "path": "/fields/System.Title", "value": title},
|
|
197
|
+
{"op": "add", "path": "/fields/System.Description", "value": description_html},
|
|
198
|
+
{"op": "add", "path": "/fields/Microsoft.VSTS.Common.Priority", "value": ado_priority},
|
|
199
|
+
{"op": "add", "path": "/fields/Microsoft.VSTS.Common.Severity", "value": ado_severity},
|
|
200
|
+
{"op": "add", "path": "/fields/System.Tags", "value": tags},
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
if config.get("area_path"):
|
|
204
|
+
ops.append({"op": "add", "path": "/fields/System.AreaPath", "value": config["area_path"]})
|
|
205
|
+
if config.get("iteration_path"):
|
|
206
|
+
ops.append({"op": "add", "path": "/fields/System.IterationPath", "value": config["iteration_path"]})
|
|
207
|
+
if config.get("assignee"):
|
|
208
|
+
ops.append({"op": "add", "path": "/fields/System.AssignedTo", "value": config["assignee"]})
|
|
209
|
+
|
|
210
|
+
return ops
|
|
211
|
+
|
|
212
|
+
async def export_finding(
|
|
213
|
+
self,
|
|
214
|
+
finding: SecurityFinding,
|
|
215
|
+
report: SecurityReport,
|
|
216
|
+
config: dict,
|
|
217
|
+
) -> ExportResult:
|
|
218
|
+
organization = config["organization"]
|
|
219
|
+
project = config["project"]
|
|
220
|
+
pat = config["pat"]
|
|
221
|
+
work_item_type = config.get("work_item_type", "Bug")
|
|
222
|
+
|
|
223
|
+
url = (
|
|
224
|
+
f"https://dev.azure.com/{organization}/{project}/_apis/wit/workitems/"
|
|
225
|
+
f"${work_item_type}?api-version=7.1"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
headers = {
|
|
229
|
+
"Content-Type": "application/json-patch+json",
|
|
230
|
+
"Authorization": self._build_auth_header(pat),
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
patch_document = self._build_patch_document(finding, report, config)
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
237
|
+
resp = await client.post(url, json=patch_document, headers=headers)
|
|
238
|
+
resp.raise_for_status()
|
|
239
|
+
data = resp.json()
|
|
240
|
+
except httpx.HTTPStatusError as exc:
|
|
241
|
+
error_msg = f"Azure DevOps API error {exc.response.status_code}: {exc.response.text}"
|
|
242
|
+
logger.warning("AzureDevOpsExporter: %s", error_msg)
|
|
243
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
244
|
+
except httpx.RequestError as exc:
|
|
245
|
+
error_msg = f"Azure DevOps network error: {exc}"
|
|
246
|
+
logger.warning("AzureDevOpsExporter: %s", error_msg)
|
|
247
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
248
|
+
|
|
249
|
+
external_id = str(data.get("id", ""))
|
|
250
|
+
external_url = data.get("_links", {}).get("html", {}).get("href")
|
|
251
|
+
|
|
252
|
+
return ExportResult(
|
|
253
|
+
finding=finding,
|
|
254
|
+
success=True,
|
|
255
|
+
external_id=external_id,
|
|
256
|
+
external_url=external_url,
|
|
257
|
+
)
|
|
@@ -0,0 +1,307 @@
|
|
|
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
|
+
"""Abstract base for exporter plugins that push NAT findings to external trackers."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from mannf.product.security.models import SecurityFinding, SecurityReport
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TestConnectionResult:
|
|
21
|
+
"""Result of a lightweight connection test against an external service."""
|
|
22
|
+
|
|
23
|
+
success: bool
|
|
24
|
+
message: str # e.g., "Connected to Jira — project SEC found"
|
|
25
|
+
details: dict = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
_SEVERITY_ORDER: dict[str, int] = {
|
|
28
|
+
"critical": 0,
|
|
29
|
+
"high": 1,
|
|
30
|
+
"medium": 2,
|
|
31
|
+
"low": 3,
|
|
32
|
+
"info": 4,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class ExportResult:
|
|
38
|
+
"""Result of exporting a single finding."""
|
|
39
|
+
|
|
40
|
+
finding: SecurityFinding
|
|
41
|
+
success: bool
|
|
42
|
+
external_id: str | None = None # e.g., Jira issue key "NAT-123"
|
|
43
|
+
external_url: str | None = None # link to the created issue
|
|
44
|
+
error: str | None = None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ExportSummary:
|
|
49
|
+
"""Summary of a batch export operation."""
|
|
50
|
+
|
|
51
|
+
total: int
|
|
52
|
+
succeeded: int
|
|
53
|
+
failed: int
|
|
54
|
+
skipped: int
|
|
55
|
+
results: list[ExportResult] = field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ExporterPlugin(ABC):
|
|
59
|
+
"""Abstract base class for defect tracker exporter plugins.
|
|
60
|
+
|
|
61
|
+
Subclasses must define the class-level attributes ``name``,
|
|
62
|
+
``display_name``, and ``description``, and implement the abstract
|
|
63
|
+
methods ``export_finding`` and ``validate_config``.
|
|
64
|
+
|
|
65
|
+
Example::
|
|
66
|
+
|
|
67
|
+
class MyExporter(ExporterPlugin):
|
|
68
|
+
name = "my-tracker"
|
|
69
|
+
display_name = "My Tracker"
|
|
70
|
+
description = "Export findings to My Tracker."
|
|
71
|
+
|
|
72
|
+
async def export_finding(self, finding, report, config):
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
def validate_config(self, config):
|
|
76
|
+
errors = []
|
|
77
|
+
if not config.get("api_key"):
|
|
78
|
+
errors.append("api_key is required")
|
|
79
|
+
return errors
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
name: str # e.g., "jira", "linear", "github-issues"
|
|
83
|
+
display_name: str # e.g., "Jira Cloud", "Linear", "GitHub Issues"
|
|
84
|
+
description: str # one-line description
|
|
85
|
+
|
|
86
|
+
@abstractmethod
|
|
87
|
+
async def export_finding(
|
|
88
|
+
self,
|
|
89
|
+
finding: SecurityFinding,
|
|
90
|
+
report: SecurityReport,
|
|
91
|
+
config: dict,
|
|
92
|
+
) -> ExportResult:
|
|
93
|
+
"""Export a single finding to the external tracker.
|
|
94
|
+
|
|
95
|
+
Parameters
|
|
96
|
+
----------
|
|
97
|
+
finding:
|
|
98
|
+
The security finding to export.
|
|
99
|
+
report:
|
|
100
|
+
The parent scan report that provides scan-level context.
|
|
101
|
+
config:
|
|
102
|
+
Exporter-specific configuration dict (keys validated by
|
|
103
|
+
:meth:`validate_config`).
|
|
104
|
+
|
|
105
|
+
Returns
|
|
106
|
+
-------
|
|
107
|
+
ExportResult
|
|
108
|
+
The result of the export attempt.
|
|
109
|
+
"""
|
|
110
|
+
...
|
|
111
|
+
|
|
112
|
+
async def export_findings(
|
|
113
|
+
self,
|
|
114
|
+
findings: list[SecurityFinding],
|
|
115
|
+
report: SecurityReport,
|
|
116
|
+
config: dict,
|
|
117
|
+
min_severity: str = "low",
|
|
118
|
+
) -> ExportSummary:
|
|
119
|
+
"""Export multiple findings, filtering by minimum severity.
|
|
120
|
+
|
|
121
|
+
The default implementation calls :meth:`export_finding` in a loop.
|
|
122
|
+
Export failures are caught and recorded in the summary rather than
|
|
123
|
+
propagated, so a single failing export never aborts the batch.
|
|
124
|
+
|
|
125
|
+
Parameters
|
|
126
|
+
----------
|
|
127
|
+
findings:
|
|
128
|
+
All findings from the scan.
|
|
129
|
+
report:
|
|
130
|
+
The parent scan report.
|
|
131
|
+
config:
|
|
132
|
+
Exporter-specific configuration dict.
|
|
133
|
+
min_severity:
|
|
134
|
+
Minimum severity level to export (``"critical"``, ``"high"``,
|
|
135
|
+
``"medium"``, ``"low"``, or ``"info"``). Findings below this
|
|
136
|
+
threshold are counted as *skipped*.
|
|
137
|
+
|
|
138
|
+
Returns
|
|
139
|
+
-------
|
|
140
|
+
ExportSummary
|
|
141
|
+
Aggregated results for every finding processed.
|
|
142
|
+
"""
|
|
143
|
+
min_order = _SEVERITY_ORDER.get(min_severity.lower(), _SEVERITY_ORDER["low"])
|
|
144
|
+
|
|
145
|
+
results: list[ExportResult] = []
|
|
146
|
+
succeeded = 0
|
|
147
|
+
failed = 0
|
|
148
|
+
skipped = 0
|
|
149
|
+
|
|
150
|
+
for finding in findings:
|
|
151
|
+
finding_order = _SEVERITY_ORDER.get(finding.severity.lower(), _SEVERITY_ORDER["info"])
|
|
152
|
+
if finding_order > min_order:
|
|
153
|
+
skipped += 1
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
result = await self.export_finding(finding, report, config)
|
|
158
|
+
except Exception as exc: # noqa: BLE001
|
|
159
|
+
logger.warning(
|
|
160
|
+
"Exporter '%s' raised an unexpected error for finding '%s': %s",
|
|
161
|
+
self.name,
|
|
162
|
+
finding.check_id,
|
|
163
|
+
exc,
|
|
164
|
+
)
|
|
165
|
+
result = ExportResult(finding=finding, success=False, error=str(exc))
|
|
166
|
+
|
|
167
|
+
results.append(result)
|
|
168
|
+
if result.success:
|
|
169
|
+
succeeded += 1
|
|
170
|
+
else:
|
|
171
|
+
failed += 1
|
|
172
|
+
|
|
173
|
+
return ExportSummary(
|
|
174
|
+
total=len(findings),
|
|
175
|
+
succeeded=succeeded,
|
|
176
|
+
failed=failed,
|
|
177
|
+
skipped=skipped,
|
|
178
|
+
results=results,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
@abstractmethod
|
|
182
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
183
|
+
"""Validate exporter-specific config.
|
|
184
|
+
|
|
185
|
+
Parameters
|
|
186
|
+
----------
|
|
187
|
+
config:
|
|
188
|
+
Configuration dict to validate.
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
list[str]
|
|
193
|
+
A list of human-readable error strings. An empty list means the
|
|
194
|
+
config is valid.
|
|
195
|
+
"""
|
|
196
|
+
...
|
|
197
|
+
|
|
198
|
+
async def test_connection(self, config: dict) -> TestConnectionResult:
|
|
199
|
+
"""Test connectivity to the external service using the given config.
|
|
200
|
+
|
|
201
|
+
The default implementation validates the config and returns success if
|
|
202
|
+
there are no validation errors. Concrete subclasses should override
|
|
203
|
+
this with a lightweight, read-only API call that verifies credentials
|
|
204
|
+
and target availability live.
|
|
205
|
+
|
|
206
|
+
Parameters
|
|
207
|
+
----------
|
|
208
|
+
config:
|
|
209
|
+
Exporter-specific configuration dict.
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
TestConnectionResult
|
|
214
|
+
Result indicating whether the connection was successful.
|
|
215
|
+
"""
|
|
216
|
+
errors = self.validate_config(config)
|
|
217
|
+
if errors:
|
|
218
|
+
return TestConnectionResult(
|
|
219
|
+
success=False,
|
|
220
|
+
message=f"Config validation failed: {'; '.join(errors)}",
|
|
221
|
+
details={"errors": errors},
|
|
222
|
+
)
|
|
223
|
+
return TestConnectionResult(
|
|
224
|
+
success=True,
|
|
225
|
+
message=f"Config for '{self.name}' is valid (no live connection test performed).",
|
|
226
|
+
details={},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _severity_to_priority(self, severity: str) -> str:
|
|
230
|
+
"""Map a NAT severity to a generic priority label.
|
|
231
|
+
|
|
232
|
+
Concrete exporters may override this to return tracker-specific
|
|
233
|
+
priority values (e.g., ``"P1"`` for Jira, ``"urgent"`` for Linear).
|
|
234
|
+
|
|
235
|
+
Parameters
|
|
236
|
+
----------
|
|
237
|
+
severity:
|
|
238
|
+
NAT severity string (``"critical"``, ``"high"``, ``"medium"``,
|
|
239
|
+
``"low"``, or ``"info"``).
|
|
240
|
+
|
|
241
|
+
Returns
|
|
242
|
+
-------
|
|
243
|
+
str
|
|
244
|
+
A generic priority label.
|
|
245
|
+
"""
|
|
246
|
+
mapping = {
|
|
247
|
+
"critical": "critical",
|
|
248
|
+
"high": "high",
|
|
249
|
+
"medium": "medium",
|
|
250
|
+
"low": "low",
|
|
251
|
+
"info": "info",
|
|
252
|
+
}
|
|
253
|
+
return mapping.get(severity.lower(), "low")
|
|
254
|
+
|
|
255
|
+
def _format_finding_body(
|
|
256
|
+
self,
|
|
257
|
+
finding: SecurityFinding,
|
|
258
|
+
report: SecurityReport,
|
|
259
|
+
) -> str:
|
|
260
|
+
"""Generate a Markdown-formatted description body for a finding.
|
|
261
|
+
|
|
262
|
+
Parameters
|
|
263
|
+
----------
|
|
264
|
+
finding:
|
|
265
|
+
The security finding to format.
|
|
266
|
+
report:
|
|
267
|
+
The parent scan report for additional context.
|
|
268
|
+
|
|
269
|
+
Returns
|
|
270
|
+
-------
|
|
271
|
+
str
|
|
272
|
+
A Markdown string suitable for use as an issue body.
|
|
273
|
+
"""
|
|
274
|
+
lines = [
|
|
275
|
+
f"## {finding.title}",
|
|
276
|
+
"",
|
|
277
|
+
finding.description,
|
|
278
|
+
"",
|
|
279
|
+
"### Details",
|
|
280
|
+
"",
|
|
281
|
+
f"- **Endpoint:** `{finding.endpoint}`",
|
|
282
|
+
f"- **Severity:** {finding.severity}",
|
|
283
|
+
f"- **Confidence:** {finding.confidence:.0%}",
|
|
284
|
+
]
|
|
285
|
+
if finding.cwe_id:
|
|
286
|
+
lines.append(f"- **CWE:** [{finding.cwe_id}](https://cwe.mitre.org/data/definitions/{finding.cwe_id.replace('CWE-', '')}.html)")
|
|
287
|
+
lines += [
|
|
288
|
+
"",
|
|
289
|
+
"### Evidence",
|
|
290
|
+
"",
|
|
291
|
+
finding.evidence,
|
|
292
|
+
"",
|
|
293
|
+
"### Remediation",
|
|
294
|
+
"",
|
|
295
|
+
finding.remediation,
|
|
296
|
+
"",
|
|
297
|
+
"### Scan Context",
|
|
298
|
+
"",
|
|
299
|
+
f"- **Scan ID:** `{report.scan_id}`",
|
|
300
|
+
f"- **Target URL:** {report.target_url}",
|
|
301
|
+
f"- **Timestamp:** {report.timestamp}",
|
|
302
|
+
f"- **Total Endpoints Scanned:** {report.total_endpoints_scanned}",
|
|
303
|
+
"",
|
|
304
|
+
"---",
|
|
305
|
+
"*Generated by [NAT (Neural Agent Testing Framework)](https://nat-testing.io)*",
|
|
306
|
+
]
|
|
307
|
+
return "\n".join(lines)
|