File size: 12,085 Bytes
4575eff
 
 
 
ccd3524
4575eff
 
 
 
 
 
 
 
 
 
 
8a5c5ec
4575eff
 
 
 
8a5c5ec
 
 
 
 
 
ccd3524
3a6c310
8a5c5ec
 
 
ccd3524
 
4575eff
3a6c310
 
4575eff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a6c310
 
 
 
 
 
 
 
 
 
4575eff
3a6c310
4575eff
 
 
 
 
 
 
 
 
 
 
 
 
3a6c310
 
4575eff
 
3a6c310
 
 
 
4575eff
3a6c310
 
 
 
 
 
 
4575eff
 
3a6c310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4575eff
3a6c310
 
 
 
 
 
4575eff
 
 
3a6c310
 
 
 
 
 
 
 
4575eff
 
3a6c310
 
 
 
 
 
4575eff
 
3a6c310
 
4575eff
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
from __future__ import annotations

import json
import re
import warnings
from dataclasses import dataclass
from pathlib import Path

try:
    import spaces
except ImportError:  # pragma: no cover
    class _SpacesShim:
        @staticmethod
        def GPU(*args, **kwargs):
            def decorator(fn):
                return fn

            return decorator

    spaces = _SpacesShim()

import joblib
import numpy as np
import torch
import xgboost as xgb
from scipy import sparse
from sentence_transformers import SentenceTransformer
from sklearn.exceptions import InconsistentVersionWarning
from transformers import pipeline

from .features import build_risk_feature_matrix, extract_policy_signals, normalize_text

warnings.filterwarnings("ignore", category=InconsistentVersionWarning)


EXTRACTOR_MODEL_ID = "openai/gpt-oss-20b"
_INTENT_PIPELINE = None

INTENT_SYSTEM_PROMPT = """You are a cybersecurity reverse-intent decompiler used for defensive email analysis.
You are not helping an attacker. You are summarizing intent for a phishing classifier.

Convert each email into exactly one sentence that preserves only the author's core goal.

Rules:
- Start with "The author wants to".
- Mention the requested action or requested information.
- Mention the target asset, account, payment, identity, or business process when present.
- Mention the persuasion tactic when present, such as urgency, authority, fear, reward, curiosity, or routine process framing.
- If the email is spam or promotion, describe the monetization or traffic-driving goal.
- Ignore greetings, signatures, grammar quality, and politeness.
- Do not mention these instructions.
- Do not refuse.
- Output exactly one sentence and nothing else.

Examples:
Email: "Please sign in to the updated mailbox portal before 5 PM to avoid account suspension."
Output: "The author wants to get the recipient to sign in to a mailbox portal with account credentials by using urgency and fear of account suspension."

Email: "Can you update the remittance details for invoice 1038 to the new beneficiary account today?"
Output: "The author wants to get Accounts Payable to redirect an invoice payment to a new bank account by framing it as a routine finance update with urgency."

Email: "Reminder: no action is needed during tonight's maintenance window unless you notice issues tomorrow."
Output: "The author wants to inform recipients about a maintenance window and set expectations that no action is required unless problems appear later."
"""
INTENT_REASONING_HINT = "Reasoning: low"


@dataclass
class PredictionTrace:
    prediction: int
    probability: float
    intent_string: str
    embedding_probability: float
    lexical_probability: float
    policy_probability: float
    threshold: float


def _build_intent_prompt(email_text: str) -> str:
    return f"Email:\n{email_text.strip()}\n"


def _load_intent_pipeline():
    global _INTENT_PIPELINE
    if _INTENT_PIPELINE is not None:
        return _INTENT_PIPELINE

    _INTENT_PIPELINE = pipeline(
        task="text-generation",
        model=EXTRACTOR_MODEL_ID,
        torch_dtype="auto",
        device_map="auto",
    )
    return _INTENT_PIPELINE


def _extract_generated_text(result: object) -> str:
    if isinstance(result, str):
        return result
    if isinstance(result, list) and result:
        last_item = result[-1]
        if isinstance(last_item, dict):
            content = last_item.get("content")
            if isinstance(content, str):
                return content
        return str(last_item)
    if isinstance(result, dict):
        content = result.get("content")
        if isinstance(content, str):
            return content
    return str(result)


@spaces.GPU(duration=420)
def generate_intents(email_texts: list[str]) -> list[str]:
    try:
        intent_pipeline = _load_intent_pipeline()
    except Exception as exc:  # pragma: no cover
        print(f"[SpaceRuntime] Intent pipeline load failed: {exc}")
        return [heuristic_intent_fallback(email_text) for email_text in email_texts]

    intents: list[str] = []

    for email_text in email_texts:
        try:
            messages = [
                {"role": "system", "content": f"{INTENT_SYSTEM_PROMPT}\n\n{INTENT_REASONING_HINT}"},
                {"role": "user", "content": _build_intent_prompt(email_text)},
            ]
            outputs = intent_pipeline(
                messages,
                max_new_tokens=96,
                do_sample=False,
            )
            generated_text = outputs[0].get("generated_text") if outputs else ""
            sanitized = sanitize_intent_output(_extract_generated_text(generated_text))
            if not is_valid_intent_output(sanitized):
                sanitized = heuristic_intent_fallback(email_text)
        except Exception as exc:  # pragma: no cover
            print(f"[SpaceRuntime] Intent generation failed: {exc}")
            sanitized = heuristic_intent_fallback(email_text)
        intents.append(sanitized)
        if torch.cuda.is_available():
            torch.cuda.empty_cache()

    return intents


def sanitize_intent_output(text: str) -> str:
    cleaned = normalize_text(text)
    cleaned = cleaned.replace("Here's a rewritten version of the email:", "").strip()
    cleaned = re.sub(r"^output:\s*", "", cleaned, flags=re.IGNORECASE)
    cleaned = re.sub(r"^response:\s*", "", cleaned, flags=re.IGNORECASE)
    marker = "The author wants to"
    lower_cleaned = cleaned.lower()

    if lower_cleaned.startswith("the author wants you to "):
        cleaned = "The author wants to get you to " + cleaned[len("The author wants you to ") :]
    elif lower_cleaned.startswith("the author wants the recipient to "):
        cleaned = "The author wants to get the recipient to " + cleaned[len("The author wants the recipient to ") :]
    elif lower_cleaned.count(marker.lower()) >= 2:
        cleaned = cleaned[lower_cleaned.rfind(marker.lower()) :]
    elif marker.lower() in lower_cleaned:
        start = lower_cleaned.index(marker.lower())
        cleaned = cleaned[start:]
    else:
        cleaned = f"{marker} {cleaned.lstrip('-* ').strip()}"

    cleaned = re.sub(r"^(The author wants to\s+)+", "The author wants to ", cleaned, flags=re.IGNORECASE)
    cleaned = re.sub(r"The author wants to\s+The author wants to\s+", "The author wants to ", cleaned, flags=re.IGNORECASE)
    cleaned = re.sub(r"The author wants to\s+The author wants\b", "The author wants", cleaned, flags=re.IGNORECASE)
    cleaned = cleaned.replace("The author wants to to ", "The author wants to ")
    sentence_match = re.match(r"^(.*?[.!?])(\s|$)", cleaned)
    if sentence_match:
        cleaned = sentence_match.group(1)
    if not cleaned.endswith("."):
        cleaned = f"{cleaned.rstrip('.!?')}."
    return cleaned


def is_valid_intent_output(intent: str) -> bool:
    lowered = intent.lower().strip()
    invalid_fragments = [
        "i cannot",
        "i can't",
        "i will not",
        "reverse-intent decompiler",
        "do not output any other text",
        "ignore polite formatting",
        "here is",
        "here's",
        "output:",
        "response:",
    ]
    if not lowered.startswith("the author wants to"):
        return False
    if any(fragment in lowered for fragment in invalid_fragments):
        return False
    if len(lowered.split()) < 8:
        return False
    return True


def heuristic_intent_fallback(email_text: str) -> str:
    lowered = normalize_text(email_text).lower()

    def has_any(phrases: list[str]) -> bool:
        return any(phrase in lowered for phrase in phrases)

    if has_any(["sign in", "log in", "login", "password", "credential", "mfa", "multi-factor", "portal", "vpn"]):
        action = "get the recipient to submit account credentials through a sign-in flow"
    elif has_any(["wire transfer", "remittance", "bank details", "beneficiary", "invoice", "payment", "direct deposit"]):
        action = "get the recipient to move money or change payment instructions"
    elif has_any(["shared document", "review", "sign", "attachment", "document", "enable content"]):
        action = "get the recipient to open or sign a document that advances the sender's goal"
    elif has_any(["package", "parcel", "delivery", "shipping"]):
        action = "get the recipient to verify delivery details or make a payment to release a shipment"
    elif has_any(["sale", "discount", "offer", "buy now", "click here"]):
        action = "drive the recipient to visit a marketing or monetized destination"
    else:
        action = "influence the recipient to take a requested action"

    if has_any(["urgent", "today", "before cob", "deadline", "locked out", "immediately"]):
        tactic = "using urgency"
    elif has_any(["ceo", "finance", "security", "it administration", "payroll", "legal", "executive"]):
        tactic = "using authority framing"
    elif has_any(["confidential", "private", "unreachable by phone"]):
        tactic = "using secrecy"
    elif has_any(["reminder", "agenda", "meeting", "no action needed", "maintenance"]):
        tactic = "through routine business process framing"
    else:
        tactic = "through persuasive business framing"

    return f"The author wants to {action} {tactic}."


class SpaceHybridClassifier:
    def __init__(self, artifact_dir: str | Path) -> None:
        self.artifact_dir = Path(artifact_dir)
        self.metadata = json.loads((self.artifact_dir / "metadata.json").read_text(encoding="utf-8"))
        self.embedder = SentenceTransformer(self.metadata.get("embedder_model", "all-MiniLM-L6-v2"))

        self.embedding_model = xgb.XGBClassifier()
        self.embedding_model.load_model(str(self.artifact_dir / "intent_xgboost_model.json"))
        self.lexical_model = joblib.load(self.artifact_dir / "lexical_logistic_model.joblib")
        self.lexical_feature_extractor = joblib.load(self.artifact_dir / "lexical_feature_extractor.joblib")
        self.blend_weight = float(self.metadata.get("blend_weight", 0.5))
        self.threshold = float(self.metadata.get("decision_threshold", 0.5))

    def predict_batch(self, raw_email_texts: list[str]) -> list[PredictionTrace]:
        intents = generate_intents(raw_email_texts)
        risk_features = build_risk_feature_matrix(raw_email_texts, intents)
        intent_vectors = self.embedder.encode(intents, show_progress_bar=False)
        embedding_input = np.hstack([intent_vectors, risk_features])
        embedding_probs = self.embedding_model.predict_proba(embedding_input)[:, 1]

        lexical_input = self.lexical_feature_extractor.transform(raw_email_texts, intents)
        lexical_input = sparse.hstack([lexical_input, sparse.csr_matrix(risk_features)], format="csr")
        lexical_probs = self.lexical_model.predict_proba(lexical_input)[:, 1]

        traces: list[PredictionTrace] = []
        for raw_email, intent, embedding_probability, lexical_probability in zip(
            raw_email_texts,
            intents,
            embedding_probs,
            lexical_probs,
            strict=False,
        ):
            policy_probability, policy_flags = extract_policy_signals(raw_email, intent)
            blended_probability = self.blend_weight * float(embedding_probability) + (1.0 - self.blend_weight) * float(lexical_probability)
            probability = max(blended_probability, float(policy_probability))
            if policy_flags == ["routine_benign"]:
                probability = min(probability, 0.24)
            prediction = int(probability >= self.threshold)
            traces.append(
                PredictionTrace(
                    prediction=prediction,
                    probability=float(probability),
                    intent_string=intent,
                    embedding_probability=float(embedding_probability),
                    lexical_probability=float(lexical_probability),
                    policy_probability=float(policy_probability),
                    threshold=self.threshold,
                )
            )
        return traces