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,311 @@
|
|
|
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
|
+
"""Reusable LLM-powered root cause analysis service (Phase 10.3).
|
|
7
|
+
|
|
8
|
+
Provides :class:`RootCauseSuggestion` (Pydantic model) and
|
|
9
|
+
:class:`ScanRootCauseAnalyzer` — a standalone async service that can be called
|
|
10
|
+
from any scan pipeline (API security scans, browser functional tests, or the
|
|
11
|
+
autonomous loop) to generate LLM root cause explanations for failures.
|
|
12
|
+
|
|
13
|
+
Feature gating
|
|
14
|
+
--------------
|
|
15
|
+
The analyzer honours the ``llm_root_cause`` quota from
|
|
16
|
+
:mod:`mannf.product.billing.feature_gates`:
|
|
17
|
+
|
|
18
|
+
- ``free``: disabled (0)
|
|
19
|
+
- ``pro``: top 5 findings/scan
|
|
20
|
+
- ``team`` / ``enterprise``: unlimited (-1)
|
|
21
|
+
|
|
22
|
+
Callers should sort failures by severity **before** calling
|
|
23
|
+
:meth:`ScanRootCauseAnalyzer.analyze_failures` and pass only the subset they
|
|
24
|
+
are allowed to analyze. The service itself does not enforce the quota — that
|
|
25
|
+
is the responsibility of the caller (typically the server endpoint or
|
|
26
|
+
orchestrator integration).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
from typing import Any, Dict, List, Optional
|
|
34
|
+
|
|
35
|
+
from pydantic import BaseModel, Field
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Extended prompt template — works with API/security/functional failures
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
_EXTENDED_ROOT_CAUSE_PROMPT = """\
|
|
44
|
+
You are an expert software reliability engineer specialising in web application \
|
|
45
|
+
test automation and root cause analysis.
|
|
46
|
+
|
|
47
|
+
A failure has been detected. Using the information below, identify the most \
|
|
48
|
+
likely root cause and suggest concrete debugging steps.
|
|
49
|
+
|
|
50
|
+
Failure details:
|
|
51
|
+
Type: {failure_type}
|
|
52
|
+
URL / Endpoint: {url}
|
|
53
|
+
Error / Description: {error}
|
|
54
|
+
Severity: {severity}
|
|
55
|
+
HTTP Status Code: {status_code}
|
|
56
|
+
HTTP Response Body (truncated): {response_body}
|
|
57
|
+
Security Finding ID: {check_id}
|
|
58
|
+
Security Finding Title: {title}
|
|
59
|
+
CWE: {cwe_id}
|
|
60
|
+
Evidence: {evidence}
|
|
61
|
+
Remediation hint: {remediation}
|
|
62
|
+
Additional context: {context}
|
|
63
|
+
|
|
64
|
+
Based on the above, provide a structured JSON response with the following fields:
|
|
65
|
+
- "likely_cause": one-sentence description of the most probable root cause
|
|
66
|
+
- "suggested_fix": concise actionable suggestion for where to start debugging
|
|
67
|
+
- "confidence": float 0.0–1.0 indicating your confidence in this root cause \
|
|
68
|
+
(use lower values when limited context is available)
|
|
69
|
+
- "files_to_check": list of source file paths or component names most likely \
|
|
70
|
+
to contain the defect (empty list when unknown)
|
|
71
|
+
|
|
72
|
+
Return ONLY the JSON object with no surrounding markdown or explanation."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
# Pydantic model
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class RootCauseSuggestion(BaseModel):
|
|
81
|
+
"""Structured LLM root cause suggestion for a single failure.
|
|
82
|
+
|
|
83
|
+
Compatible with any failure format: API scan findings, browser test
|
|
84
|
+
failures, or security findings.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
failure_id: str = Field("", description="Identifier of the failure (task_id, finding id, etc.).")
|
|
88
|
+
failure_type: str = Field("unknown", description="'security', 'functional', or 'autonomous'.")
|
|
89
|
+
url: str = Field("", description="URL or endpoint where the failure was detected.")
|
|
90
|
+
error: str = Field("", description="Error message or failure description.")
|
|
91
|
+
severity: str = Field("", description="Severity level: 'critical', 'high', 'medium', 'low'.")
|
|
92
|
+
likely_cause: str = Field(..., description="One-sentence description of the most probable root cause.")
|
|
93
|
+
suggested_fix: str = Field(..., description="Concise actionable debugging suggestion.")
|
|
94
|
+
confidence: float = Field(..., ge=0.0, le=1.0, description="LLM confidence in this root cause (0–1).")
|
|
95
|
+
files_to_check: List[str] = Field(default_factory=list, description="Source files or components to inspect.")
|
|
96
|
+
quota_limited: bool = Field(False, description="True when this finding was not analyzed due to plan quota.")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Analyzer service
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ScanRootCauseAnalyzer:
|
|
105
|
+
"""Reusable async service that calls the LLM to explain scan failures.
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
llm_provider:
|
|
110
|
+
An initialised :class:`~mannf.product.llm.base.LLMProvider` instance.
|
|
111
|
+
When ``None``, :meth:`analyze_failures` returns an empty list.
|
|
112
|
+
quota:
|
|
113
|
+
Maximum number of failures to analyze in one call. ``-1`` means
|
|
114
|
+
unlimited. ``0`` means disabled. Corresponds to the
|
|
115
|
+
``llm_root_cause`` feature gate value.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
llm_provider: Optional[Any] = None,
|
|
121
|
+
quota: int = -1,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._provider = llm_provider
|
|
124
|
+
self._quota = quota
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Public API
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
async def analyze_failures(
|
|
131
|
+
self,
|
|
132
|
+
failures: List[Dict[str, Any]],
|
|
133
|
+
context: Optional[Dict[str, Any]] = None,
|
|
134
|
+
) -> List[RootCauseSuggestion]:
|
|
135
|
+
"""Analyze a list of failures and return root cause suggestions.
|
|
136
|
+
|
|
137
|
+
Parameters
|
|
138
|
+
----------
|
|
139
|
+
failures:
|
|
140
|
+
List of failure dicts. Each dict should contain as many of
|
|
141
|
+
the following keys as available:
|
|
142
|
+
|
|
143
|
+
- ``id`` / ``failure_id`` / ``task_id`` — unique identifier
|
|
144
|
+
- ``type`` / ``failure_type`` — ``"security"``, ``"functional"``, etc.
|
|
145
|
+
- ``url`` — URL or endpoint path
|
|
146
|
+
- ``error`` / ``description`` — error message
|
|
147
|
+
- ``severity`` — ``"critical"`` / ``"high"`` / ``"medium"`` / ``"low"``
|
|
148
|
+
- ``status_code`` — HTTP status code (for API failures)
|
|
149
|
+
- ``response_body`` — truncated HTTP response body
|
|
150
|
+
- ``check_id`` — security check identifier
|
|
151
|
+
- ``title`` — finding title
|
|
152
|
+
- ``cwe_id`` — CWE identifier
|
|
153
|
+
- ``evidence`` — security evidence string
|
|
154
|
+
- ``remediation`` — remediation hint
|
|
155
|
+
context:
|
|
156
|
+
Optional dict with additional context shared across all failures
|
|
157
|
+
(e.g., ``{"api_base_url": "...", "scan_type": "security"}``).
|
|
158
|
+
|
|
159
|
+
Returns
|
|
160
|
+
-------
|
|
161
|
+
list[RootCauseSuggestion]
|
|
162
|
+
One suggestion per analyzed failure. Failures that hit the quota
|
|
163
|
+
wall are returned with ``quota_limited=True`` and placeholder text.
|
|
164
|
+
Returns an empty list when LLM is unavailable or quota is 0.
|
|
165
|
+
"""
|
|
166
|
+
if self._provider is None or self._quota == 0:
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
if not failures:
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
ctx = context or {}
|
|
173
|
+
results: List[RootCauseSuggestion] = []
|
|
174
|
+
|
|
175
|
+
for idx, failure in enumerate(failures):
|
|
176
|
+
failure_id = (
|
|
177
|
+
failure.get("id")
|
|
178
|
+
or failure.get("failure_id")
|
|
179
|
+
or failure.get("task_id")
|
|
180
|
+
or str(idx)
|
|
181
|
+
)
|
|
182
|
+
severity = failure.get("severity", "")
|
|
183
|
+
url = failure.get("url", failure.get("endpoint", ""))
|
|
184
|
+
failure_type = failure.get("type", failure.get("failure_type", "unknown"))
|
|
185
|
+
|
|
186
|
+
# Quota enforcement: beyond the allowed N, return placeholder entries
|
|
187
|
+
if self._quota != -1 and idx >= self._quota:
|
|
188
|
+
results.append(
|
|
189
|
+
RootCauseSuggestion(
|
|
190
|
+
failure_id=str(failure_id),
|
|
191
|
+
failure_type=failure_type,
|
|
192
|
+
url=url,
|
|
193
|
+
error=failure.get("error", failure.get("description", "")),
|
|
194
|
+
severity=severity,
|
|
195
|
+
likely_cause="Analysis skipped — plan quota reached.",
|
|
196
|
+
suggested_fix="Upgrade your plan to analyze all findings.",
|
|
197
|
+
confidence=0.0,
|
|
198
|
+
files_to_check=[],
|
|
199
|
+
quota_limited=True,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
suggestion = await self._analyze_one(failure, ctx)
|
|
205
|
+
if suggestion is not None:
|
|
206
|
+
results.append(suggestion)
|
|
207
|
+
|
|
208
|
+
return results
|
|
209
|
+
|
|
210
|
+
# ------------------------------------------------------------------
|
|
211
|
+
# Internals
|
|
212
|
+
# ------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
async def _analyze_one(
|
|
215
|
+
self,
|
|
216
|
+
failure: Dict[str, Any],
|
|
217
|
+
context: Dict[str, Any],
|
|
218
|
+
) -> Optional[RootCauseSuggestion]:
|
|
219
|
+
"""Analyze a single failure dict; returns ``None`` on LLM error."""
|
|
220
|
+
failure_id = (
|
|
221
|
+
failure.get("id")
|
|
222
|
+
or failure.get("failure_id")
|
|
223
|
+
or failure.get("task_id")
|
|
224
|
+
or ""
|
|
225
|
+
)
|
|
226
|
+
failure_type = failure.get("type", failure.get("failure_type", "unknown"))
|
|
227
|
+
url = failure.get("url", failure.get("endpoint", ""))
|
|
228
|
+
error = failure.get("error", failure.get("description", ""))
|
|
229
|
+
severity = failure.get("severity", "")
|
|
230
|
+
status_code = str(failure.get("status_code", ""))
|
|
231
|
+
response_body = (failure.get("response_body") or "")[:500]
|
|
232
|
+
check_id = failure.get("check_id", "")
|
|
233
|
+
title = failure.get("title", "")
|
|
234
|
+
cwe_id = failure.get("cwe_id", "")
|
|
235
|
+
evidence = (failure.get("evidence") or "")[:500]
|
|
236
|
+
remediation = failure.get("remediation", "")
|
|
237
|
+
|
|
238
|
+
extra_ctx = dict(context)
|
|
239
|
+
extra_ctx.update(failure.get("context", {}))
|
|
240
|
+
context_str = json.dumps(extra_ctx) if extra_ctx else "{}"
|
|
241
|
+
|
|
242
|
+
prompt = _EXTENDED_ROOT_CAUSE_PROMPT.format(
|
|
243
|
+
failure_type=failure_type,
|
|
244
|
+
url=url,
|
|
245
|
+
error=error,
|
|
246
|
+
severity=severity,
|
|
247
|
+
status_code=status_code,
|
|
248
|
+
response_body=response_body,
|
|
249
|
+
check_id=check_id,
|
|
250
|
+
title=title,
|
|
251
|
+
cwe_id=cwe_id,
|
|
252
|
+
evidence=evidence,
|
|
253
|
+
remediation=remediation,
|
|
254
|
+
context=context_str,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
try:
|
|
258
|
+
raw = await self._call_llm(prompt)
|
|
259
|
+
parsed = self._parse_llm_json(raw)
|
|
260
|
+
except Exception as exc: # noqa: BLE001
|
|
261
|
+
logger.warning("ScanRootCauseAnalyzer: LLM call failed for %s — %s", failure_id, exc)
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
return RootCauseSuggestion(
|
|
265
|
+
failure_id=str(failure_id),
|
|
266
|
+
failure_type=failure_type,
|
|
267
|
+
url=url,
|
|
268
|
+
error=error,
|
|
269
|
+
severity=severity,
|
|
270
|
+
likely_cause=str(parsed.get("likely_cause", "Unable to determine root cause")),
|
|
271
|
+
suggested_fix=str(parsed.get("suggested_fix", "Investigate the error manually")),
|
|
272
|
+
confidence=float(max(0.0, min(1.0, parsed.get("confidence", 0.0)))),
|
|
273
|
+
files_to_check=list(parsed.get("files_to_check", [])),
|
|
274
|
+
quota_limited=False,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def _call_llm(self, prompt: str) -> str:
|
|
278
|
+
"""Invoke the LLM provider's generate method."""
|
|
279
|
+
provider = self._provider
|
|
280
|
+
if hasattr(provider, "generate"):
|
|
281
|
+
return await provider.generate(prompt)
|
|
282
|
+
if hasattr(provider, "acomplete"):
|
|
283
|
+
return await provider.acomplete(prompt)
|
|
284
|
+
if hasattr(provider, "complete"):
|
|
285
|
+
result = provider.complete(prompt)
|
|
286
|
+
if hasattr(result, "__await__"):
|
|
287
|
+
return await result
|
|
288
|
+
return result
|
|
289
|
+
raise AttributeError("LLM provider has no 'generate', 'acomplete', or 'complete' method")
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _parse_llm_json(raw: str) -> Dict[str, Any]:
|
|
293
|
+
"""Extract the JSON object from the LLM response string."""
|
|
294
|
+
text = raw.strip()
|
|
295
|
+
if text.startswith("```"):
|
|
296
|
+
lines = text.splitlines()
|
|
297
|
+
inner = [ln for ln in lines[1:] if ln.strip() != "```"]
|
|
298
|
+
text = "\n".join(inner).strip()
|
|
299
|
+
try:
|
|
300
|
+
return json.loads(text)
|
|
301
|
+
except json.JSONDecodeError:
|
|
302
|
+
pass
|
|
303
|
+
# Fallback: find the first {...} block
|
|
304
|
+
start = text.find("{")
|
|
305
|
+
end = text.rfind("}")
|
|
306
|
+
if start != -1 and end != -1 and end > start:
|
|
307
|
+
try:
|
|
308
|
+
return json.loads(text[start : end + 1])
|
|
309
|
+
except json.JSONDecodeError:
|
|
310
|
+
pass
|
|
311
|
+
return {}
|
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
"""Pydantic models for the LLM-generated test plan feature (Phase 10.1)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import List, Optional
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PlanStatus(str, Enum):
|
|
18
|
+
draft = "draft"
|
|
19
|
+
approved = "approved"
|
|
20
|
+
executed = "executed"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Priority(str, Enum):
|
|
24
|
+
critical = "critical"
|
|
25
|
+
high = "high"
|
|
26
|
+
medium = "medium"
|
|
27
|
+
low = "low"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EndpointGroup(BaseModel):
|
|
31
|
+
"""A logical group of related API endpoints within a test plan."""
|
|
32
|
+
|
|
33
|
+
name: str = Field(..., description="Human-readable group name (e.g. 'Authentication', 'User CRUD').")
|
|
34
|
+
endpoints: List[str] = Field(default_factory=list, description="List of 'METHOD /path' strings in this group.")
|
|
35
|
+
priority: Priority = Field(Priority.medium, description="Risk-based priority for this group.")
|
|
36
|
+
risk_assessment: str = Field("", description="Brief description of the risk profile for this group.")
|
|
37
|
+
suggested_strategies: List[str] = Field(
|
|
38
|
+
default_factory=list,
|
|
39
|
+
description="Recommended test strategies (e.g. 'boundary value', 'auth bypass').",
|
|
40
|
+
)
|
|
41
|
+
edge_cases: List[str] = Field(
|
|
42
|
+
default_factory=list,
|
|
43
|
+
description="Notable edge cases or business logic scenarios to cover.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PlanApproval(BaseModel):
|
|
48
|
+
"""Records user review/approval of a test plan."""
|
|
49
|
+
|
|
50
|
+
approved_by: str = Field("", description="Username or identifier of the approver.")
|
|
51
|
+
approved_at: Optional[str] = Field(None, description="ISO-8601 timestamp of approval.")
|
|
52
|
+
excluded_groups: List[str] = Field(
|
|
53
|
+
default_factory=list,
|
|
54
|
+
description="Names of endpoint groups that were excluded from execution.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestPlan(BaseModel):
|
|
59
|
+
"""Top-level container for an LLM-generated API test plan."""
|
|
60
|
+
|
|
61
|
+
id: str = Field(..., description="Unique plan identifier (UUID).")
|
|
62
|
+
status: PlanStatus = Field(PlanStatus.draft, description="Lifecycle state of the plan.")
|
|
63
|
+
created_at: str = Field(
|
|
64
|
+
default_factory=lambda: datetime.now(timezone.utc).isoformat(),
|
|
65
|
+
description="ISO-8601 creation timestamp.",
|
|
66
|
+
)
|
|
67
|
+
spec_source: str = Field("", description="Original spec file name or URL.")
|
|
68
|
+
endpoint_groups: List[EndpointGroup] = Field(
|
|
69
|
+
default_factory=list,
|
|
70
|
+
description="Priority-ranked groups of related endpoints.",
|
|
71
|
+
)
|
|
72
|
+
estimated_coverage: Optional[float] = Field(
|
|
73
|
+
None,
|
|
74
|
+
ge=0.0,
|
|
75
|
+
le=1.0,
|
|
76
|
+
description="Estimated test coverage as a fraction (0–1), if the LLM provided one.",
|
|
77
|
+
)
|
|
78
|
+
approval: Optional[PlanApproval] = Field(None, description="Approval record, set when the plan is approved.")
|
mannf/product/metrics.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
"""Custom OpenTelemetry metrics for NAT scan lifecycle events.
|
|
7
|
+
|
|
8
|
+
Public API
|
|
9
|
+
----------
|
|
10
|
+
- :func:`record_scan_start` — call when a scan begins.
|
|
11
|
+
- :func:`record_scan_end` — call when a scan finishes (any terminal status).
|
|
12
|
+
|
|
13
|
+
All functions are safe no-ops when ``opentelemetry-api`` is not installed or
|
|
14
|
+
when no metrics exporter is configured.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# No-op stubs used when OTel is unavailable
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _NoOpCounter:
|
|
30
|
+
def add(self, amount: float, attributes: Optional[dict] = None) -> None:
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _NoOpHistogram:
|
|
35
|
+
def record(self, amount: float, attributes: Optional[dict] = None) -> None:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _NoOpUpDownCounter:
|
|
40
|
+
def add(self, amount: float, attributes: Optional[dict] = None) -> None:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Meter + instruments (initialised lazily)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
scan_total: Any = _NoOpCounter()
|
|
49
|
+
scan_duration_seconds: Any = _NoOpHistogram()
|
|
50
|
+
scan_status_total: Any = _NoOpCounter()
|
|
51
|
+
active_scans: Any = _NoOpUpDownCounter()
|
|
52
|
+
|
|
53
|
+
_initialized: bool = False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _init_instruments() -> None:
|
|
57
|
+
"""Create real OTel instruments if the SDK is available."""
|
|
58
|
+
global scan_total, scan_duration_seconds, scan_status_total, active_scans, _initialized
|
|
59
|
+
if _initialized:
|
|
60
|
+
return
|
|
61
|
+
_initialized = True
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
from opentelemetry import metrics as _otel_metrics # type: ignore[import]
|
|
65
|
+
|
|
66
|
+
meter = _otel_metrics.get_meter("mannf.product", version="1.0")
|
|
67
|
+
|
|
68
|
+
scan_total = meter.create_counter(
|
|
69
|
+
name="mannf.scan_total",
|
|
70
|
+
description="Total number of scans started",
|
|
71
|
+
unit="1",
|
|
72
|
+
)
|
|
73
|
+
scan_duration_seconds = meter.create_histogram(
|
|
74
|
+
name="mannf.scan_duration_seconds",
|
|
75
|
+
description="Wall-clock scan duration in seconds",
|
|
76
|
+
unit="s",
|
|
77
|
+
)
|
|
78
|
+
scan_status_total = meter.create_counter(
|
|
79
|
+
name="mannf.scan_status_total",
|
|
80
|
+
description="Total scans completed, by terminal status",
|
|
81
|
+
unit="1",
|
|
82
|
+
)
|
|
83
|
+
active_scans = meter.create_up_down_counter(
|
|
84
|
+
name="mannf.active_scans",
|
|
85
|
+
description="Number of scans currently running",
|
|
86
|
+
unit="1",
|
|
87
|
+
)
|
|
88
|
+
except ImportError:
|
|
89
|
+
# opentelemetry-api not installed — no-op stubs remain
|
|
90
|
+
pass
|
|
91
|
+
except Exception: # noqa: BLE001
|
|
92
|
+
import traceback
|
|
93
|
+
logger.warning(
|
|
94
|
+
"Failed to initialise OpenTelemetry metric instruments:\n%s",
|
|
95
|
+
traceback.format_exc(),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Initialise eagerly so tests can inspect the state after import.
|
|
100
|
+
_init_instruments()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Public helper functions
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def record_scan_start(scan_type: str, tenant_id: Optional[str] = None) -> None:
|
|
109
|
+
"""Increment scan_total and active_scans counters when a scan begins.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
scan_type: ``"functional"`` or ``"security"``.
|
|
113
|
+
tenant_id: Optional tenant identifier for multi-tenant deployments.
|
|
114
|
+
"""
|
|
115
|
+
try:
|
|
116
|
+
attrs: dict[str, str] = {"scan_type": scan_type}
|
|
117
|
+
if tenant_id:
|
|
118
|
+
attrs["tenant_id"] = str(tenant_id)
|
|
119
|
+
scan_total.add(1, attrs)
|
|
120
|
+
active_scans.add(1, attrs)
|
|
121
|
+
except Exception: # noqa: BLE001
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def record_scan_end(
|
|
126
|
+
scan_type: str,
|
|
127
|
+
tenant_id: Optional[str],
|
|
128
|
+
status: str,
|
|
129
|
+
duration_s: float,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Record scan completion metrics.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
scan_type: ``"functional"`` or ``"security"``.
|
|
135
|
+
tenant_id: Optional tenant identifier.
|
|
136
|
+
status: Terminal status — one of ``"completed"``, ``"failed"``,
|
|
137
|
+
``"cancelled"``, ``"timed_out"``.
|
|
138
|
+
duration_s: Wall-clock scan duration in seconds.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
base_attrs: dict[str, str] = {"scan_type": scan_type}
|
|
142
|
+
if tenant_id:
|
|
143
|
+
base_attrs["tenant_id"] = str(tenant_id)
|
|
144
|
+
|
|
145
|
+
scan_status_total.add(1, {**base_attrs, "status": status})
|
|
146
|
+
scan_duration_seconds.record(duration_s, base_attrs)
|
|
147
|
+
active_scans.add(-1, base_attrs)
|
|
148
|
+
except Exception: # noqa: BLE001
|
|
149
|
+
pass
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
|
|
5
|
+
"""API audit logging middleware.
|
|
6
|
+
|
|
7
|
+
Records every mutating HTTP call (POST, PUT, PATCH, DELETE) to the
|
|
8
|
+
``api_audit_log`` table. Never blocks the request — failures are logged
|
|
9
|
+
and silently swallowed.
|
|
10
|
+
|
|
11
|
+
Usage::
|
|
12
|
+
|
|
13
|
+
from mannf.product.middleware.audit_middleware import attach_audit_middleware
|
|
14
|
+
attach_audit_middleware(app)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import uuid
|
|
21
|
+
|
|
22
|
+
from fastapi import FastAPI
|
|
23
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
24
|
+
from starlette.requests import Request
|
|
25
|
+
from starlette.responses import Response
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_MUTATING_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuditMiddleware(BaseHTTPMiddleware):
|
|
33
|
+
"""Starlette middleware that logs mutating API calls to api_audit_log."""
|
|
34
|
+
|
|
35
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
36
|
+
response = await call_next(request)
|
|
37
|
+
|
|
38
|
+
if request.method.upper() not in _MUTATING_METHODS:
|
|
39
|
+
return response
|
|
40
|
+
|
|
41
|
+
# Only log API paths
|
|
42
|
+
path = request.url.path
|
|
43
|
+
if not path.startswith("/api/"):
|
|
44
|
+
return response
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
await _log_api_call(request, response.status_code)
|
|
48
|
+
except Exception as exc: # noqa: BLE001
|
|
49
|
+
logger.debug("AuditMiddleware: failed to log call to %s: %s", path, exc)
|
|
50
|
+
|
|
51
|
+
return response
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
async def _log_api_call(request: Request, status_code: int) -> None:
|
|
55
|
+
"""Write an ApiAuditLog record for a request."""
|
|
56
|
+
try:
|
|
57
|
+
from mannf.product.database import _async_session_factory, _build_engine # noqa: PLC0415
|
|
58
|
+
from mannf.product.models import ApiAuditLog # noqa: PLC0415
|
|
59
|
+
except ImportError:
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
if _build_engine() is None or _async_session_factory is None:
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
tenant = getattr(request.state, "tenant", None)
|
|
66
|
+
user = getattr(request.state, "user", None)
|
|
67
|
+
|
|
68
|
+
tenant_id: uuid.UUID | None = None
|
|
69
|
+
if tenant:
|
|
70
|
+
try:
|
|
71
|
+
raw_tid = tenant.get("tenant_id") or tenant.get("id")
|
|
72
|
+
if raw_tid:
|
|
73
|
+
tenant_id = uuid.UUID(str(raw_tid))
|
|
74
|
+
except (ValueError, AttributeError):
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
user_id: uuid.UUID | None = None
|
|
78
|
+
if user and user.get("id"):
|
|
79
|
+
try:
|
|
80
|
+
user_id = uuid.UUID(str(user["id"]))
|
|
81
|
+
except ValueError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# Best-effort request summary (method + path + query)
|
|
85
|
+
qs = str(request.query_params) if request.query_params else ""
|
|
86
|
+
request_summary = f"{request.method} {request.url.path}"
|
|
87
|
+
if qs:
|
|
88
|
+
request_summary += f"?{qs}"
|
|
89
|
+
|
|
90
|
+
source_ip = request.headers.get("x-forwarded-for") or (request.client.host if request.client else None)
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
async with _async_session_factory() as session:
|
|
94
|
+
entry = ApiAuditLog(
|
|
95
|
+
id=uuid.uuid4(),
|
|
96
|
+
tenant_id=tenant_id,
|
|
97
|
+
user_id=user_id,
|
|
98
|
+
method=request.method[:10],
|
|
99
|
+
path=request.url.path[:2048],
|
|
100
|
+
status_code=status_code,
|
|
101
|
+
request_summary=request_summary[:4096] if request_summary else None,
|
|
102
|
+
source_ip=source_ip[:64] if source_ip else None,
|
|
103
|
+
)
|
|
104
|
+
session.add(entry)
|
|
105
|
+
await session.commit()
|
|
106
|
+
except Exception as exc: # noqa: BLE001
|
|
107
|
+
logger.debug("AuditMiddleware: DB write failed: %s", exc)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def attach_audit_middleware(app: FastAPI) -> None:
|
|
111
|
+
"""Attach the AuditMiddleware to a FastAPI application."""
|
|
112
|
+
app.add_middleware(AuditMiddleware)
|