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,363 @@
|
|
|
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
|
+
"""PagerDuty exporter plugin — creates PagerDuty incidents from NAT security findings.
|
|
7
|
+
|
|
8
|
+
Implements the PagerDuty Events API v2 (https://events.pagerduty.com/v2/enqueue).
|
|
9
|
+
Only findings at severity >= High are posted as incidents by default; this
|
|
10
|
+
threshold is configurable via the ``min_severity`` config key.
|
|
11
|
+
|
|
12
|
+
Usage
|
|
13
|
+
-----
|
|
14
|
+
Export high/critical findings to PagerDuty via the CLI::
|
|
15
|
+
|
|
16
|
+
nat security-scan --spec api.yaml --base-url http://localhost \\
|
|
17
|
+
--export pagerduty \\
|
|
18
|
+
--export-config routing_key=$PD_ROUTING_KEY \\
|
|
19
|
+
--export-min-severity high
|
|
20
|
+
|
|
21
|
+
Required config keys
|
|
22
|
+
--------------------
|
|
23
|
+
routing_key:
|
|
24
|
+
PagerDuty *integration key* (also called *routing key*). Created by
|
|
25
|
+
adding a new **Events API v2** integration on a PagerDuty service.
|
|
26
|
+
|
|
27
|
+
Optional config keys
|
|
28
|
+
--------------------
|
|
29
|
+
service_id:
|
|
30
|
+
PagerDuty service ID. Included in incident details for reference.
|
|
31
|
+
escalation_policy:
|
|
32
|
+
Escalation policy name. Included in incident details for reference.
|
|
33
|
+
source:
|
|
34
|
+
The ``source`` field in the PagerDuty event payload (default
|
|
35
|
+
``"nat-security-scan"``).
|
|
36
|
+
component:
|
|
37
|
+
The ``component`` field in the PagerDuty event payload. Useful for
|
|
38
|
+
grouping incidents (e.g., ``"api"``, ``"auth-service"``).
|
|
39
|
+
group:
|
|
40
|
+
The ``group`` field in the PagerDuty event payload.
|
|
41
|
+
playbook_url:
|
|
42
|
+
URL to a remediation playbook. Included as a link in the incident body.
|
|
43
|
+
artifact_url:
|
|
44
|
+
URL to a scan artifact (e.g., a report HTML or JSON link). Included as
|
|
45
|
+
a link in the incident body.
|
|
46
|
+
timeout:
|
|
47
|
+
HTTP request timeout in seconds (default ``30``).
|
|
48
|
+
|
|
49
|
+
Severity mapping
|
|
50
|
+
----------------
|
|
51
|
+
NAT severities are mapped to the four PagerDuty severity levels as follows:
|
|
52
|
+
|
|
53
|
+
+------------+------------------+
|
|
54
|
+
| NAT | PagerDuty |
|
|
55
|
+
+============+==================+
|
|
56
|
+
| critical | critical |
|
|
57
|
+
+------------+------------------+
|
|
58
|
+
| high | error |
|
|
59
|
+
+------------+------------------+
|
|
60
|
+
| medium | warning |
|
|
61
|
+
+------------+------------------+
|
|
62
|
+
| low | warning |
|
|
63
|
+
+------------+------------------+
|
|
64
|
+
| info | info |
|
|
65
|
+
+------------+------------------+
|
|
66
|
+
|
|
67
|
+
Dedup keys
|
|
68
|
+
----------
|
|
69
|
+
A deterministic ``dedup_key`` is generated from the scan ID and finding check
|
|
70
|
+
ID so that re-running a scan never creates duplicate incidents for the same
|
|
71
|
+
finding. The key has the format ``nat-<scan_id>-<check_id>``.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
from __future__ import annotations
|
|
75
|
+
|
|
76
|
+
import hashlib
|
|
77
|
+
import logging
|
|
78
|
+
|
|
79
|
+
import httpx
|
|
80
|
+
|
|
81
|
+
from mannf.product.exporters.base import ExportResult, ExporterPlugin, TestConnectionResult
|
|
82
|
+
from mannf.product.security.models import SecurityFinding, SecurityReport
|
|
83
|
+
|
|
84
|
+
logger = logging.getLogger(__name__)
|
|
85
|
+
|
|
86
|
+
_PAGERDUTY_EVENTS_URL = "https://events.pagerduty.com/v2/enqueue"
|
|
87
|
+
|
|
88
|
+
_SEVERITY_TO_PD: dict[str, str] = {
|
|
89
|
+
"critical": "critical",
|
|
90
|
+
"high": "error",
|
|
91
|
+
"medium": "warning",
|
|
92
|
+
"low": "warning",
|
|
93
|
+
"info": "info",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PagerDutyExporter(ExporterPlugin):
|
|
98
|
+
"""Exports NAT security findings as PagerDuty incidents via the Events API v2.
|
|
99
|
+
|
|
100
|
+
Only findings at severity >= ``high`` are posted by default. Use the
|
|
101
|
+
``min_severity`` parameter on :meth:`export_findings` (or the CLI flag
|
|
102
|
+
``--export-min-severity``) to change this threshold.
|
|
103
|
+
|
|
104
|
+
Required config keys:
|
|
105
|
+
routing_key: PagerDuty integration key (Events API v2).
|
|
106
|
+
|
|
107
|
+
Optional config keys:
|
|
108
|
+
service_id: PagerDuty service ID (included in incident details).
|
|
109
|
+
escalation_policy: Escalation policy name (included in incident details).
|
|
110
|
+
source: Event source label (default ``"nat-security-scan"``).
|
|
111
|
+
component: Component label for grouping incidents.
|
|
112
|
+
group: Group label for grouping incidents.
|
|
113
|
+
playbook_url: URL to a remediation playbook.
|
|
114
|
+
artifact_url: URL to a scan artifact or report.
|
|
115
|
+
timeout: HTTP timeout in seconds (default ``30``).
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
name = "pagerduty"
|
|
119
|
+
display_name = "PagerDuty"
|
|
120
|
+
description = (
|
|
121
|
+
"Creates PagerDuty incidents for NAT security findings via the Events API v2."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Config validation
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
129
|
+
errors: list[str] = []
|
|
130
|
+
|
|
131
|
+
if not config.get("routing_key"):
|
|
132
|
+
errors.append("'routing_key' is required")
|
|
133
|
+
|
|
134
|
+
timeout = config.get("timeout")
|
|
135
|
+
if timeout is not None:
|
|
136
|
+
try:
|
|
137
|
+
t = float(timeout)
|
|
138
|
+
if t <= 0:
|
|
139
|
+
errors.append("'timeout' must be a positive number")
|
|
140
|
+
except (TypeError, ValueError):
|
|
141
|
+
errors.append("'timeout' must be a positive number")
|
|
142
|
+
|
|
143
|
+
return errors
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
# Connection test
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
async def test_connection(self, config: dict) -> TestConnectionResult:
|
|
150
|
+
"""Test connection via GET /abilities with routing_key header — confirms key validity."""
|
|
151
|
+
errors = self.validate_config(config)
|
|
152
|
+
if errors:
|
|
153
|
+
return TestConnectionResult(
|
|
154
|
+
success=False,
|
|
155
|
+
message=f"Config validation failed: {'; '.join(errors)}",
|
|
156
|
+
details={"errors": errors},
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
routing_key = config["routing_key"]
|
|
160
|
+
timeout = self._get_timeout(config)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
164
|
+
resp = await client.get(
|
|
165
|
+
"https://api.pagerduty.com/abilities",
|
|
166
|
+
headers={
|
|
167
|
+
"Accept": "application/json",
|
|
168
|
+
"Authorization": f"Token token={routing_key}",
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
resp.raise_for_status()
|
|
172
|
+
data = resp.json()
|
|
173
|
+
except httpx.HTTPStatusError as exc:
|
|
174
|
+
status = exc.response.status_code
|
|
175
|
+
if status == 401:
|
|
176
|
+
msg = "PagerDuty authentication failed — check your routing_key"
|
|
177
|
+
elif status == 403:
|
|
178
|
+
msg = "PagerDuty access forbidden — routing_key may lack required permissions"
|
|
179
|
+
else:
|
|
180
|
+
msg = f"PagerDuty API error {status}: {exc.response.text}"
|
|
181
|
+
return TestConnectionResult(success=False, message=msg, details={})
|
|
182
|
+
except httpx.RequestError as exc:
|
|
183
|
+
return TestConnectionResult(
|
|
184
|
+
success=False,
|
|
185
|
+
message=f"PagerDuty network error: {exc}",
|
|
186
|
+
details={},
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
abilities = data.get("abilities", [])
|
|
190
|
+
return TestConnectionResult(
|
|
191
|
+
success=True,
|
|
192
|
+
message="Connected to PagerDuty — routing key is valid",
|
|
193
|
+
details={"abilities": abilities},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# ------------------------------------------------------------------
|
|
197
|
+
# Severity mapping
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def _severity_to_priority(self, severity: str) -> str:
|
|
201
|
+
"""Map a NAT severity to a PagerDuty severity label."""
|
|
202
|
+
return _SEVERITY_TO_PD.get(severity.lower(), "warning")
|
|
203
|
+
|
|
204
|
+
# ------------------------------------------------------------------
|
|
205
|
+
# Internal helpers
|
|
206
|
+
# ------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
def _build_dedup_key(self, finding: SecurityFinding, report: SecurityReport) -> str:
|
|
209
|
+
"""Generate a deterministic dedup key for idempotent incident creation.
|
|
210
|
+
|
|
211
|
+
The key is a short SHA-256 hex digest derived from the scan ID and the
|
|
212
|
+
finding check ID, prefixed with ``nat-`` for easy identification in
|
|
213
|
+
PagerDuty. Identical findings from the same scan always produce the
|
|
214
|
+
same key so re-posting is idempotent.
|
|
215
|
+
"""
|
|
216
|
+
raw = f"{report.scan_id}:{finding.check_id}:{finding.endpoint}"
|
|
217
|
+
digest = hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
218
|
+
return f"nat-{digest}"
|
|
219
|
+
|
|
220
|
+
def _build_payload(
|
|
221
|
+
self,
|
|
222
|
+
finding: SecurityFinding,
|
|
223
|
+
report: SecurityReport,
|
|
224
|
+
config: dict,
|
|
225
|
+
) -> dict:
|
|
226
|
+
"""Build the PagerDuty Events API v2 request payload."""
|
|
227
|
+
routing_key: str = config["routing_key"]
|
|
228
|
+
source: str = config.get("source", "nat-security-scan")
|
|
229
|
+
component: str | None = config.get("component")
|
|
230
|
+
group: str | None = config.get("group")
|
|
231
|
+
service_id: str | None = config.get("service_id")
|
|
232
|
+
escalation_policy: str | None = config.get("escalation_policy")
|
|
233
|
+
playbook_url: str | None = config.get("playbook_url")
|
|
234
|
+
artifact_url: str | None = config.get("artifact_url")
|
|
235
|
+
|
|
236
|
+
pd_severity = self._severity_to_priority(finding.severity)
|
|
237
|
+
dedup_key = self._build_dedup_key(finding, report)
|
|
238
|
+
|
|
239
|
+
summary = f"[NAT] {finding.title} — {finding.endpoint} ({finding.severity.upper()})"
|
|
240
|
+
|
|
241
|
+
custom_details: dict = {
|
|
242
|
+
"check_id": finding.check_id,
|
|
243
|
+
"check_name": finding.check_name,
|
|
244
|
+
"nat_severity": finding.severity,
|
|
245
|
+
"endpoint": finding.endpoint,
|
|
246
|
+
"description": finding.description,
|
|
247
|
+
"evidence": finding.evidence,
|
|
248
|
+
"remediation": finding.remediation,
|
|
249
|
+
"confidence": finding.confidence,
|
|
250
|
+
"scan_id": report.scan_id,
|
|
251
|
+
"target_url": report.target_url,
|
|
252
|
+
"scan_timestamp": report.timestamp,
|
|
253
|
+
"total_endpoints_scanned": report.total_endpoints_scanned,
|
|
254
|
+
}
|
|
255
|
+
if finding.cwe_id:
|
|
256
|
+
custom_details["cwe_id"] = finding.cwe_id
|
|
257
|
+
custom_details["cwe_url"] = (
|
|
258
|
+
f"https://cwe.mitre.org/data/definitions/{finding.cwe_id.replace('CWE-', '')}.html"
|
|
259
|
+
)
|
|
260
|
+
if service_id:
|
|
261
|
+
custom_details["service_id"] = service_id
|
|
262
|
+
if escalation_policy:
|
|
263
|
+
custom_details["escalation_policy"] = escalation_policy
|
|
264
|
+
|
|
265
|
+
event_payload: dict = {
|
|
266
|
+
"summary": summary,
|
|
267
|
+
"source": source,
|
|
268
|
+
"severity": pd_severity,
|
|
269
|
+
"custom_details": custom_details,
|
|
270
|
+
}
|
|
271
|
+
if component:
|
|
272
|
+
event_payload["component"] = component
|
|
273
|
+
if group:
|
|
274
|
+
event_payload["group"] = group
|
|
275
|
+
|
|
276
|
+
# Links: playbook and artifact references
|
|
277
|
+
links: list[dict] = []
|
|
278
|
+
if playbook_url:
|
|
279
|
+
links.append({"href": playbook_url, "text": "Remediation Playbook"})
|
|
280
|
+
if artifact_url:
|
|
281
|
+
links.append({"href": artifact_url, "text": "Scan Artifact / Report"})
|
|
282
|
+
# Always include a CWE reference link when available
|
|
283
|
+
if finding.cwe_id:
|
|
284
|
+
cwe_num = finding.cwe_id.replace("CWE-", "")
|
|
285
|
+
links.append({
|
|
286
|
+
"href": f"https://cwe.mitre.org/data/definitions/{cwe_num}.html",
|
|
287
|
+
"text": f"CWE Reference: {finding.cwe_id}",
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
body: dict = {
|
|
291
|
+
"routing_key": routing_key,
|
|
292
|
+
"event_action": "trigger",
|
|
293
|
+
"dedup_key": dedup_key,
|
|
294
|
+
"payload": event_payload,
|
|
295
|
+
}
|
|
296
|
+
if links:
|
|
297
|
+
body["links"] = links
|
|
298
|
+
|
|
299
|
+
return body
|
|
300
|
+
|
|
301
|
+
def _get_timeout(self, config: dict) -> float:
|
|
302
|
+
"""Return the configured timeout, falling back to 30 seconds."""
|
|
303
|
+
timeout = config.get("timeout", 30)
|
|
304
|
+
try:
|
|
305
|
+
return float(timeout)
|
|
306
|
+
except (TypeError, ValueError):
|
|
307
|
+
return 30.0
|
|
308
|
+
|
|
309
|
+
# ------------------------------------------------------------------
|
|
310
|
+
# Single-finding export
|
|
311
|
+
# ------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
async def export_finding(
|
|
314
|
+
self,
|
|
315
|
+
finding: SecurityFinding,
|
|
316
|
+
report: SecurityReport,
|
|
317
|
+
config: dict,
|
|
318
|
+
) -> ExportResult:
|
|
319
|
+
timeout = self._get_timeout(config)
|
|
320
|
+
|
|
321
|
+
payload = self._build_payload(finding, report, config)
|
|
322
|
+
dedup_key: str = payload["dedup_key"]
|
|
323
|
+
|
|
324
|
+
headers = {
|
|
325
|
+
"Content-Type": "application/json",
|
|
326
|
+
"Accept": "application/json",
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
async with httpx.AsyncClient(headers=headers, timeout=timeout) as client:
|
|
331
|
+
resp = await client.post(
|
|
332
|
+
_PAGERDUTY_EVENTS_URL,
|
|
333
|
+
json=payload,
|
|
334
|
+
)
|
|
335
|
+
resp.raise_for_status()
|
|
336
|
+
data = resp.json()
|
|
337
|
+
except httpx.HTTPStatusError as exc:
|
|
338
|
+
status = exc.response.status_code
|
|
339
|
+
error_msg = f"PagerDuty API error {status}: {exc.response.text}"
|
|
340
|
+
if status == 401:
|
|
341
|
+
error_msg = "PagerDuty API error 401: invalid routing_key / unauthorized"
|
|
342
|
+
elif status == 429:
|
|
343
|
+
error_msg = "PagerDuty API error 429: rate limit exceeded — retry later"
|
|
344
|
+
logger.warning("PagerDutyExporter: %s", error_msg)
|
|
345
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
346
|
+
except httpx.RequestError as exc:
|
|
347
|
+
error_msg = f"PagerDuty network error: {exc}"
|
|
348
|
+
logger.warning("PagerDutyExporter: %s", error_msg)
|
|
349
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
350
|
+
|
|
351
|
+
# PagerDuty Events API v2 returns {"status": "success", "dedup_key": "..."}
|
|
352
|
+
incident_key = data.get("dedup_key", dedup_key)
|
|
353
|
+
external_url = (
|
|
354
|
+
f"https://app.pagerduty.com/incidents?service_id={config['service_id']}"
|
|
355
|
+
if config.get("service_id")
|
|
356
|
+
else "https://app.pagerduty.com/incidents"
|
|
357
|
+
)
|
|
358
|
+
return ExportResult(
|
|
359
|
+
finding=finding,
|
|
360
|
+
success=True,
|
|
361
|
+
external_id=incident_key,
|
|
362
|
+
external_url=external_url,
|
|
363
|
+
)
|
|
@@ -0,0 +1,322 @@
|
|
|
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
|
+
"""Sentry exporter plugin — creates Sentry 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
|
+
# NAT severity → Sentry event level
|
|
20
|
+
_SEVERITY_TO_SENTRY_LEVEL: dict[str, str] = {
|
|
21
|
+
"critical": "fatal",
|
|
22
|
+
"high": "error",
|
|
23
|
+
"medium": "warning",
|
|
24
|
+
"low": "info",
|
|
25
|
+
"info": "debug",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SentryExporter(ExporterPlugin):
|
|
30
|
+
"""Exports NAT security findings as Sentry issues via the Sentry Issues API.
|
|
31
|
+
|
|
32
|
+
Required config keys:
|
|
33
|
+
dsn: Sentry DSN (includes project ID, public key, and host).
|
|
34
|
+
Either ``dsn`` **or** both ``auth_token`` + ``org_slug`` +
|
|
35
|
+
``project_slug`` must be provided.
|
|
36
|
+
|
|
37
|
+
Optional config keys (alternative auth path):
|
|
38
|
+
auth_token: Sentry auth token (alternative to DSN-based creation).
|
|
39
|
+
org_slug: Sentry organisation slug (required when using auth_token).
|
|
40
|
+
project_slug: Sentry project slug (required when using auth_token for
|
|
41
|
+
``test_connection``).
|
|
42
|
+
|
|
43
|
+
Optional config keys (tagging / grouping):
|
|
44
|
+
environment: Environment tag attached to each event (default ``"nat-scan"``).
|
|
45
|
+
release: Release string to associate with events.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
name = "sentry"
|
|
49
|
+
display_name = "Sentry"
|
|
50
|
+
description = "Creates Sentry issues for NAT security findings via the Sentry Issues API."
|
|
51
|
+
|
|
52
|
+
def validate_config(self, config: dict) -> list[str]:
|
|
53
|
+
errors: list[str] = []
|
|
54
|
+
has_dsn = bool(config.get("dsn"))
|
|
55
|
+
has_token = bool(config.get("auth_token"))
|
|
56
|
+
if not has_dsn and not has_token:
|
|
57
|
+
errors.append(
|
|
58
|
+
"Either 'dsn' or 'auth_token' is required"
|
|
59
|
+
)
|
|
60
|
+
if has_token:
|
|
61
|
+
if not config.get("org_slug"):
|
|
62
|
+
errors.append("'org_slug' is required when using 'auth_token'")
|
|
63
|
+
if not config.get("project_slug"):
|
|
64
|
+
errors.append("'project_slug' is required when using 'auth_token'")
|
|
65
|
+
return errors
|
|
66
|
+
|
|
67
|
+
async def test_connection(self, config: dict) -> TestConnectionResult:
|
|
68
|
+
"""Test connection via GET /api/0/projects/{org_slug}/{project_slug}/ — confirms token + project access."""
|
|
69
|
+
errors = self.validate_config(config)
|
|
70
|
+
if errors:
|
|
71
|
+
return TestConnectionResult(
|
|
72
|
+
success=False,
|
|
73
|
+
message=f"Config validation failed: {'; '.join(errors)}",
|
|
74
|
+
details={"errors": errors},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
auth_token = config.get("auth_token")
|
|
78
|
+
org_slug = config.get("org_slug")
|
|
79
|
+
project_slug = config.get("project_slug")
|
|
80
|
+
|
|
81
|
+
if not (auth_token and org_slug and project_slug):
|
|
82
|
+
# DSN-only config — no API-based connection test is possible without
|
|
83
|
+
# the org/project slugs; fall back to config-valid success.
|
|
84
|
+
return TestConnectionResult(
|
|
85
|
+
success=True,
|
|
86
|
+
message="Sentry config is valid (DSN-only mode — live connection test requires auth_token + org_slug + project_slug).",
|
|
87
|
+
details={"mode": "dsn"},
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
url = f"https://sentry.io/api/0/projects/{org_slug}/{project_slug}/"
|
|
91
|
+
headers = {
|
|
92
|
+
"Authorization": f"Bearer {auth_token}",
|
|
93
|
+
"Content-Type": "application/json",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
async with httpx.AsyncClient(timeout=10) as client:
|
|
98
|
+
resp = await client.get(url, headers=headers)
|
|
99
|
+
resp.raise_for_status()
|
|
100
|
+
data = resp.json()
|
|
101
|
+
except httpx.HTTPStatusError as exc:
|
|
102
|
+
status = exc.response.status_code
|
|
103
|
+
if status == 401:
|
|
104
|
+
msg = "Sentry authentication failed — check your auth_token"
|
|
105
|
+
elif status == 403:
|
|
106
|
+
msg = "Sentry access forbidden — check auth_token scopes (project:read required)"
|
|
107
|
+
elif status == 404:
|
|
108
|
+
msg = f"Sentry project '{org_slug}/{project_slug}' not found — check org_slug and project_slug"
|
|
109
|
+
else:
|
|
110
|
+
msg = f"Sentry API error {status}: {exc.response.text}"
|
|
111
|
+
return TestConnectionResult(success=False, message=msg, details={})
|
|
112
|
+
except httpx.RequestError as exc:
|
|
113
|
+
return TestConnectionResult(
|
|
114
|
+
success=False,
|
|
115
|
+
message=f"Sentry network error: {exc}",
|
|
116
|
+
details={},
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
project_name = data.get("name", project_slug)
|
|
120
|
+
return TestConnectionResult(
|
|
121
|
+
success=True,
|
|
122
|
+
message=f"Connected to Sentry — project '{project_name}' is accessible",
|
|
123
|
+
details={"project": project_name, "org": org_slug},
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def _severity_to_priority(self, severity: str) -> str:
|
|
127
|
+
"""Map NAT severity to a Sentry event level string."""
|
|
128
|
+
return _SEVERITY_TO_SENTRY_LEVEL.get(severity.lower(), "error")
|
|
129
|
+
|
|
130
|
+
def _parse_dsn(self, dsn: str) -> tuple[str, str, str]:
|
|
131
|
+
"""Parse a Sentry DSN into (store_url, public_key, project_id).
|
|
132
|
+
|
|
133
|
+
A Sentry DSN has the form::
|
|
134
|
+
|
|
135
|
+
https://<public_key>@<host>/api/<project_id>/store/
|
|
136
|
+
or
|
|
137
|
+
https://<public_key>@<host>/<project_id>
|
|
138
|
+
|
|
139
|
+
Returns (store_url, public_key, project_id).
|
|
140
|
+
Raises ValueError if the DSN cannot be parsed.
|
|
141
|
+
"""
|
|
142
|
+
# Normalise: strip trailing slash, split scheme
|
|
143
|
+
dsn = dsn.strip().rstrip("/")
|
|
144
|
+
if "://" not in dsn:
|
|
145
|
+
raise ValueError(f"Invalid Sentry DSN (missing scheme): {dsn!r}")
|
|
146
|
+
scheme, rest = dsn.split("://", 1)
|
|
147
|
+
|
|
148
|
+
if "@" not in rest:
|
|
149
|
+
raise ValueError(f"Invalid Sentry DSN (missing '@'): {dsn!r}")
|
|
150
|
+
public_key, hostpath = rest.split("@", 1)
|
|
151
|
+
|
|
152
|
+
# Extract host and path
|
|
153
|
+
if "/" not in hostpath:
|
|
154
|
+
raise ValueError(f"Invalid Sentry DSN (missing project path): {dsn!r}")
|
|
155
|
+
slash_idx = hostpath.index("/")
|
|
156
|
+
host = hostpath[:slash_idx]
|
|
157
|
+
path = hostpath[slash_idx + 1:]
|
|
158
|
+
|
|
159
|
+
# The path might be just the project_id (legacy) or already api/<id>
|
|
160
|
+
parts = [p for p in path.split("/") if p]
|
|
161
|
+
if not parts:
|
|
162
|
+
raise ValueError(f"Invalid Sentry DSN (missing project ID): {dsn!r}")
|
|
163
|
+
project_id = parts[-1]
|
|
164
|
+
|
|
165
|
+
store_url = f"{scheme}://{host}/api/{project_id}/store/"
|
|
166
|
+
return store_url, public_key, project_id
|
|
167
|
+
|
|
168
|
+
def _build_event_payload(
|
|
169
|
+
self,
|
|
170
|
+
finding: SecurityFinding,
|
|
171
|
+
report: SecurityReport,
|
|
172
|
+
config: dict,
|
|
173
|
+
) -> dict:
|
|
174
|
+
"""Build the Sentry store-endpoint event payload."""
|
|
175
|
+
level = self._severity_to_priority(finding.severity)
|
|
176
|
+
environment = config.get("environment", "nat-scan")
|
|
177
|
+
body_text = self._format_finding_body(finding, report)
|
|
178
|
+
|
|
179
|
+
tags: list[dict] = [
|
|
180
|
+
{"key": "severity", "value": finding.severity},
|
|
181
|
+
{"key": "check_id", "value": finding.check_id},
|
|
182
|
+
{"key": "scan_id", "value": report.scan_id},
|
|
183
|
+
]
|
|
184
|
+
if finding.cwe_id:
|
|
185
|
+
tags.append({"key": "cwe", "value": finding.cwe_id})
|
|
186
|
+
|
|
187
|
+
payload: dict = {
|
|
188
|
+
"message": f"[NAT] {finding.title}",
|
|
189
|
+
"level": level,
|
|
190
|
+
"environment": environment,
|
|
191
|
+
"tags": tags,
|
|
192
|
+
"extra": {
|
|
193
|
+
"check_id": finding.check_id,
|
|
194
|
+
"check_name": finding.check_name,
|
|
195
|
+
"endpoint": finding.endpoint,
|
|
196
|
+
"confidence": finding.confidence,
|
|
197
|
+
"remediation": finding.remediation,
|
|
198
|
+
"scan_id": report.scan_id,
|
|
199
|
+
"target_url": report.target_url,
|
|
200
|
+
"body": body_text,
|
|
201
|
+
},
|
|
202
|
+
"fingerprint": [
|
|
203
|
+
"nat",
|
|
204
|
+
report.scan_id,
|
|
205
|
+
finding.check_id,
|
|
206
|
+
finding.endpoint or "",
|
|
207
|
+
],
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if config.get("release"):
|
|
211
|
+
payload["release"] = config["release"]
|
|
212
|
+
|
|
213
|
+
return payload
|
|
214
|
+
|
|
215
|
+
async def export_finding(
|
|
216
|
+
self,
|
|
217
|
+
finding: SecurityFinding,
|
|
218
|
+
report: SecurityReport,
|
|
219
|
+
config: dict,
|
|
220
|
+
) -> ExportResult:
|
|
221
|
+
dsn = config.get("dsn", "")
|
|
222
|
+
auth_token = config.get("auth_token")
|
|
223
|
+
environment = config.get("environment", "nat-scan")
|
|
224
|
+
payload = self._build_event_payload(finding, report, config)
|
|
225
|
+
|
|
226
|
+
if dsn:
|
|
227
|
+
# DSN-based submission via the store endpoint
|
|
228
|
+
try:
|
|
229
|
+
store_url, public_key, project_id = self._parse_dsn(dsn)
|
|
230
|
+
except ValueError as exc:
|
|
231
|
+
error_msg = f"Invalid Sentry DSN: {exc}"
|
|
232
|
+
logger.warning("SentryExporter: %s", error_msg)
|
|
233
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
234
|
+
|
|
235
|
+
headers = {
|
|
236
|
+
"Content-Type": "application/json",
|
|
237
|
+
"X-Sentry-Auth": (
|
|
238
|
+
f"Sentry sentry_version=7, sentry_key={public_key}, "
|
|
239
|
+
f"sentry_client=nat-sentry-exporter/1.0"
|
|
240
|
+
),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
245
|
+
resp = await client.post(store_url, json=payload, headers=headers)
|
|
246
|
+
resp.raise_for_status()
|
|
247
|
+
data = resp.json()
|
|
248
|
+
except httpx.HTTPStatusError as exc:
|
|
249
|
+
status = exc.response.status_code
|
|
250
|
+
if status == 401:
|
|
251
|
+
error_msg = "Sentry authentication failed — check your auth_token or DSN"
|
|
252
|
+
elif status == 429:
|
|
253
|
+
error_msg = "Sentry rate limit exceeded — try again later"
|
|
254
|
+
else:
|
|
255
|
+
error_msg = f"Sentry API error {status}: {exc.response.text}"
|
|
256
|
+
logger.warning("SentryExporter: %s", error_msg)
|
|
257
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
258
|
+
except httpx.RequestError as exc:
|
|
259
|
+
error_msg = f"Sentry network error: {exc}"
|
|
260
|
+
logger.warning("SentryExporter: %s", error_msg)
|
|
261
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
262
|
+
|
|
263
|
+
event_id: str = data.get("id", "")
|
|
264
|
+
return ExportResult(
|
|
265
|
+
finding=finding,
|
|
266
|
+
success=True,
|
|
267
|
+
external_id=event_id or None,
|
|
268
|
+
external_url=None,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
# auth_token-based submission via the Issues API
|
|
272
|
+
org_slug = config.get("org_slug", "")
|
|
273
|
+
project_slug = config.get("project_slug", "")
|
|
274
|
+
|
|
275
|
+
# Build an issue via the Issues API (POST /api/0/projects/{org}/{proj}/issues/)
|
|
276
|
+
# For creating new events we use the store endpoint if available; otherwise
|
|
277
|
+
# we submit a synthetic issue payload.
|
|
278
|
+
issue_payload = {
|
|
279
|
+
"title": f"[NAT] {finding.title}",
|
|
280
|
+
"culprit": finding.endpoint or "",
|
|
281
|
+
"level": self._severity_to_priority(finding.severity),
|
|
282
|
+
"environment": environment,
|
|
283
|
+
"tags": {t["key"]: t["value"] for t in payload.get("tags", [])},
|
|
284
|
+
"extra": payload.get("extra", {}),
|
|
285
|
+
}
|
|
286
|
+
if config.get("release"):
|
|
287
|
+
issue_payload["release"] = config["release"]
|
|
288
|
+
|
|
289
|
+
url = f"https://sentry.io/api/0/projects/{org_slug}/{project_slug}/issues/"
|
|
290
|
+
headers = {
|
|
291
|
+
"Authorization": f"Bearer {auth_token}",
|
|
292
|
+
"Content-Type": "application/json",
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
297
|
+
resp = await client.post(url, json=issue_payload, headers=headers)
|
|
298
|
+
resp.raise_for_status()
|
|
299
|
+
data = resp.json()
|
|
300
|
+
except httpx.HTTPStatusError as exc:
|
|
301
|
+
status = exc.response.status_code
|
|
302
|
+
if status == 401:
|
|
303
|
+
error_msg = "Sentry authentication failed — check your auth_token"
|
|
304
|
+
elif status == 429:
|
|
305
|
+
error_msg = "Sentry rate limit exceeded — try again later"
|
|
306
|
+
else:
|
|
307
|
+
error_msg = f"Sentry API error {status}: {exc.response.text}"
|
|
308
|
+
logger.warning("SentryExporter: %s", error_msg)
|
|
309
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
310
|
+
except httpx.RequestError as exc:
|
|
311
|
+
error_msg = f"Sentry network error: {exc}"
|
|
312
|
+
logger.warning("SentryExporter: %s", error_msg)
|
|
313
|
+
return ExportResult(finding=finding, success=False, error=error_msg)
|
|
314
|
+
|
|
315
|
+
issue_id: str = str(data.get("id", ""))
|
|
316
|
+
issue_url: str | None = data.get("permalink")
|
|
317
|
+
return ExportResult(
|
|
318
|
+
finding=finding,
|
|
319
|
+
success=True,
|
|
320
|
+
external_id=issue_id or None,
|
|
321
|
+
external_url=issue_url,
|
|
322
|
+
)
|