File size: 23,295 Bytes
d60732b | 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 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 | """
SCP V98 — AttackPatternMemory
Copyright (c) 2026 Minh. MIT License.
Port từ WHY H3 — học pattern attack từ bypass log, tự sinh rule mới.
Naming convention: <Purpose>Memory (world standard, e.g. PatternMemory, AttackMemory).
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
import re as _re # [V104.32 #23] needed for word-boundary check
import threading
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
logger = logging.getLogger("scp.security.attack_memory")
PROMOTE_AFTER_HITS = 2 # [V104.20 #2 ROLLBACK] 1 was too aggressive — 2 hits required
AUTO_DISABLE_FP_RATE = 0.3
MAX_DYNAMIC_RULES = 200
@dataclass
class DynamicRule:
"""Rule tự sinh từ bypass learning."""
rule_id: str
rule_type: str # keyword | pattern | strategy
pattern: str
description: str = ""
created_at: float = 0.0
hits: int = 0
false_positives: int = 0
promoted: bool = False
promoted_at: float = 0.0
disabled: bool = False
source_signatures: list[str] = field(default_factory=list)
examples: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, Any]:
return {
"rule_id": self.rule_id,
"rule_type": self.rule_type,
"pattern": self.pattern,
"description": self.description,
"created_at": self.created_at,
"hits": self.hits,
"false_positives": self.false_positives,
"promoted": self.promoted,
"disabled": self.disabled,
"source_signatures": self.source_signatures,
"examples": self.examples[:3],
}
# Attack keywords for keyword learning
ATTACK_KEYWORDS = [
"ignore", "previous", "instructions", "forget", "system",
"prompt", "reveal", "dan", "jailbreak", "override",
"bỏ", "qua", "lệnh", "quên", "hệ", "thống",
]
class AttackPatternMemory:
"""Learn from bypass + auto-fix + generate new rules.
Naming convention: <Purpose>Memory (world standard).
3 tiers learning:
Tầng 1: Keyword learning — học keyword mới
Tầng 2: Pattern learning — học cấu trúc câu attack
Tầng 3: Strategy learning — học multi-turn manipulation
Auto-rule generation:
Khi 1 pattern xuất hiện 3+ lần trong bypass_log → promote thành rule
Test rule mới trên lịch sử bypass → confirm không false positive → activate
Rule mới được inject vào runtime memory — H1/H8 query để block
"""
def __init__(self, data_dir: str = "data"):
self.data_dir = Path(data_dir)
self.data_dir.mkdir(parents=True, exist_ok=True)
self.bypasses: list[dict[str, Any]] = []
self.dynamic_rules: dict[str, DynamicRule] = {}
self._compiled_rules: dict[str, re.Pattern] = {}
self._keyword_index: dict[str, str] = {} # keyword → rule_id
self._lock_hashes: set = set()
# [V5.8-DEDUP] Dedup hash set for record_bypass — keyed by
# md5(f"{attack_type}:{question[:200]}"). Populated at load time
# from existing bypasses.jsonl so we never re-store a duplicate.
# TẠI SAO: bypasses.jsonl was 47686 lines but 8 cross_lang_* variants
# of the same ~6000 questions = ~48000 duplicates. Dedup at write-time
# prevents further bloat; one-time cleanup script removes existing dupes.
self._bypass_hashes: set = set()
# [V104.50 #P1-9] Re-entrant lock guarding ALL read-modify-write
# cycles on dynamic_rules.jsonl + bypasses.jsonl + in-memory dicts.
# Without this, concurrent record_bypass calls corrupt the JSONL
# (truncate-rewrite races) and lose rule.hits increments.
self._lock: threading.RLock = threading.RLock()
# Initial load runs unlocked — __init__ is single-threaded by contract.
self._load_bypasses()
self._load_rules()
def _load_bypasses(self):
"""Load bypass history."""
bypass_file = self.data_dir / "bypasses.jsonl"
if not bypass_file.is_file():
return
try:
for line in bypass_file.read_text(encoding="utf-8").splitlines():
if line.strip():
rec = json.loads(line)
self.bypasses.append(rec)
# [V5.8-DEDUP] Pre-populate dedup hash set from existing
# records so we don't re-add duplicates that are already
# on disk. TẠI SAO: bypasses.jsonl was 6.8 MB / 47686 lines
# but runtime analysis showed 8 cross_lang_* variants of
# the same ~6000 questions = ~48000 duplicates. Without
# seeding the hash set at load, every duplicate already on
# disk would be re-storable on next record_bypass call.
try:
_at = rec.get("attack_type", "") or ""
_q = rec.get("question", "") or ""
_h = hashlib.sha256(f"{_at}:{_q[:200]}".encode()).hexdigest()
self._bypass_hashes.add(_h)
# [V5.8-DEDUP] Also seed canonical (cross_lang_en)
# hashes for cross_lang_<other> records that already
# exist on disk — so a new cross_lang_zh variant of
# an already-stored cross_lang_zh is still deduped by
# its own attack_type hash (above), but a NEW en-variant
# of an existing zh-variant is also deduped (below).
if _at.startswith("cross_lang_") and _at != "cross_lang_en":
_canon_h = hashlib.sha256(
f"cross_lang_en:{_q[:200]}".encode()
).hexdigest()
self._bypass_hashes.add(_canon_h)
except Exception as e:
logger.warning(f"Silent except: {e}") # defensive — bad record shouldn't break load
logger.info(
f"[AttackPatternMemory] Loaded {len(self.bypasses)} bypasses "
f"({len(self._bypass_hashes)} dedup hashes)"
)
except Exception as e:
logger.warning(f"[AttackPatternMemory] Load bypasses error: {e}")
def _load_rules(self):
"""Load existing dynamic rules."""
rules_file = self.data_dir / "dynamic_rules.jsonl"
if not rules_file.is_file():
return
try:
for line in rules_file.read_text(encoding="utf-8").splitlines():
if line.strip():
data = json.loads(line)
rule = DynamicRule(**{k: data.get(k) for k in DynamicRule.__dataclass_fields__})
self.dynamic_rules[rule.rule_id] = rule
if rule.promoted and not rule.disabled:
self._compile_rule(rule)
logger.info(f"[AttackPatternMemory] Loaded {len(self.dynamic_rules)} rules")
except Exception as e:
logger.warning(f"[AttackPatternMemory] Load rules error: {e}")
def _compile_rule(self, rule: DynamicRule):
"""Compile regex pattern for rule."""
try:
if rule.rule_type == "pattern":
self._compiled_rules[rule.rule_id] = re.compile(rule.pattern, re.IGNORECASE)
elif rule.rule_type == "keyword":
self._keyword_index[rule.pattern.lower()] = rule.rule_id
except Exception as e:
logger.debug(f"[AttackPatternMemory] Compile error for {rule.rule_id}: {e}")
def record_bypass(
self,
question: str,
answer: str,
attack_type: str,
signatures: list[str] | None = None,
) -> dict[str, Any]:
"""Record 1 bypass event.
Args:
question: Question that bypassed
answer: Answer that was returned
attack_type: Type of attack
signatures: Attack signatures detected
Returns:
Dict with bypass_id + rule info
[V104.50 #P1-9] Whole body guarded by self._lock (RLock) so the
full read-modify-write cycle (append bypass → generate/update rule
→ check promotion → save rule via truncate-rewrite) is atomic.
Concurrent record_bypass calls from H8 background thread, judge main
thread, and ThreatSimulator thread can no longer interleave and
corrupt dynamic_rules.jsonl or lose rule.hits increments.
The lock is NOT held across any network call (this method does none).
[V5.8-DEDUP] Duplicate check at top — TẠI SAO: attack_crawler +
cross_language_learner generate 8 language variants (en/zh/ja/ko/fr/
de/es/ar) of every attack pattern. Without dedup, the same logical
bypass is stored 8 times → bypasses.jsonl ballooned to 6.8 MB /
47686 lines with ~48000 duplicates. Dedup key = md5 of
"{attack_type}:{question[:200]}". For cross_lang_<non-en> variants
we ALSO check against the cross_lang_en canonical hash — if the
English version was already stored, the translated variant is
skipped (canonical-only storage). The hash set is seeded at _load
time so existing on-disk duplicates are not re-storable.
"""
with self._lock:
# [V5.8-DEDUP] cross_lang_<non-en> → check canonical cross_lang_en hash
_is_cross_lang_variant = (
isinstance(attack_type, str)
and attack_type.startswith("cross_lang_")
and attack_type != "cross_lang_en"
)
if _is_cross_lang_variant:
_canon_hash = hashlib.sha256(
f"cross_lang_en:{(question or '')[:200]}".encode()
).hexdigest()
if _canon_hash in self._bypass_hashes:
logger.debug(
f"[V5.8-DEDUP] Skipping translated_variant "
f"(attack_type={attack_type}, canonical cross_lang_en exists)"
)
return {
"bypass_id": None,
"rule_id": None,
"rule_promoted": False,
"dedup_skipped": "translated_variant",
}
# [V5.8-DEDUP] Same attack_type + same question → duplicate
_bypass_hash = hashlib.sha256(
f"{attack_type}:{(question or '')[:200]}".encode()
).hexdigest()
if _bypass_hash in self._bypass_hashes:
logger.debug(
f"[V5.8-DEDUP] Skipping duplicate bypass "
f"(hash={_bypass_hash[:8]}, attack_type={attack_type})"
)
return {
"bypass_id": None,
"rule_id": None,
"rule_promoted": False,
"dedup_skipped": "duplicate",
}
self._bypass_hashes.add(_bypass_hash)
bypass_id = hashlib.sha256(f"{question}{time.time()}".encode()).hexdigest()[:16]
bypass = {
"id": bypass_id,
"timestamp": time.time(),
"question": question[:500],
"answer": answer[:500],
"attack_type": attack_type,
"signatures": signatures or [],
}
self.bypasses.append(bypass)
self._save_bypass(bypass)
# Generate or update rule (also locked — RLock allows re-entry).
rule = self._generate_or_update_rule(question, attack_type, signatures)
if rule:
self._check_promotion(rule)
return {
"bypass_id": bypass_id,
"rule_id": rule.rule_id if rule else None,
"rule_promoted": rule.promoted if rule else False,
}
def _generate_or_update_rule(
self,
question: str,
attack_type: str,
signatures: list[str] | None = None,
) -> DynamicRule | None:
"""Generate new rule or update existing.
[V104.50 #P1-9] Guarded by self._lock — the `rule.hits += 1`
read-modify-write and the truncate-rewrite `_save_rule` must be
atomic together to avoid lost increments and rule corruption.
"""
with self._lock:
keywords = self._extract_keywords(question)
if not keywords:
return None
# Generate rule_id from keywords + attack_type
# [FIX #7] TẠI SAO: MD5 has collision risk (CWE-327) — attacker could
# craft 2 attacks with same rule_id → bypass detection. SHA256 same
# API, just longer hash. Reality > Model: verified sha256 works.
rule_id_input = f"{attack_type}:{':'.join(sorted(keywords))}"
rule_id = f"auto_{hashlib.sha256(rule_id_input.encode()).hexdigest()[:8]}"
if rule_id in self.dynamic_rules:
# Update existing
rule = self.dynamic_rules[rule_id]
rule.hits += 1
if question[:100] not in rule.examples:
rule.examples.append(question[:100])
if signatures:
for sig in signatures:
if sig not in rule.source_signatures:
rule.source_signatures.append(sig)
else:
# Create new
rule = DynamicRule(
rule_id=rule_id,
rule_type="keyword",
pattern=keywords[0], # primary keyword
description=f"Auto-generated for {attack_type}",
created_at=time.time(),
hits=1,
source_signatures=signatures or [],
examples=[question[:100]],
)
self.dynamic_rules[rule_id] = rule
self._save_rule(rule)
return rule
def _extract_keywords(self, text: str) -> list[str]:
"""Extract attack keywords from text.
[BUG-3 FIX] TÁI SAO: trước đây chỉ extract từ ATTACK_KEYWORDS list (hardcoded).
25k bypasses không generate rule vì _extract_keywords không tìm thấy keyword
trong attack text → return [] → _generate_or_update_rule return None → 0 rules.
Fix: ngoài ATTACK_KEYWORDS, cũng extract từ signatures passed vào + n-grams.
"""
text_lower = text.lower()
keywords = [kw for kw in ATTACK_KEYWORDS if len(kw) >= 4 and _re.search(r'\b' + _re.escape(kw) + r'\b', text_lower)]
# [BUG-3 FIX] Nếu không tìm thấy keyword từ list → extract từ text directly
# Lấy các từ dài ≥4 ký tự xuất hiện ≥2 lần trong text
if not keywords:
import re as _re2
words = _re2.findall(r'\b[a-z]{4,}\b', text_lower)
from collections import Counter
word_counts = Counter(words)
# Lấy top 3 từ xuất hiện nhiều nhất (có thể là attack pattern)
keywords = [w for w, c in word_counts.most_common(3) if c >= 1 and w not in {
"that", "this", "with", "from", "have", "they", "will", "what",
"your", "were", "been", "more", "when", "some", "them", "then",
"than", "also", "just", "like", "such", "into", "only", "very",
"does", "done", "each", "make", "made", "most", "over", "take",
"want", "well", "here", "there", "where", "which", "would", "could",
"should", "about", "after", "before", "every", "never", "always",
"please", "ignore", "forget", "system", "prompt", "instructions",
"previous", "above", "below", "these", "those", "their", "other",
}]
if keywords:
logger.debug(f"[BUG-3] Extracted keywords from text: {keywords}")
return keywords[:5] # Limit to 5 keywords
def _check_promotion(self, rule: DynamicRule):
"""Check if rule should be promoted.
[V104.50 #P1-9] Guarded by self._lock — promotion mutates rule state
and rebuilds _compiled_rules/_keyword_index; must be atomic with the
increment that triggered it (caller already holds the lock via
record_bypass, but RLock makes it safe to call standalone too).
[BUG-3 FIX] TÁI SAO: PROMOTE_AFTER_HITS=2 nhưng mỗi attack chỉ hit 1 lần
(different attack text → different rule_id → hits=1 → never promoted).
Fix: auto-promote sau 1 hit nếu rule có source_signatures (came from
ThreatSimulator/H8 — đã verified là attack).
"""
with self._lock:
if rule.promoted or rule.disabled:
return
# [BUG-3 FIX] Auto-promote sau 1 hit nếu có source_signatures
# (came from verified attack source — ThreatSimulator/H8)
should_promote = False
if rule.hits >= PROMOTE_AFTER_HITS:
should_promote = True
elif rule.hits >= 1 and rule.source_signatures:
# [BUG-3] Rule came from verified attack → promote immediately
should_promote = True
logger.info(f"[BUG-3] Auto-promoting rule {rule.rule_id} (1 hit + signatures={rule.source_signatures})")
if should_promote:
# Check false positive rate
if rule.false_positives > 0:
fp_rate = rule.false_positives / rule.hits
if fp_rate > AUTO_DISABLE_FP_RATE:
rule.disabled = True
logger.info(f"[AttackPatternMemory] Auto-disabled rule {rule.rule_id} (FP rate {fp_rate:.1%})")
return
# Promote
rule.promoted = True
rule.promoted_at = time.time()
self._compile_rule(rule)
logger.info(f"[AttackPatternMemory] Promoted rule {rule.rule_id} (hits={rule.hits})")
def check_against_rules(self, question: str) -> dict[str, Any]:
"""Check if question matches any active rule.
[V104.50 #P1-9] Read-only but takes the lock to get a consistent
snapshot of _keyword_index / _compiled_rules / dynamic_rules (a
concurrent record_bypass could otherwise mutate them mid-iteration).
"""
with self._lock:
q_lower = question.lower()
# Check keyword rules
for keyword, rule_id in list(self._keyword_index.items()):
# [V104.18 #7 FIX] Word boundary match (was: substring → "dan" in "danger")
# [FALSE-POS-FIX] F402: use module-level _re (imported at L15), not loop-local import
if _re.search(r'\b' + _re.escape(keyword) + r'\b', q_lower):
rule = self.dynamic_rules.get(rule_id)
if rule and rule.promoted and not rule.disabled:
return {
"matched": True,
"rule_id": rule_id,
"rule_type": "keyword",
"attack_type": rule.description,
}
# Check pattern rules
for rule_id, pattern in list(self._compiled_rules.items()):
if pattern.search(question):
rule = self.dynamic_rules.get(rule_id)
if rule and rule.promoted and not rule.disabled:
return {
"matched": True,
"rule_id": rule_id,
"rule_type": "pattern",
"attack_type": rule.description,
}
return {"matched": False}
def _save_bypass(self, bypass: dict[str, Any]):
"""Save bypass to file.
[V104.50 #P1-9] Append-mode write is inherently atomic at the OS
level for small lines, but we still take the lock for the in-memory
`self.bypasses.append` ordering (the caller record_bypass already
holds it; RLock makes standalone calls safe too).
"""
with self._lock:
try:
bypass_file = self.data_dir / "bypasses.jsonl"
with open(bypass_file, "a", encoding="utf-8") as f:
f.write(json.dumps(bypass, ensure_ascii=False) + "\n")
except Exception as e:
logger.debug(f"[AttackPatternMemory] Save bypass error: {e}")
def _save_rule(self, rule: DynamicRule):
"""Save rule to file (rewrite all).
[V104.50 #P1-9] CRITICAL: truncate-rewrite ("w" mode) is NOT
concurrency-safe. Two concurrent calls both truncate → last writer
wins → earlier rules silently vanish. Guard with self._lock so the
full read-iterate-write cycle is atomic with the in-memory mutation
that preceded it (rule.hits += 1, promote, etc.). The caller
_generate_or_update_rule already holds the lock; RLock allows
re-entry and makes standalone calls (e.g. from tests) safe too.
Additionally use atomic write (temp file + os.replace) so a crashed
write cannot corrupt the existing file.
"""
with self._lock:
try:
import os as _os
rules_file = self.data_dir / "dynamic_rules.jsonl"
tmp_file = rules_file.with_suffix(".jsonl.tmp")
with open(tmp_file, "w", encoding="utf-8") as f:
for r in self.dynamic_rules.values():
f.write(json.dumps(r.to_dict(), ensure_ascii=False) + "\n")
_os.replace(tmp_file, rules_file)
except Exception as e:
logger.debug(f"[AttackPatternMemory] Save rule error: {e}")
def get_active_rules(self) -> list[dict[str, Any]]:
"""Get all promoted + active rules.
[V104.50 #P1-9] Snapshot under lock to avoid concurrent mutation
during iteration.
"""
with self._lock:
return [r.to_dict() for r in self.dynamic_rules.values() if r.promoted and not r.disabled]
def stats(self) -> dict[str, Any]:
# [V104.50 #P1-9] Snapshot under lock for consistent counts.
with self._lock:
return {
"total_bypasses": len(self.bypasses),
"total_rules": len(self.dynamic_rules),
"active_rules": len(self.get_active_rules()),
"promoted_rules": sum(1 for r in self.dynamic_rules.values() if r.promoted),
"disabled_rules": sum(1 for r in self.dynamic_rules.values() if r.disabled),
"by_type": {
t: sum(1 for r in self.dynamic_rules.values() if r.rule_type == t)
for t in ("keyword", "pattern", "strategy")
},
}
__all__ = ["DynamicRule", "AttackPatternMemory", "ATTACK_KEYWORDS"]
|