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,197 @@
|
|
|
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
|
+
"""GitHub Issues exporter plugin — creates GitHub Issues from NAT security findings."""
|
|
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.exporters.dedup import AUTO_FILED_LABEL
|
|
16
|
+
from mannf.product.security.models import SecurityFinding, SecurityReport
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_GITHUB_API_BASE = "https://api.github.com"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GitHubIssuesExporter(ExporterPlugin):
|
|
24
|
+
"""Exports NAT security findings as GitHub Issues via the REST API.
|
|
25
|
+
|
|
26
|
+
Required config keys:
|
|
27
|
+
token: GitHub personal access token or ``$GITHUB_TOKEN``.
|
|
28
|
+
repo: Repository in ``owner/repo`` format, e.g. ``"myorg/myrepo"``.
|
|
29
|
+
|
|
30
|
+
Optional config keys:
|
|
31
|
+
labels: Extra label names to add to every created issue.
|
|
32
|
+
assignees: GitHub usernames to assign to every created issue.
|
|
33
|
+
deduplicate: If ``true`` (bool), search for an existing open issue
|
|
34
|
+
with the same ``[NAT]`` title before creating a new one.
|
|
35
|
+
Defaults to ``false``.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
name = "github-issues"
|
|
39
|
+
display_name = "GitHub Issues"
|
|
40
|
+
description = "Creates GitHub Issues for NAT security findings via the REST API."
|
|
41
|
+
|
|
42
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
43
|
+
errors: list[str] = []
|
|
44
|
+
if not config.get("token"):
|
|
45
|
+
errors.append("'token' is required")
|
|
46
|
+
repo = config.get("repo", "")
|
|
47
|
+
if not repo:
|
|
48
|
+
errors.append("'repo' is required")
|
|
49
|
+
elif "/" not in repo or len(repo.split("/")) != 2 or not all(repo.split("/")):
|
|
50
|
+
errors.append("'repo' must be in 'owner/repo' format")
|
|
51
|
+
return errors
|
|
52
|
+
|
|
53
|
+
async def test_connection(self, config: dict) -> TestConnectionResult:
|
|
54
|
+
"""Test connection by fetching the repository metadata via GET /repos/{owner}/{repo}."""
|
|
55
|
+
errors = self.validate_config(config)
|
|
56
|
+
if errors:
|
|
57
|
+
return TestConnectionResult(
|
|
58
|
+
success=False,
|
|
59
|
+
message=f"Config validation failed: {'; '.join(errors)}",
|
|
60
|
+
details={"errors": errors},
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
token = config["token"]
|
|
64
|
+
owner, repo_name = config["repo"].split("/", 1)
|
|
65
|
+
url = f"{_GITHUB_API_BASE}/repos/{owner}/{repo_name}"
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
async with httpx.AsyncClient(headers=self._build_headers(token), timeout=10) as client:
|
|
69
|
+
resp = await client.get(url)
|
|
70
|
+
resp.raise_for_status()
|
|
71
|
+
data = resp.json()
|
|
72
|
+
except httpx.HTTPStatusError as exc:
|
|
73
|
+
status = exc.response.status_code
|
|
74
|
+
if status == 401:
|
|
75
|
+
msg = "GitHub authentication failed — check your token"
|
|
76
|
+
elif status == 404:
|
|
77
|
+
msg = f"GitHub repo '{config['repo']}' not found or not accessible"
|
|
78
|
+
else:
|
|
79
|
+
msg = f"GitHub API error {status}: {exc.response.text}"
|
|
80
|
+
return TestConnectionResult(success=False, message=msg, details={})
|
|
81
|
+
except httpx.RequestError as exc:
|
|
82
|
+
return TestConnectionResult(
|
|
83
|
+
success=False,
|
|
84
|
+
message=f"GitHub network error: {exc}",
|
|
85
|
+
details={},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
return TestConnectionResult(
|
|
89
|
+
success=True,
|
|
90
|
+
message=f"Connected to GitHub — repo '{data.get('full_name', config['repo'])}' found",
|
|
91
|
+
details={"repo": data.get("full_name"), "private": data.get("private")},
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _build_headers(self, token: str) -> dict:
|
|
95
|
+
return {
|
|
96
|
+
"Authorization": f"Bearer {token}",
|
|
97
|
+
"Accept": "application/vnd.github+json",
|
|
98
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async def _find_existing_issue(
|
|
102
|
+
self,
|
|
103
|
+
client: httpx.AsyncClient,
|
|
104
|
+
owner: str,
|
|
105
|
+
repo: str,
|
|
106
|
+
title: str,
|
|
107
|
+
) -> str | None:
|
|
108
|
+
"""Search for an open issue with *title*; return its HTML URL or None."""
|
|
109
|
+
search_url = f"{_GITHUB_API_BASE}/search/issues"
|
|
110
|
+
query = f'repo:{owner}/{repo} is:issue is:open in:title "{title}"'
|
|
111
|
+
try:
|
|
112
|
+
resp = await client.get(search_url, params={"q": query, "per_page": 1})
|
|
113
|
+
resp.raise_for_status()
|
|
114
|
+
items = resp.json().get("items", [])
|
|
115
|
+
if items:
|
|
116
|
+
return items[0].get("html_url")
|
|
117
|
+
except Exception as exc: # noqa: BLE001
|
|
118
|
+
logger.warning("GitHubIssuesExporter: deduplication search failed: %s", exc)
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
async def export_finding(
|
|
122
|
+
self,
|
|
123
|
+
finding: SecurityFinding,
|
|
124
|
+
report: SecurityReport,
|
|
125
|
+
config: dict,
|
|
126
|
+
) -> ExportResult:
|
|
127
|
+
token = config["token"]
|
|
128
|
+
owner, repo_name = config["repo"].split("/", 1)
|
|
129
|
+
extra_labels: list[str] = config.get("labels") or []
|
|
130
|
+
assignees: list[str] = config.get("assignees") or []
|
|
131
|
+
deduplicate: bool = bool(config.get("deduplicate", False))
|
|
132
|
+
milestone: int | None = config.get("milestone") # GitHub milestone number
|
|
133
|
+
|
|
134
|
+
title = f"[NAT] {finding.title}"
|
|
135
|
+
body = self._format_finding_body(finding, report)
|
|
136
|
+
|
|
137
|
+
# Functional findings (auto-filed from autonomous loop) use a distinct
|
|
138
|
+
# label set; security findings keep "nat-security".
|
|
139
|
+
is_functional = finding.check_id.startswith("FUNC-")
|
|
140
|
+
if is_functional:
|
|
141
|
+
base_labels = [AUTO_FILED_LABEL, finding.severity]
|
|
142
|
+
else:
|
|
143
|
+
base_labels = ["nat-security", finding.severity, finding.check_id]
|
|
144
|
+
labels = base_labels + extra_labels
|
|
145
|
+
|
|
146
|
+
headers = self._build_headers(token)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
async with httpx.AsyncClient(headers=headers, timeout=30) as client:
|
|
150
|
+
if deduplicate:
|
|
151
|
+
existing_url = await self._find_existing_issue(
|
|
152
|
+
client, owner, repo_name, title
|
|
153
|
+
)
|
|
154
|
+
if existing_url:
|
|
155
|
+
logger.info(
|
|
156
|
+
"GitHubIssuesExporter: skipping duplicate — existing issue: %s",
|
|
157
|
+
existing_url,
|
|
158
|
+
)
|
|
159
|
+
issue_number = existing_url.rstrip("/").split("/")[-1]
|
|
160
|
+
return ExportResult(
|
|
161
|
+
finding=finding,
|
|
162
|
+
success=True,
|
|
163
|
+
external_id=issue_number,
|
|
164
|
+
external_url=existing_url,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
payload: dict = {
|
|
168
|
+
"title": title,
|
|
169
|
+
"body": body,
|
|
170
|
+
"labels": labels,
|
|
171
|
+
}
|
|
172
|
+
if assignees:
|
|
173
|
+
payload["assignees"] = assignees
|
|
174
|
+
if milestone is not None:
|
|
175
|
+
payload["milestone"] = milestone
|
|
176
|
+
|
|
177
|
+
resp = await client.post(
|
|
178
|
+
f"{_GITHUB_API_BASE}/repos/{owner}/{repo_name}/issues",
|
|
179
|
+
json=payload,
|
|
180
|
+
)
|
|
181
|
+
resp.raise_for_status()
|
|
182
|
+
data = resp.json()
|
|
183
|
+
except httpx.HTTPStatusError as exc:
|
|
184
|
+
error_msg = f"GitHub API error {exc.response.status_code}: {exc.response.text}"
|
|
185
|
+
logger.warning("GitHubIssuesExporter: %s", error_msg)
|
|
186
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
187
|
+
except httpx.RequestError as exc:
|
|
188
|
+
error_msg = f"GitHub network error: {exc}"
|
|
189
|
+
logger.warning("GitHubIssuesExporter: %s", error_msg)
|
|
190
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
191
|
+
|
|
192
|
+
return ExportResult(
|
|
193
|
+
finding=finding,
|
|
194
|
+
success=True,
|
|
195
|
+
external_id=str(data.get("number", "")),
|
|
196
|
+
external_url=data.get("html_url"),
|
|
197
|
+
)
|
|
@@ -0,0 +1,215 @@
|
|
|
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
|
+
"""GitLab Issues exporter plugin — creates GitLab issues from NAT security findings.
|
|
7
|
+
|
|
8
|
+
Usage
|
|
9
|
+
-----
|
|
10
|
+
Export findings to a GitLab project 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 gitlab \\
|
|
15
|
+
--export-config gitlab_url=https://gitlab.com \\
|
|
16
|
+
--export-config project_id=12345 \\
|
|
17
|
+
--export-config private_token=$GITLAB_TOKEN
|
|
18
|
+
|
|
19
|
+
Obtaining a GitLab Personal Access Token
|
|
20
|
+
-----------------------------------------
|
|
21
|
+
1. Sign in to your GitLab instance.
|
|
22
|
+
2. Go to *User Settings → Access Tokens* (or *Profile → Preferences → Access Tokens*).
|
|
23
|
+
3. Create a token with the ``api`` scope.
|
|
24
|
+
4. Copy the token value — it is shown only once.
|
|
25
|
+
|
|
26
|
+
Required config keys
|
|
27
|
+
--------------------
|
|
28
|
+
gitlab_url: GitLab instance base URL, e.g. ``https://gitlab.com`` or a
|
|
29
|
+
self-hosted URL such as ``https://gitlab.mycompany.com``.
|
|
30
|
+
project_id: Numeric GitLab project ID or ``namespace/project`` path slug,
|
|
31
|
+
e.g. ``12345`` or ``mygroup/myproject``.
|
|
32
|
+
private_token: GitLab Personal Access Token with ``api`` scope.
|
|
33
|
+
|
|
34
|
+
Optional config keys
|
|
35
|
+
--------------------
|
|
36
|
+
labels: List of additional label strings to attach to every issue.
|
|
37
|
+
assignee_ids: List of GitLab user IDs (integers) to assign.
|
|
38
|
+
confidential: Whether issues should be confidential (default ``False``).
|
|
39
|
+
min_severity: Minimum severity to export (default ``"low"``).
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
from urllib.parse import quote_plus
|
|
46
|
+
|
|
47
|
+
import httpx
|
|
48
|
+
|
|
49
|
+
from mannf.product.exporters.base import ExportResult, ExporterPlugin, TestConnectionResult
|
|
50
|
+
from mannf.product.security.models import SecurityFinding, SecurityReport
|
|
51
|
+
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
_TITLE_MAX_LENGTH = 255
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _owasp_label(check_id: str) -> str:
|
|
58
|
+
"""Convert a check_id such as ``'OWASP-API1'`` to ``'owasp:API1'``."""
|
|
59
|
+
if check_id.upper().startswith("OWASP-"):
|
|
60
|
+
return "owasp:" + check_id[len("OWASP-"):]
|
|
61
|
+
return check_id
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class GitLabExporter(ExporterPlugin):
|
|
65
|
+
"""Exports NAT security findings as GitLab Issues via the GitLab REST API.
|
|
66
|
+
|
|
67
|
+
Required config keys:
|
|
68
|
+
gitlab_url: GitLab instance base URL (e.g. ``https://gitlab.com``).
|
|
69
|
+
project_id: Numeric project ID or ``namespace/project`` slug.
|
|
70
|
+
private_token: Personal Access Token with ``api`` scope.
|
|
71
|
+
|
|
72
|
+
Optional config keys:
|
|
73
|
+
labels: List of extra label strings for every created issue.
|
|
74
|
+
assignee_ids: List of GitLab user IDs to assign.
|
|
75
|
+
confidential: Create issues as confidential (default ``False``).
|
|
76
|
+
min_severity: Minimum severity threshold (default ``"low"``).
|
|
77
|
+
|
|
78
|
+
Example CLI usage::
|
|
79
|
+
|
|
80
|
+
nat security-scan --spec api.yaml --base-url http://localhost \\
|
|
81
|
+
--export gitlab \\
|
|
82
|
+
--export-config gitlab_url=https://gitlab.com \\
|
|
83
|
+
--export-config project_id=12345 \\
|
|
84
|
+
--export-config private_token=$GITLAB_TOKEN
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
name = "gitlab"
|
|
88
|
+
display_name = "GitLab Issues"
|
|
89
|
+
description = "Export NAT findings as GitLab issues via the GitLab REST API."
|
|
90
|
+
|
|
91
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
92
|
+
errors: list[str] = []
|
|
93
|
+
if not config.get("gitlab_url"):
|
|
94
|
+
errors.append("'gitlab_url' is required")
|
|
95
|
+
if not config.get("project_id"):
|
|
96
|
+
errors.append("'project_id' is required")
|
|
97
|
+
if not config.get("private_token"):
|
|
98
|
+
errors.append("'private_token' is required")
|
|
99
|
+
return errors
|
|
100
|
+
|
|
101
|
+
async def test_connection(self, config: dict) -> TestConnectionResult:
|
|
102
|
+
"""Test connection via GET /api/v4/projects/{project_id} — confirms token + project."""
|
|
103
|
+
errors = self.validate_config(config)
|
|
104
|
+
if errors:
|
|
105
|
+
return TestConnectionResult(
|
|
106
|
+
success=False,
|
|
107
|
+
message=f"Config validation failed: {'; '.join(errors)}",
|
|
108
|
+
details={"errors": errors},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
gitlab_url = config["gitlab_url"].rstrip("/")
|
|
112
|
+
project_id = config["project_id"]
|
|
113
|
+
private_token = config["private_token"]
|
|
114
|
+
url = f"{gitlab_url}/api/v4/projects/{self._project_path(project_id)}"
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
118
|
+
resp = await client.get(url, headers={"PRIVATE-TOKEN": private_token})
|
|
119
|
+
resp.raise_for_status()
|
|
120
|
+
data = resp.json()
|
|
121
|
+
except httpx.HTTPStatusError as exc:
|
|
122
|
+
status = exc.response.status_code
|
|
123
|
+
if status == 401:
|
|
124
|
+
msg = "GitLab authentication failed — check your private_token"
|
|
125
|
+
elif status == 404:
|
|
126
|
+
msg = f"GitLab project '{project_id}' not found or not accessible"
|
|
127
|
+
else:
|
|
128
|
+
msg = f"GitLab API error {status}: {exc.response.text}"
|
|
129
|
+
return TestConnectionResult(success=False, message=msg, details={})
|
|
130
|
+
except httpx.RequestError as exc:
|
|
131
|
+
return TestConnectionResult(
|
|
132
|
+
success=False,
|
|
133
|
+
message=f"GitLab network error: {exc}",
|
|
134
|
+
details={},
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return TestConnectionResult(
|
|
138
|
+
success=True,
|
|
139
|
+
message=f"Connected to GitLab — project '{data.get('path_with_namespace', project_id)}' found",
|
|
140
|
+
details={"project": data.get("path_with_namespace"), "id": data.get("id")},
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
def _severity_to_priority(self, severity: str) -> str:
|
|
144
|
+
mapping = {
|
|
145
|
+
"critical": "priority::critical",
|
|
146
|
+
"high": "priority::high",
|
|
147
|
+
"medium": "priority::medium",
|
|
148
|
+
"low": "priority::low",
|
|
149
|
+
"info": "priority::info",
|
|
150
|
+
}
|
|
151
|
+
return mapping.get(severity.lower(), "priority::low")
|
|
152
|
+
|
|
153
|
+
def _build_labels(self, finding: SecurityFinding, extra_labels: list[str]) -> str:
|
|
154
|
+
"""Return a comma-separated label string for the GitLab issue."""
|
|
155
|
+
labels = [
|
|
156
|
+
"nat-security",
|
|
157
|
+
f"severity:{finding.severity}",
|
|
158
|
+
_owasp_label(finding.check_id),
|
|
159
|
+
]
|
|
160
|
+
labels.extend(extra_labels)
|
|
161
|
+
return ",".join(labels)
|
|
162
|
+
|
|
163
|
+
def _project_path(self, project_id: str | int) -> str:
|
|
164
|
+
"""URL-encode the project_id for use in a GitLab API path segment."""
|
|
165
|
+
return quote_plus(str(project_id))
|
|
166
|
+
|
|
167
|
+
async def export_finding(
|
|
168
|
+
self,
|
|
169
|
+
finding: SecurityFinding,
|
|
170
|
+
report: SecurityReport,
|
|
171
|
+
config: dict,
|
|
172
|
+
) -> ExportResult:
|
|
173
|
+
gitlab_url = config["gitlab_url"].rstrip("/")
|
|
174
|
+
project_id = config["project_id"]
|
|
175
|
+
private_token = config["private_token"]
|
|
176
|
+
extra_labels: list[str] = config.get("labels") or []
|
|
177
|
+
assignee_ids: list[int] = config.get("assignee_ids") or []
|
|
178
|
+
confidential: bool = bool(config.get("confidential", False))
|
|
179
|
+
|
|
180
|
+
raw_title = f"[NAT] {finding.title}"
|
|
181
|
+
title = raw_title[:_TITLE_MAX_LENGTH]
|
|
182
|
+
description = self._format_finding_body(finding, report)
|
|
183
|
+
labels_str = self._build_labels(finding, extra_labels)
|
|
184
|
+
|
|
185
|
+
url = f"{gitlab_url}/api/v4/projects/{self._project_path(project_id)}/issues"
|
|
186
|
+
headers = {"PRIVATE-TOKEN": private_token}
|
|
187
|
+
payload: dict = {
|
|
188
|
+
"title": title,
|
|
189
|
+
"description": description,
|
|
190
|
+
"labels": labels_str,
|
|
191
|
+
"confidential": confidential,
|
|
192
|
+
}
|
|
193
|
+
if assignee_ids:
|
|
194
|
+
payload["assignee_ids"] = assignee_ids
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
async with httpx.AsyncClient(headers=headers, timeout=30) as client:
|
|
198
|
+
resp = await client.post(url, json=payload)
|
|
199
|
+
resp.raise_for_status()
|
|
200
|
+
data = resp.json()
|
|
201
|
+
except httpx.HTTPStatusError as exc:
|
|
202
|
+
error_msg = f"GitLab API error {exc.response.status_code}: {exc.response.text}"
|
|
203
|
+
logger.warning("GitLabExporter: %s", error_msg)
|
|
204
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
205
|
+
except httpx.RequestError as exc:
|
|
206
|
+
error_msg = f"GitLab network error: {exc}"
|
|
207
|
+
logger.warning("GitLabExporter: %s", error_msg)
|
|
208
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
209
|
+
|
|
210
|
+
return ExportResult(
|
|
211
|
+
finding=finding,
|
|
212
|
+
success=True,
|
|
213
|
+
external_id=str(data.get("iid", "")),
|
|
214
|
+
external_url=data.get("web_url"),
|
|
215
|
+
)
|
|
@@ -0,0 +1,180 @@
|
|
|
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
|
+
"""Jira Cloud exporter plugin — creates Jira issues from NAT security findings."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from mannf.product.exporters.base import ExportResult, ExporterPlugin, TestConnectionResult
|
|
16
|
+
from mannf.product.security.models import SecurityFinding, SecurityReport
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_SEVERITY_TO_JIRA_PRIORITY: dict[str, str] = {
|
|
21
|
+
"critical": "Highest",
|
|
22
|
+
"high": "High",
|
|
23
|
+
"medium": "Medium",
|
|
24
|
+
"low": "Low",
|
|
25
|
+
"info": "Lowest",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class JiraExporter(ExporterPlugin):
|
|
30
|
+
"""Exports NAT security findings as Jira Cloud issues via the REST API v3.
|
|
31
|
+
|
|
32
|
+
Required config keys:
|
|
33
|
+
base_url: Jira Cloud base URL, e.g. ``https://myco.atlassian.net``.
|
|
34
|
+
project_key: Jira project key, e.g. ``"NAT"``.
|
|
35
|
+
email: Atlassian account email used for Basic auth.
|
|
36
|
+
api_token: Atlassian API token used for Basic auth.
|
|
37
|
+
|
|
38
|
+
Optional config keys:
|
|
39
|
+
issue_type: Jira issue type name (default ``"Bug"``).
|
|
40
|
+
component: Jira component name to assign to new issues.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
name = "jira"
|
|
44
|
+
display_name = "Jira Cloud"
|
|
45
|
+
description = "Creates Jira Cloud issues for NAT security findings via the REST API v3."
|
|
46
|
+
|
|
47
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
48
|
+
required = ("base_url", "project_key", "email", "api_token")
|
|
49
|
+
return [f"'{k}' is required" for k in required if not config.get(k)]
|
|
50
|
+
|
|
51
|
+
async def test_connection(self, config: dict) -> TestConnectionResult:
|
|
52
|
+
"""Test connection via GET /rest/api/3/myself — confirms email + api_token + base_url."""
|
|
53
|
+
errors = self.validate_config(config)
|
|
54
|
+
if errors:
|
|
55
|
+
return TestConnectionResult(
|
|
56
|
+
success=False,
|
|
57
|
+
message=f"Config validation failed: {'; '.join(errors)}",
|
|
58
|
+
details={"errors": errors},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
base_url = config["base_url"].rstrip("/")
|
|
62
|
+
email = config["email"]
|
|
63
|
+
api_token = config["api_token"]
|
|
64
|
+
credentials = base64.b64encode(f"{email}:{api_token}".encode()).decode()
|
|
65
|
+
headers = {
|
|
66
|
+
"Authorization": f"Basic {credentials}",
|
|
67
|
+
"Accept": "application/json",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
72
|
+
resp = await client.get(
|
|
73
|
+
f"{base_url}/rest/api/3/myself",
|
|
74
|
+
headers=headers,
|
|
75
|
+
)
|
|
76
|
+
resp.raise_for_status()
|
|
77
|
+
data = resp.json()
|
|
78
|
+
except httpx.HTTPStatusError as exc:
|
|
79
|
+
status = exc.response.status_code
|
|
80
|
+
if status == 401:
|
|
81
|
+
msg = "Jira authentication failed — check your email and api_token"
|
|
82
|
+
elif status == 403:
|
|
83
|
+
msg = "Jira access forbidden — check account permissions"
|
|
84
|
+
elif status == 404:
|
|
85
|
+
msg = f"Jira instance not found at '{base_url}'"
|
|
86
|
+
else:
|
|
87
|
+
msg = f"Jira API error {status}: {exc.response.text}"
|
|
88
|
+
return TestConnectionResult(success=False, message=msg, details={})
|
|
89
|
+
except httpx.RequestError as exc:
|
|
90
|
+
return TestConnectionResult(
|
|
91
|
+
success=False,
|
|
92
|
+
message=f"Jira network error: {exc}",
|
|
93
|
+
details={},
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
display_name = data.get("displayName", email)
|
|
97
|
+
return TestConnectionResult(
|
|
98
|
+
success=True,
|
|
99
|
+
message=f"Connected to Jira — authenticated as '{display_name}'",
|
|
100
|
+
details={"displayName": display_name, "accountId": data.get("accountId")},
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def _severity_to_priority(self, severity: str) -> str:
|
|
104
|
+
return _SEVERITY_TO_JIRA_PRIORITY.get(severity.lower(), "Low")
|
|
105
|
+
|
|
106
|
+
def _build_adf_body(self, markdown_text: str) -> dict:
|
|
107
|
+
"""Wrap plain text in an Atlassian Document Format (ADF) paragraph node."""
|
|
108
|
+
return {
|
|
109
|
+
"type": "doc",
|
|
110
|
+
"version": 1,
|
|
111
|
+
"content": [
|
|
112
|
+
{
|
|
113
|
+
"type": "paragraph",
|
|
114
|
+
"content": [{"type": "text", "text": markdown_text}],
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async def export_finding(
|
|
120
|
+
self,
|
|
121
|
+
finding: SecurityFinding,
|
|
122
|
+
report: SecurityReport,
|
|
123
|
+
config: dict,
|
|
124
|
+
) -> ExportResult:
|
|
125
|
+
base_url = config["base_url"].rstrip("/")
|
|
126
|
+
project_key = config["project_key"]
|
|
127
|
+
email = config["email"]
|
|
128
|
+
api_token = config["api_token"]
|
|
129
|
+
issue_type = config.get("issue_type", "Bug")
|
|
130
|
+
component = config.get("component")
|
|
131
|
+
|
|
132
|
+
priority_name = self._severity_to_priority(finding.severity)
|
|
133
|
+
body_text = self._format_finding_body(finding, report)
|
|
134
|
+
|
|
135
|
+
credentials = base64.b64encode(f"{email}:{api_token}".encode()).decode()
|
|
136
|
+
headers = {
|
|
137
|
+
"Authorization": f"Basic {credentials}",
|
|
138
|
+
"Accept": "application/json",
|
|
139
|
+
"Content-Type": "application/json",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
labels = ["nat-security", finding.check_id]
|
|
143
|
+
fields: dict = {
|
|
144
|
+
"project": {"key": project_key},
|
|
145
|
+
"summary": f"[NAT] {finding.title}",
|
|
146
|
+
"description": self._build_adf_body(body_text),
|
|
147
|
+
"issuetype": {"name": issue_type},
|
|
148
|
+
"priority": {"name": priority_name},
|
|
149
|
+
"labels": labels,
|
|
150
|
+
}
|
|
151
|
+
if component:
|
|
152
|
+
fields["components"] = [{"name": component}]
|
|
153
|
+
|
|
154
|
+
payload = {"fields": fields}
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
async with httpx.AsyncClient(headers=headers, timeout=30) as client:
|
|
158
|
+
resp = await client.post(
|
|
159
|
+
f"{base_url}/rest/api/3/issue",
|
|
160
|
+
json=payload,
|
|
161
|
+
)
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
data = resp.json()
|
|
164
|
+
except httpx.HTTPStatusError as exc:
|
|
165
|
+
error_msg = f"Jira API error {exc.response.status_code}: {exc.response.text}"
|
|
166
|
+
logger.warning("JiraExporter: %s", error_msg)
|
|
167
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
168
|
+
except httpx.RequestError as exc:
|
|
169
|
+
error_msg = f"Jira network error: {exc}"
|
|
170
|
+
logger.warning("JiraExporter: %s", error_msg)
|
|
171
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
172
|
+
|
|
173
|
+
issue_key = data.get("key", "")
|
|
174
|
+
issue_url = f"{base_url}/browse/{issue_key}" if issue_key else None
|
|
175
|
+
return ExportResult(
|
|
176
|
+
finding=finding,
|
|
177
|
+
success=True,
|
|
178
|
+
external_id=issue_key,
|
|
179
|
+
external_url=issue_url,
|
|
180
|
+
)
|