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
|