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,195 @@
|
|
|
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
|
+
"""Linear exporter plugin — creates Linear 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.security.models import SecurityFinding, SecurityReport
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
_LINEAR_API_URL = "https://api.linear.app/graphql"
|
|
20
|
+
|
|
21
|
+
# Linear priority values: 0=no priority, 1=urgent, 2=high, 3=medium, 4=low
|
|
22
|
+
_SEVERITY_TO_LINEAR_PRIORITY: dict[str, int] = {
|
|
23
|
+
"critical": 1,
|
|
24
|
+
"high": 2,
|
|
25
|
+
"medium": 3,
|
|
26
|
+
"low": 4,
|
|
27
|
+
"info": 0,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_ISSUE_CREATE_MUTATION = """
|
|
31
|
+
mutation IssueCreate($input: IssueCreateInput!) {
|
|
32
|
+
issueCreate(input: $input) {
|
|
33
|
+
success
|
|
34
|
+
issue {
|
|
35
|
+
id
|
|
36
|
+
identifier
|
|
37
|
+
url
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LinearExporter(ExporterPlugin):
|
|
45
|
+
"""Exports NAT security findings as Linear issues via the GraphQL API.
|
|
46
|
+
|
|
47
|
+
Required config keys:
|
|
48
|
+
api_key: Linear personal API key.
|
|
49
|
+
team_id: Linear team ID to create issues in.
|
|
50
|
+
|
|
51
|
+
Optional config keys:
|
|
52
|
+
project_id: Linear project ID to assign issues to.
|
|
53
|
+
assignee_id: Linear user ID to assign issues to.
|
|
54
|
+
label_ids: List of Linear label IDs to attach to every issue.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
name = "linear"
|
|
58
|
+
display_name = "Linear"
|
|
59
|
+
description = "Creates Linear issues for NAT security findings via the GraphQL API."
|
|
60
|
+
|
|
61
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
62
|
+
required = ("api_key", "team_id")
|
|
63
|
+
return [f"'{k}' is required" for k in required if not config.get(k)]
|
|
64
|
+
|
|
65
|
+
async def test_connection(self, config: dict) -> TestConnectionResult:
|
|
66
|
+
"""Test connection via POST /graphql with viewer query — confirms api_key."""
|
|
67
|
+
errors = self.validate_config(config)
|
|
68
|
+
if errors:
|
|
69
|
+
return TestConnectionResult(
|
|
70
|
+
success=False,
|
|
71
|
+
message=f"Config validation failed: {'; '.join(errors)}",
|
|
72
|
+
details={"errors": errors},
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
api_key = config["api_key"]
|
|
76
|
+
headers = {
|
|
77
|
+
"Authorization": api_key,
|
|
78
|
+
"Content-Type": "application/json",
|
|
79
|
+
}
|
|
80
|
+
payload = {"query": "{ viewer { id name } }"}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
84
|
+
resp = await client.post(_LINEAR_API_URL, json=payload, headers=headers)
|
|
85
|
+
resp.raise_for_status()
|
|
86
|
+
data = resp.json()
|
|
87
|
+
except httpx.HTTPStatusError as exc:
|
|
88
|
+
status = exc.response.status_code
|
|
89
|
+
if status == 401:
|
|
90
|
+
msg = "Linear authentication failed — check your api_key"
|
|
91
|
+
else:
|
|
92
|
+
msg = f"Linear API error {status}: {exc.response.text}"
|
|
93
|
+
return TestConnectionResult(success=False, message=msg, details={})
|
|
94
|
+
except httpx.RequestError as exc:
|
|
95
|
+
return TestConnectionResult(
|
|
96
|
+
success=False,
|
|
97
|
+
message=f"Linear network error: {exc}",
|
|
98
|
+
details={},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
gql_errors = data.get("errors")
|
|
102
|
+
if gql_errors:
|
|
103
|
+
return TestConnectionResult(
|
|
104
|
+
success=False,
|
|
105
|
+
message=f"Linear GraphQL error: {gql_errors[0].get('message', str(gql_errors))}",
|
|
106
|
+
details={},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
viewer = data.get("data", {}).get("viewer", {})
|
|
110
|
+
name = viewer.get("name", "unknown")
|
|
111
|
+
return TestConnectionResult(
|
|
112
|
+
success=True,
|
|
113
|
+
message=f"Connected to Linear — authenticated as '{name}'",
|
|
114
|
+
details={"id": viewer.get("id"), "name": name},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _severity_to_priority(self, severity: str) -> str:
|
|
118
|
+
# Return a string representation for the base class contract;
|
|
119
|
+
# use _severity_to_linear_priority() for the numeric value.
|
|
120
|
+
return str(_SEVERITY_TO_LINEAR_PRIORITY.get(severity.lower(), 0))
|
|
121
|
+
|
|
122
|
+
def _severity_to_linear_priority(self, severity: str) -> int:
|
|
123
|
+
return _SEVERITY_TO_LINEAR_PRIORITY.get(severity.lower(), 0)
|
|
124
|
+
|
|
125
|
+
async def export_finding(
|
|
126
|
+
self,
|
|
127
|
+
finding: SecurityFinding,
|
|
128
|
+
report: SecurityReport,
|
|
129
|
+
config: dict,
|
|
130
|
+
) -> ExportResult:
|
|
131
|
+
api_key = config["api_key"]
|
|
132
|
+
team_id = config["team_id"]
|
|
133
|
+
project_id = config.get("project_id")
|
|
134
|
+
assignee_id = config.get("assignee_id")
|
|
135
|
+
label_ids: list[str] = config.get("label_ids") or []
|
|
136
|
+
|
|
137
|
+
priority = self._severity_to_linear_priority(finding.severity)
|
|
138
|
+
description = self._format_finding_body(finding, report)
|
|
139
|
+
|
|
140
|
+
issue_input: dict = {
|
|
141
|
+
"teamId": team_id,
|
|
142
|
+
"title": f"[NAT] {finding.title}",
|
|
143
|
+
"description": description,
|
|
144
|
+
"priority": priority,
|
|
145
|
+
}
|
|
146
|
+
if project_id:
|
|
147
|
+
issue_input["projectId"] = project_id
|
|
148
|
+
if assignee_id:
|
|
149
|
+
issue_input["assigneeId"] = assignee_id
|
|
150
|
+
if label_ids:
|
|
151
|
+
issue_input["labelIds"] = label_ids
|
|
152
|
+
|
|
153
|
+
headers = {
|
|
154
|
+
"Authorization": api_key,
|
|
155
|
+
"Content-Type": "application/json",
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
payload = {
|
|
159
|
+
"query": _ISSUE_CREATE_MUTATION,
|
|
160
|
+
"variables": {"input": issue_input},
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
async with httpx.AsyncClient(headers=headers, timeout=30) as client:
|
|
165
|
+
resp = await client.post(_LINEAR_API_URL, json=payload)
|
|
166
|
+
resp.raise_for_status()
|
|
167
|
+
data = resp.json()
|
|
168
|
+
except httpx.HTTPStatusError as exc:
|
|
169
|
+
error_msg = f"Linear API error {exc.response.status_code}: {exc.response.text}"
|
|
170
|
+
logger.warning("LinearExporter: %s", error_msg)
|
|
171
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
172
|
+
except httpx.RequestError as exc:
|
|
173
|
+
error_msg = f"Linear network error: {exc}"
|
|
174
|
+
logger.warning("LinearExporter: %s", error_msg)
|
|
175
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
176
|
+
|
|
177
|
+
errors = data.get("errors")
|
|
178
|
+
if errors:
|
|
179
|
+
error_msg = f"Linear GraphQL errors: {errors}"
|
|
180
|
+
logger.warning("LinearExporter: %s", error_msg)
|
|
181
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
182
|
+
|
|
183
|
+
issue_create = data.get("data", {}).get("issueCreate", {})
|
|
184
|
+
if not issue_create.get("success"):
|
|
185
|
+
error_msg = "Linear issueCreate returned success=false"
|
|
186
|
+
logger.warning("LinearExporter: %s", error_msg)
|
|
187
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
188
|
+
|
|
189
|
+
issue = issue_create.get("issue", {})
|
|
190
|
+
return ExportResult(
|
|
191
|
+
finding=finding,
|
|
192
|
+
success=True,
|
|
193
|
+
external_id=issue.get("identifier"),
|
|
194
|
+
external_url=issue.get("url"),
|
|
195
|
+
)
|
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
"""Plugin loader for custom ExporterPlugin implementations.
|
|
7
|
+
|
|
8
|
+
Provides two discovery mechanisms:
|
|
9
|
+
- Directory scanning: load .py files from a directory path.
|
|
10
|
+
- Entry-point discovery: use importlib.metadata to find registered exporters.
|
|
11
|
+
|
|
12
|
+
Both mechanisms validate that each discovered class has the required attributes
|
|
13
|
+
and the callable methods before instantiating it. Invalid exporters are skipped
|
|
14
|
+
with a warning — they will never cause the scanner to crash.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import importlib
|
|
20
|
+
import importlib.util
|
|
21
|
+
import inspect
|
|
22
|
+
import logging
|
|
23
|
+
import sys
|
|
24
|
+
from importlib.metadata import entry_points
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
from mannf.product.exporters.base import ExporterPlugin
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
_REQUIRED_ATTRS = ("name", "display_name", "description")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_valid_exporter(cls: type) -> bool:
|
|
35
|
+
"""Return True if *cls* is a valid, concrete ExporterPlugin subclass."""
|
|
36
|
+
if not (isinstance(cls, type) and issubclass(cls, ExporterPlugin) and cls is not ExporterPlugin):
|
|
37
|
+
return False
|
|
38
|
+
for attr in _REQUIRED_ATTRS:
|
|
39
|
+
if not hasattr(cls, attr):
|
|
40
|
+
logger.warning(
|
|
41
|
+
"Exporter %s is missing required attribute '%s' — skipping.",
|
|
42
|
+
cls.__name__,
|
|
43
|
+
attr,
|
|
44
|
+
)
|
|
45
|
+
return False
|
|
46
|
+
if not callable(getattr(cls, "export_finding", None)):
|
|
47
|
+
logger.warning(
|
|
48
|
+
"Exporter %s has no callable 'export_finding' method — skipping.",
|
|
49
|
+
cls.__name__,
|
|
50
|
+
)
|
|
51
|
+
return False
|
|
52
|
+
if not callable(getattr(cls, "validate_config", None)):
|
|
53
|
+
logger.warning(
|
|
54
|
+
"Exporter %s has no callable 'validate_config' method — skipping.",
|
|
55
|
+
cls.__name__,
|
|
56
|
+
)
|
|
57
|
+
return False
|
|
58
|
+
return True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _instantiate(cls: type) -> ExporterPlugin | None:
|
|
62
|
+
"""Try to instantiate *cls*; log a warning and return None on failure."""
|
|
63
|
+
try:
|
|
64
|
+
return cls()
|
|
65
|
+
except Exception as exc: # noqa: BLE001
|
|
66
|
+
logger.warning(
|
|
67
|
+
"Exporter %s raised %s during __init__ — skipping.", cls.__name__, exc
|
|
68
|
+
)
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_exporters_from_directory(path: str) -> list[ExporterPlugin]:
|
|
73
|
+
"""Discover and load ExporterPlugin subclasses from all ``.py`` files in *path*.
|
|
74
|
+
|
|
75
|
+
Parameters
|
|
76
|
+
----------
|
|
77
|
+
path:
|
|
78
|
+
Filesystem path to a directory containing exporter ``.py`` files.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
list[ExporterPlugin]
|
|
83
|
+
Instantiated exporter objects for every valid exporter found.
|
|
84
|
+
"""
|
|
85
|
+
exporters: list[ExporterPlugin] = []
|
|
86
|
+
directory = Path(path)
|
|
87
|
+
|
|
88
|
+
if not directory.exists():
|
|
89
|
+
logger.warning(
|
|
90
|
+
"Exporter directory '%s' does not exist — no exporters loaded.", path
|
|
91
|
+
)
|
|
92
|
+
return exporters
|
|
93
|
+
|
|
94
|
+
if not directory.is_dir():
|
|
95
|
+
logger.warning(
|
|
96
|
+
"Exporter path '%s' is not a directory — no exporters loaded.", path
|
|
97
|
+
)
|
|
98
|
+
return exporters
|
|
99
|
+
|
|
100
|
+
for py_file in sorted(directory.glob("*.py")):
|
|
101
|
+
module_name = f"_nat_exporter_{py_file.stem}"
|
|
102
|
+
try:
|
|
103
|
+
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
|
104
|
+
if spec is None or spec.loader is None:
|
|
105
|
+
logger.warning(
|
|
106
|
+
"Could not create module spec for '%s' — skipping.", py_file
|
|
107
|
+
)
|
|
108
|
+
continue
|
|
109
|
+
module = importlib.util.module_from_spec(spec)
|
|
110
|
+
sys.modules[module_name] = module
|
|
111
|
+
spec.loader.exec_module(module) # type: ignore[union-attr]
|
|
112
|
+
except Exception as exc: # noqa: BLE001
|
|
113
|
+
logger.warning(
|
|
114
|
+
"Failed to import exporter file '%s': %s — skipping.", py_file, exc
|
|
115
|
+
)
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
for _name, obj in inspect.getmembers(module, inspect.isclass):
|
|
119
|
+
if obj.__module__ != module_name:
|
|
120
|
+
# Avoid re-processing imported base classes
|
|
121
|
+
continue
|
|
122
|
+
if _is_valid_exporter(obj):
|
|
123
|
+
instance = _instantiate(obj)
|
|
124
|
+
if instance is not None:
|
|
125
|
+
exporters.append(instance)
|
|
126
|
+
|
|
127
|
+
return exporters
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def load_exporters_from_entry_points(
|
|
131
|
+
group: str = "nat.exporters",
|
|
132
|
+
) -> list[ExporterPlugin]:
|
|
133
|
+
"""Discover and load ExporterPlugin subclasses registered as package entry points.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
group:
|
|
138
|
+
The entry-point group name to scan (default ``"nat.exporters"``).
|
|
139
|
+
|
|
140
|
+
Returns
|
|
141
|
+
-------
|
|
142
|
+
list[ExporterPlugin]
|
|
143
|
+
Instantiated exporter objects for every valid entry point found.
|
|
144
|
+
"""
|
|
145
|
+
exporters: list[ExporterPlugin] = []
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
eps = entry_points(group=group)
|
|
149
|
+
except Exception as exc: # noqa: BLE001
|
|
150
|
+
logger.warning(
|
|
151
|
+
"Failed to query entry points for group '%s': %s", group, exc
|
|
152
|
+
)
|
|
153
|
+
return exporters
|
|
154
|
+
|
|
155
|
+
for ep in eps:
|
|
156
|
+
try:
|
|
157
|
+
obj = ep.load()
|
|
158
|
+
except Exception as exc: # noqa: BLE001
|
|
159
|
+
logger.warning(
|
|
160
|
+
"Failed to load entry point '%s': %s — skipping.", ep.name, exc
|
|
161
|
+
)
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
if _is_valid_exporter(obj):
|
|
165
|
+
instance = _instantiate(obj)
|
|
166
|
+
if instance is not None:
|
|
167
|
+
exporters.append(instance)
|
|
168
|
+
else:
|
|
169
|
+
logger.warning(
|
|
170
|
+
"Entry point '%s' does not point to a valid ExporterPlugin subclass — skipping.",
|
|
171
|
+
ep.name,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return exporters
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def load_all_exporters(
|
|
178
|
+
exporters_dir: str | None = None,
|
|
179
|
+
entry_point_group: str = "nat.exporters",
|
|
180
|
+
) -> list[ExporterPlugin]:
|
|
181
|
+
"""Load exporters from both a directory and registered entry points, deduplicating by class name.
|
|
182
|
+
|
|
183
|
+
Parameters
|
|
184
|
+
----------
|
|
185
|
+
exporters_dir:
|
|
186
|
+
Optional filesystem path to a directory of exporter ``.py`` files.
|
|
187
|
+
entry_point_group:
|
|
188
|
+
Entry-point group to scan (default ``"nat.exporters"``).
|
|
189
|
+
|
|
190
|
+
Returns
|
|
191
|
+
-------
|
|
192
|
+
list[ExporterPlugin]
|
|
193
|
+
Merged, deduplicated list of instantiated exporter objects.
|
|
194
|
+
"""
|
|
195
|
+
seen_class_names: set[str] = set()
|
|
196
|
+
combined: list[ExporterPlugin] = []
|
|
197
|
+
|
|
198
|
+
dir_exporters = (
|
|
199
|
+
load_exporters_from_directory(exporters_dir) if exporters_dir else []
|
|
200
|
+
)
|
|
201
|
+
ep_exporters = load_exporters_from_entry_points(entry_point_group)
|
|
202
|
+
|
|
203
|
+
for exporter in dir_exporters + ep_exporters:
|
|
204
|
+
class_name = type(exporter).__name__
|
|
205
|
+
if class_name not in seen_class_names:
|
|
206
|
+
seen_class_names.add(class_name)
|
|
207
|
+
combined.append(exporter)
|
|
208
|
+
|
|
209
|
+
return combined
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_exporter_by_name(
|
|
213
|
+
name: str,
|
|
214
|
+
exporters: list[ExporterPlugin],
|
|
215
|
+
) -> ExporterPlugin | None:
|
|
216
|
+
"""Look up an exporter by its ``name`` attribute.
|
|
217
|
+
|
|
218
|
+
Parameters
|
|
219
|
+
----------
|
|
220
|
+
name:
|
|
221
|
+
The exporter name to search for (e.g., ``"jira"``, ``"linear"``).
|
|
222
|
+
exporters:
|
|
223
|
+
List of instantiated exporter objects to search.
|
|
224
|
+
|
|
225
|
+
Returns
|
|
226
|
+
-------
|
|
227
|
+
ExporterPlugin | None
|
|
228
|
+
The first matching exporter, or ``None`` if not found.
|
|
229
|
+
"""
|
|
230
|
+
for exporter in exporters:
|
|
231
|
+
if exporter.name == name:
|
|
232
|
+
return exporter
|
|
233
|
+
return None
|