spot-sdk-python 1.0.0__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.
- spot_sdk/__init__.py +86 -0
- spot_sdk/analysis_context.py +99 -0
- spot_sdk/analyzer.py +106 -0
- spot_sdk/analyzer_base.py +316 -0
- spot_sdk/api_gateway.py +271 -0
- spot_sdk/config.py +46 -0
- spot_sdk/config_client.py +203 -0
- spot_sdk/config_helpers.py +25 -0
- spot_sdk/email.py +136 -0
- spot_sdk/errors.py +24 -0
- spot_sdk/knowledge.py +341 -0
- spot_sdk/knowledge_tags.py +31 -0
- spot_sdk/logging.py +133 -0
- spot_sdk/ollama.py +58 -0
- spot_sdk/orchestrator.py +70 -0
- spot_sdk/plugin.py +30 -0
- spot_sdk/results.py +129 -0
- spot_sdk/settings_schema.py +56 -0
- spot_sdk/testing/README.md +83 -0
- spot_sdk/testing/__init__.py +22 -0
- spot_sdk/testing/factories.py +177 -0
- spot_sdk/testing/fake_knowledge_client.py +105 -0
- spot_sdk/threat_levels.py +33 -0
- spot_sdk/workflow.py +139 -0
- spot_sdk_python-1.0.0.dist-info/METADATA +353 -0
- spot_sdk_python-1.0.0.dist-info/RECORD +27 -0
- spot_sdk_python-1.0.0.dist-info/WHEEL +4 -0
spot_sdk/results.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Analysis result models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, field_validator
|
|
10
|
+
|
|
11
|
+
from .analyzer import ThreatLevel
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IndicatorType(str, Enum):
|
|
15
|
+
"""Types of phishing indicators."""
|
|
16
|
+
|
|
17
|
+
SUSPICIOUS_SENDER = "suspicious_sender"
|
|
18
|
+
URGENT_LANGUAGE = "urgent_language"
|
|
19
|
+
SUSPICIOUS_LINKS = "suspicious_links"
|
|
20
|
+
MALICIOUS_ATTACHMENT = "malicious_attachment"
|
|
21
|
+
SOCIAL_ENGINEERING = "social_engineering"
|
|
22
|
+
DOMAIN_SPOOFING = "domain_spoofing"
|
|
23
|
+
CREDENTIAL_HARVESTING = "credential_harvesting"
|
|
24
|
+
WIRE_TRANSFER_SCAM = "wire_transfer_scam"
|
|
25
|
+
CEO_FRAUD = "ceo_fraud"
|
|
26
|
+
INVOICE_SCAM = "invoice_scam"
|
|
27
|
+
IMPERSONATION = "impersonation"
|
|
28
|
+
SPELLING_GRAMMAR_ERRORS = "spelling_grammar_errors"
|
|
29
|
+
GENERIC_GREETING = "generic_greeting"
|
|
30
|
+
MISMATCHED_URL = "mismatched_url"
|
|
31
|
+
SUSPICIOUS_TIMING = "suspicious_timing"
|
|
32
|
+
MISP_IOC_MATCH = "misp_ioc_match"
|
|
33
|
+
DOMAIN_REPUTATION = "domain_reputation"
|
|
34
|
+
CONTEXT_MISMATCH = "context_mismatch"
|
|
35
|
+
SPF_FAILURE = "spf_failure"
|
|
36
|
+
UNUSUAL_HEADERS = "unusual_headers"
|
|
37
|
+
ENCODING_ANOMALY = "encoding_anomaly"
|
|
38
|
+
THREATENING_TONE = "threatening_tone"
|
|
39
|
+
|
|
40
|
+
def __str__(self) -> str:
|
|
41
|
+
return self.value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AnalysisIndicator(BaseModel):
|
|
45
|
+
"""Individual phishing indicator found in email."""
|
|
46
|
+
|
|
47
|
+
type: IndicatorType
|
|
48
|
+
confidence: float = Field(..., ge=0.0, le=1.0)
|
|
49
|
+
description: str
|
|
50
|
+
evidence: str | None = None # Text excerpt or specific evidence
|
|
51
|
+
severity: str = Field(..., pattern="^(undetermined|low|medium|high|critical)$")
|
|
52
|
+
|
|
53
|
+
# Optional: location in email where indicator was found
|
|
54
|
+
location: str | None = None # "subject", "body", "attachment", "headers"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AnalysisMetadata(BaseModel):
|
|
58
|
+
"""Analysis execution metadata."""
|
|
59
|
+
|
|
60
|
+
analyzer_id: str
|
|
61
|
+
analyzer_version: str
|
|
62
|
+
analysis_duration_ms: int
|
|
63
|
+
model_version: str | None = None
|
|
64
|
+
language_detected: str | None = None
|
|
65
|
+
processing_timestamp: datetime = Field(default_factory=datetime.utcnow)
|
|
66
|
+
|
|
67
|
+
# Resource usage (optional)
|
|
68
|
+
memory_usage_mb: int | None = None
|
|
69
|
+
cpu_usage_ms: int | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AnalysisResult(BaseModel):
|
|
73
|
+
"""
|
|
74
|
+
Standardized analysis result from any analyzer.
|
|
75
|
+
|
|
76
|
+
This is the common format returned by all analyzers,
|
|
77
|
+
enabling consistent processing by the orchestrator.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
# Core classification
|
|
81
|
+
is_phishing: bool
|
|
82
|
+
threat_level: ThreatLevel
|
|
83
|
+
confidence: float = Field(..., ge=0.0, le=1.0)
|
|
84
|
+
|
|
85
|
+
# Detailed findings
|
|
86
|
+
indicators: list[AnalysisIndicator] = Field(default_factory=list)
|
|
87
|
+
explanation: str = "" # Human-readable explanation
|
|
88
|
+
|
|
89
|
+
# Metadata
|
|
90
|
+
metadata: AnalysisMetadata
|
|
91
|
+
|
|
92
|
+
# Optional: raw analyzer output for debugging
|
|
93
|
+
raw_output: dict[str, Any] | None = None
|
|
94
|
+
|
|
95
|
+
# Additional context
|
|
96
|
+
recommendations: list[str] = Field(default_factory=list)
|
|
97
|
+
false_positive_likelihood: float | None = Field(None, ge=0.0, le=1.0)
|
|
98
|
+
|
|
99
|
+
@field_validator("threat_level")
|
|
100
|
+
@classmethod
|
|
101
|
+
def validate_threat_consistency(cls, v: str, info: Any) -> str:
|
|
102
|
+
"""Ensure threat level is consistent with is_phishing."""
|
|
103
|
+
is_phishing = info.data.get("is_phishing")
|
|
104
|
+
if is_phishing and v == ThreatLevel.SAFE:
|
|
105
|
+
raise ValueError("Cannot have 'safe' threat level when is_phishing=True")
|
|
106
|
+
if not is_phishing and v in (ThreatLevel.HIGH, ThreatLevel.CRITICAL):
|
|
107
|
+
raise ValueError("High/critical threat levels require is_phishing=True")
|
|
108
|
+
return v
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def risk_score(self) -> int:
|
|
112
|
+
"""Calculate numerical risk score (0-100)."""
|
|
113
|
+
base_score = int(self.confidence * 100)
|
|
114
|
+
|
|
115
|
+
# Adjust based on threat level
|
|
116
|
+
multipliers = {
|
|
117
|
+
ThreatLevel.SAFE: 0.0,
|
|
118
|
+
ThreatLevel.LOW: 0.3,
|
|
119
|
+
ThreatLevel.MEDIUM: 0.6,
|
|
120
|
+
ThreatLevel.HIGH: 0.8,
|
|
121
|
+
ThreatLevel.CRITICAL: 1.0,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return int(base_score * multipliers.get(self.threat_level, 0.5))
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def high_confidence_indicators(self) -> list[AnalysisIndicator]:
|
|
128
|
+
"""Get indicators with confidence > 0.7."""
|
|
129
|
+
return [ind for ind in self.indicators if ind.confidence > 0.7]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Analyzer settings schema endpoint registration.
|
|
2
|
+
|
|
3
|
+
Provides a helper to register a /settings/schema endpoint on any analyzer's
|
|
4
|
+
FastAPI app. The endpoint returns the JSON Schema of the analyzer's Pydantic
|
|
5
|
+
Settings model, which the platform uses to render typed configuration forms.
|
|
6
|
+
|
|
7
|
+
Usage in an analyzer's main.py:
|
|
8
|
+
|
|
9
|
+
from spot_sdk.settings_schema import register_settings_schema
|
|
10
|
+
from .config import Settings
|
|
11
|
+
|
|
12
|
+
app = FastAPI(...)
|
|
13
|
+
register_settings_schema(app, Settings)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from fastapi import FastAPI
|
|
19
|
+
from pydantic_settings import BaseSettings
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def register_settings_schema(app: FastAPI, settings_class: type[BaseSettings]) -> None:
|
|
23
|
+
"""Register /settings/schema and /settings/values endpoints on a FastAPI app.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
app: FastAPI application instance.
|
|
27
|
+
settings_class: The Pydantic BaseSettings class used by the analyzer.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
@app.get("/settings/schema")
|
|
31
|
+
async def get_settings_schema() -> dict[str, Any]:
|
|
32
|
+
"""Return JSON Schema for this analyzer's settings.
|
|
33
|
+
|
|
34
|
+
The schema includes field types, defaults, constraints (min/max),
|
|
35
|
+
and descriptions. The platform uses this to render typed forms
|
|
36
|
+
in the web dashboard.
|
|
37
|
+
"""
|
|
38
|
+
return settings_class.model_json_schema()
|
|
39
|
+
|
|
40
|
+
@app.get("/settings/values")
|
|
41
|
+
async def get_settings_values() -> dict[str, Any]:
|
|
42
|
+
"""Return current effective settings values.
|
|
43
|
+
|
|
44
|
+
Useful for debugging to see what values the analyzer is running with.
|
|
45
|
+
Sensitive fields (containing 'key', 'secret', 'password', 'token')
|
|
46
|
+
are redacted.
|
|
47
|
+
"""
|
|
48
|
+
instance = settings_class()
|
|
49
|
+
values = instance.model_dump()
|
|
50
|
+
|
|
51
|
+
for key in list(values.keys()):
|
|
52
|
+
if any(s in key.lower() for s in ("key", "secret", "password", "token")):
|
|
53
|
+
if values[key]:
|
|
54
|
+
values[key] = "[REDACTED]"
|
|
55
|
+
|
|
56
|
+
return values
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# SPOT SDK Testing Utilities
|
|
2
|
+
|
|
3
|
+
Shared test utilities for all SPOT projects (platform and analyzers).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
The testing module is included with spot-sdk:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from spot_sdk.testing import (
|
|
11
|
+
create_test_email,
|
|
12
|
+
create_phishing_email,
|
|
13
|
+
create_test_analysis_result,
|
|
14
|
+
create_test_indicator,
|
|
15
|
+
)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
### Creating Test Emails
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
# Create a legitimate email
|
|
24
|
+
email = create_test_email(
|
|
25
|
+
subject="Meeting Tomorrow",
|
|
26
|
+
sender="alice@company.com",
|
|
27
|
+
recipients=["team@company.com"],
|
|
28
|
+
body_text="Don't forget our meeting."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Create a phishing email with common indicators
|
|
32
|
+
phishing_email = create_phishing_email()
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Creating Test Analysis Results
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
# Create a safe analysis result
|
|
39
|
+
result = create_test_analysis_result(
|
|
40
|
+
is_phishing=False,
|
|
41
|
+
confidence=0.9,
|
|
42
|
+
threat_level="SAFE"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Create a phishing detection result
|
|
46
|
+
phishing_result = create_test_analysis_result(
|
|
47
|
+
is_phishing=True,
|
|
48
|
+
confidence=0.95,
|
|
49
|
+
threat_level="HIGH",
|
|
50
|
+
indicators=[
|
|
51
|
+
create_test_indicator(
|
|
52
|
+
indicator_type="URGENT_LANGUAGE",
|
|
53
|
+
description="Urgent language detected",
|
|
54
|
+
severity="high",
|
|
55
|
+
confidence=0.9
|
|
56
|
+
)
|
|
57
|
+
]
|
|
58
|
+
)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## In pytest
|
|
62
|
+
|
|
63
|
+
Use these factories in your conftest.py:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
import pytest
|
|
67
|
+
from spot_sdk.testing import create_test_email, create_phishing_email
|
|
68
|
+
|
|
69
|
+
@pytest.fixture
|
|
70
|
+
def sample_email():
|
|
71
|
+
return create_test_email()
|
|
72
|
+
|
|
73
|
+
@pytest.fixture
|
|
74
|
+
def phishing_email():
|
|
75
|
+
return create_phishing_email()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Design Philosophy
|
|
79
|
+
|
|
80
|
+
- **Simple**: Plain dictionaries, not complex mocks
|
|
81
|
+
- **Practical**: Real test data that works with actual SDK models
|
|
82
|
+
- **Flexible**: Easy to customize for specific test cases
|
|
83
|
+
- **No magic**: No auto-mocking or complex test frameworks
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""SPOT SDK Testing Utilities.
|
|
2
|
+
|
|
3
|
+
Shared test fixtures, factories, and utilities for all SPOT projects.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .factories import (
|
|
7
|
+
create_analysis_request,
|
|
8
|
+
create_phishing_email,
|
|
9
|
+
create_test_analysis_result,
|
|
10
|
+
create_test_email,
|
|
11
|
+
create_test_indicator,
|
|
12
|
+
create_test_user,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"create_test_email",
|
|
17
|
+
"create_phishing_email",
|
|
18
|
+
"create_test_analysis_result",
|
|
19
|
+
"create_test_indicator",
|
|
20
|
+
"create_analysis_request",
|
|
21
|
+
"create_test_user",
|
|
22
|
+
]
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Test data factories for SPOT SDK.
|
|
2
|
+
|
|
3
|
+
Simple functions to create test data as plain dictionaries.
|
|
4
|
+
These work with spot_sdk models when validated via Pydantic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def create_test_email(
|
|
12
|
+
subject: str = "Team Meeting Tomorrow",
|
|
13
|
+
sender: str = "alice@company.com",
|
|
14
|
+
recipients: list[str] | None = None,
|
|
15
|
+
body_text: str = "Hi team, don't forget about our meeting tomorrow.",
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
"""Create a legitimate test email.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
subject: Email subject
|
|
21
|
+
sender: Sender email address
|
|
22
|
+
recipients: List of recipient addresses
|
|
23
|
+
body_text: Email body text
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Email data dictionary
|
|
27
|
+
"""
|
|
28
|
+
if recipients is None:
|
|
29
|
+
recipients = ["team@company.com"]
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
"id": "test-email-123",
|
|
33
|
+
"headers": {
|
|
34
|
+
"message_id": "<test@test.com>",
|
|
35
|
+
"subject": subject,
|
|
36
|
+
"sender": sender,
|
|
37
|
+
"recipients": recipients,
|
|
38
|
+
"date": datetime.now(timezone.utc).isoformat(),
|
|
39
|
+
},
|
|
40
|
+
"body_text": body_text,
|
|
41
|
+
"body_html": None,
|
|
42
|
+
"attachments": [],
|
|
43
|
+
"received_at": datetime.now(timezone.utc).isoformat(),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def create_phishing_email() -> dict[str, Any]:
|
|
48
|
+
"""Create a phishing test email with common phishing indicators.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Email data dictionary with phishing characteristics
|
|
52
|
+
"""
|
|
53
|
+
return {
|
|
54
|
+
"id": "phishing-email-456",
|
|
55
|
+
"headers": {
|
|
56
|
+
"message_id": "<phish@evil.com>",
|
|
57
|
+
"subject": "URGENT: Verify Your Account Now!",
|
|
58
|
+
"sender": "security@paypal-verify.com",
|
|
59
|
+
"recipients": ["victim@company.com"],
|
|
60
|
+
"date": datetime.now(timezone.utc).isoformat(),
|
|
61
|
+
},
|
|
62
|
+
"body_text": (
|
|
63
|
+
"Your account has been suspended due to suspicious activity. "
|
|
64
|
+
"Click here immediately to verify: http://bit.ly/fake-link"
|
|
65
|
+
),
|
|
66
|
+
"body_html": None,
|
|
67
|
+
"attachments": [],
|
|
68
|
+
"received_at": datetime.now(timezone.utc).isoformat(),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def create_test_indicator(
|
|
73
|
+
indicator_type: str = "URGENT_LANGUAGE",
|
|
74
|
+
description: str = "Urgent language detected",
|
|
75
|
+
severity: str = "medium",
|
|
76
|
+
confidence: float = 0.8,
|
|
77
|
+
) -> dict[str, Any]:
|
|
78
|
+
"""Create a test analysis indicator.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
indicator_type: Type of indicator
|
|
82
|
+
description: Human-readable description
|
|
83
|
+
severity: Severity level (low, medium, high)
|
|
84
|
+
confidence: Confidence score (0.0-1.0)
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Indicator data dictionary
|
|
88
|
+
"""
|
|
89
|
+
return {
|
|
90
|
+
"type": indicator_type,
|
|
91
|
+
"description": description,
|
|
92
|
+
"severity": severity,
|
|
93
|
+
"confidence": confidence,
|
|
94
|
+
"source_analyzer": "test-analyzer",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def create_test_analysis_result(
|
|
99
|
+
is_phishing: bool = False,
|
|
100
|
+
confidence: float = 0.9,
|
|
101
|
+
threat_level: str = "SAFE",
|
|
102
|
+
indicators: list[dict[str, Any]] | None = None,
|
|
103
|
+
) -> dict[str, Any]:
|
|
104
|
+
"""Create a test analysis result.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
is_phishing: Whether email is classified as phishing
|
|
108
|
+
confidence: Confidence score (0.0-1.0)
|
|
109
|
+
threat_level: Threat level (SAFE, LOW, MEDIUM, HIGH, CRITICAL)
|
|
110
|
+
indicators: List of indicators (created if None)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Analysis result data dictionary
|
|
114
|
+
"""
|
|
115
|
+
if indicators is None:
|
|
116
|
+
indicators = []
|
|
117
|
+
if is_phishing:
|
|
118
|
+
indicators.append(create_test_indicator())
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
"is_phishing": is_phishing,
|
|
122
|
+
"confidence": confidence,
|
|
123
|
+
"threat_level": threat_level,
|
|
124
|
+
"indicators": indicators,
|
|
125
|
+
"metadata": {
|
|
126
|
+
"analyzer_id": "test-analyzer",
|
|
127
|
+
"analyzer_version": "1.0.0",
|
|
128
|
+
"analysis_timestamp": datetime.now(timezone.utc).isoformat(),
|
|
129
|
+
"processing_time_ms": 100,
|
|
130
|
+
},
|
|
131
|
+
"explanation": "Test analysis result",
|
|
132
|
+
"raw_output": {},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def create_analysis_request(
|
|
137
|
+
email: dict[str, Any] | None = None,
|
|
138
|
+
workflow_id: str = "default-workflow",
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""Create an analysis request.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
email: Email data dictionary (created if None)
|
|
144
|
+
workflow_id: Workflow ID to use
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
Analysis request dictionary
|
|
148
|
+
"""
|
|
149
|
+
return {
|
|
150
|
+
"email": email or create_test_email(),
|
|
151
|
+
"workflow_id": workflow_id,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def create_test_user(
|
|
156
|
+
username: str = "testuser",
|
|
157
|
+
email: str = "testuser@test.com",
|
|
158
|
+
is_active: bool = True,
|
|
159
|
+
is_superuser: bool = False,
|
|
160
|
+
) -> dict[str, Any]:
|
|
161
|
+
"""Create test user data.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
username: Username
|
|
165
|
+
email: Email address
|
|
166
|
+
is_active: Whether user is active
|
|
167
|
+
is_superuser: Whether user is superuser
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
User data dictionary
|
|
171
|
+
"""
|
|
172
|
+
return {
|
|
173
|
+
"username": username,
|
|
174
|
+
"email": email,
|
|
175
|
+
"is_active": is_active,
|
|
176
|
+
"is_superuser": is_superuser,
|
|
177
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""In-memory fake of :class:`spot_sdk.knowledge.KnowledgeClient` for tests.
|
|
2
|
+
|
|
3
|
+
No HTTP, no embedding, no vector math — just a dict keyed by ``id``
|
|
4
|
+
that supports the same upsert/fetch/delete methods. ``fetch()`` does
|
|
5
|
+
naive substring matching over content + tag-expression filtering, which
|
|
6
|
+
is enough for plugin unit tests to assert that an analyzer reads
|
|
7
|
+
documents out of the store.
|
|
8
|
+
|
|
9
|
+
Usage::
|
|
10
|
+
|
|
11
|
+
from spot_sdk.testing.fake_knowledge_client import FakeKnowledgeClient
|
|
12
|
+
|
|
13
|
+
kb = FakeKnowledgeClient()
|
|
14
|
+
await kb.upsert(KnowledgeDocument(id="...", content="...", tags=[...]))
|
|
15
|
+
docs = await kb.fetch(text="alice", tags="employee")
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from ..knowledge import KnowledgeDocument
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class FakeKnowledgeClient:
|
|
27
|
+
"""In-memory fake compatible with :class:`KnowledgeClient`."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, retrieval_limits: dict[str, Any] | None = None) -> None:
|
|
30
|
+
self._docs: dict[str, KnowledgeDocument] = {}
|
|
31
|
+
self._limits = dict(retrieval_limits or {})
|
|
32
|
+
|
|
33
|
+
# --- write side ---------------------------------------------------- #
|
|
34
|
+
|
|
35
|
+
async def upsert(self, doc: KnowledgeDocument) -> None:
|
|
36
|
+
self._docs[doc.id] = doc
|
|
37
|
+
|
|
38
|
+
async def bulk_upsert(self, docs: list[KnowledgeDocument]) -> None:
|
|
39
|
+
for d in docs:
|
|
40
|
+
self._docs[d.id] = d
|
|
41
|
+
|
|
42
|
+
async def delete(self, doc_id: str) -> None:
|
|
43
|
+
self._docs.pop(doc_id, None)
|
|
44
|
+
|
|
45
|
+
# --- read side ----------------------------------------------------- #
|
|
46
|
+
|
|
47
|
+
async def fetch(
|
|
48
|
+
self,
|
|
49
|
+
tags: str | None = None,
|
|
50
|
+
text: str | None = None,
|
|
51
|
+
top_k: int = 5,
|
|
52
|
+
min_score: float = 0.0,
|
|
53
|
+
strategy: str = "similarity",
|
|
54
|
+
) -> list[KnowledgeDocument]:
|
|
55
|
+
if text is None or not text.strip():
|
|
56
|
+
raise ValueError("fetch() requires a non-empty 'text' argument")
|
|
57
|
+
|
|
58
|
+
# Apply caps
|
|
59
|
+
max_top_k = self._limits.get("max_top_k")
|
|
60
|
+
if max_top_k is not None and top_k > max_top_k:
|
|
61
|
+
top_k = max_top_k
|
|
62
|
+
floor = self._limits.get("min_score_floor")
|
|
63
|
+
if floor is not None and min_score < floor:
|
|
64
|
+
min_score = floor
|
|
65
|
+
|
|
66
|
+
# Tag filter
|
|
67
|
+
candidates = list(self._docs.values())
|
|
68
|
+
if tags:
|
|
69
|
+
candidates = [d for d in candidates if _match_tag_expr(d.tags, tags)]
|
|
70
|
+
|
|
71
|
+
# Scored by naive substring overlap (good enough for tests)
|
|
72
|
+
terms = [t for t in re.split(r"\s+", text.lower()) if t]
|
|
73
|
+
scored: list[tuple[float, KnowledgeDocument]] = []
|
|
74
|
+
for d in candidates:
|
|
75
|
+
content_lower = d.content.lower()
|
|
76
|
+
hits = sum(1 for t in terms if t in content_lower)
|
|
77
|
+
score = hits / max(len(terms), 1)
|
|
78
|
+
if score >= min_score:
|
|
79
|
+
scored.append((score, d.model_copy(update={"score": score})))
|
|
80
|
+
|
|
81
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
82
|
+
return [d for _, d in scored[:top_k]]
|
|
83
|
+
|
|
84
|
+
# --- test helpers -------------------------------------------------- #
|
|
85
|
+
|
|
86
|
+
def all(self) -> list[KnowledgeDocument]:
|
|
87
|
+
"""Return all stored documents (test helper)."""
|
|
88
|
+
return list(self._docs.values())
|
|
89
|
+
|
|
90
|
+
def clear(self) -> None:
|
|
91
|
+
"""Remove all stored documents (test helper)."""
|
|
92
|
+
self._docs.clear()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# Same tiny tag-expression evaluator used by the real store.
|
|
96
|
+
# Grammar: term (op term)* where op is '+' (AND) or '|' (OR).
|
|
97
|
+
# AND binds tighter than OR; no parentheses.
|
|
98
|
+
def _match_tag_expr(doc_tags: list[str], expr: str) -> bool:
|
|
99
|
+
tag_set = set(doc_tags)
|
|
100
|
+
or_groups = expr.split("|")
|
|
101
|
+
for group in or_groups:
|
|
102
|
+
and_terms = [t.strip() for t in group.split("+") if t.strip()]
|
|
103
|
+
if and_terms and all(t in tag_set for t in and_terms):
|
|
104
|
+
return True
|
|
105
|
+
return False
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Standardized threat level computation for SPOT analyzers."""
|
|
2
|
+
|
|
3
|
+
from .analyzer import ThreatLevel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def confidence_to_threat_level(
|
|
7
|
+
confidence: float,
|
|
8
|
+
*,
|
|
9
|
+
high: float = 0.8,
|
|
10
|
+
medium: float = 0.5,
|
|
11
|
+
low: float = 0.2,
|
|
12
|
+
) -> ThreatLevel:
|
|
13
|
+
"""Map a confidence score to a ThreatLevel.
|
|
14
|
+
|
|
15
|
+
Uses consistent default thresholds across all analyzers.
|
|
16
|
+
Individual analyzers can override thresholds via keyword arguments.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
confidence: Confidence score in [0.0, 1.0].
|
|
20
|
+
high: Threshold for HIGH (default 0.8).
|
|
21
|
+
medium: Threshold for MEDIUM (default 0.5).
|
|
22
|
+
low: Threshold for LOW (default 0.2).
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
The corresponding ThreatLevel.
|
|
26
|
+
"""
|
|
27
|
+
if confidence >= high:
|
|
28
|
+
return ThreatLevel.HIGH
|
|
29
|
+
if confidence >= medium:
|
|
30
|
+
return ThreatLevel.MEDIUM
|
|
31
|
+
if confidence >= low:
|
|
32
|
+
return ThreatLevel.LOW
|
|
33
|
+
return ThreatLevel.SAFE
|