Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ __pycache__/
secrets/
*.pem
*.key
scripts/adversarial_test/output/
Empty file.
108 changes: 108 additions & 0 deletions scripts/adversarial_test/aggregate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import argparse
import csv
import logging
from pathlib import Path

logger = logging.getLogger(__name__)

OUTPUT_DIR = Path(__file__).parent / "output"
ORIGINAL_LABEL = "ORIGINAL"
EXCERPT_LENGTH = 60


def read_results(path: Path) -> list[dict]:
with path.open(encoding="utf-8", newline="") as f:
rows = list(csv.DictReader(f))
for row in rows:
row["rule_detected"] = row["rule_detected"] == "True"
row["pipeline_detected"] = row["pipeline_detected"] == "True"
row["rule_score"] = int(row["rule_score"])
row["pipeline_score"] = int(row["pipeline_score"])
return rows


def _rate(rows: list[dict], key: str) -> float:
return sum(row[key] for row in rows) / len(rows) * 100 if rows else 0.0


def _detection_row(label: str, rows: list[dict]) -> str:
return (
f"| {label} | {len(rows)} | {_rate(rows, 'rule_detected'):.1f}% "
f"| {_rate(rows, 'pipeline_detected'):.1f}% |"
)


def build_summary(rows: list[dict]) -> str:
originals = [r for r in rows if r["mutation_type"] == ORIGINAL_LABEL]
mutated = [r for r in rows if r["mutation_type"] != ORIGINAL_LABEL]

lines = [
"# SafeFam 사기문자 변형 공격 테스트 결과",
"",
"탐지 성공 기준: 규칙엔진 rule_score >= 40 / 파이프라인 risk_grade != LOW",
"",
"## 1. 원본 vs 변형 전체 탐지율",
"",
"| 구분 | 건수 | 규칙엔진 단독 탐지율 | 전체 파이프라인 탐지율 |",
"| --- | ---: | ---: | ---: |",
_detection_row("원본 (ORIGINAL)", originals),
_detection_row("변형 전체", mutated),
"",
"## 2. 변형 유형별 탐지율 (규칙엔진 탐지율 낮은 순)",
"",
"| 변형 유형 | 건수 | 규칙엔진 탐지율 | 파이프라인 탐지율 | 규칙 평균점수 | 파이프라인 평균점수 |",
"| --- | ---: | ---: | ---: | ---: | ---: |",
]

by_mutation: dict[str, list[dict]] = {}
for row in mutated:
by_mutation.setdefault(row["mutation_type"], []).append(row)

for name, group in sorted(by_mutation.items(), key=lambda item: _rate(item[1], "rule_detected")):
rule_avg = sum(r["rule_score"] for r in group) / len(group)
pipe_avg = sum(r["pipeline_score"] for r in group) / len(group)
lines.append(
f"| {name} | {len(group)} | {_rate(group, 'rule_detected'):.1f}% "
f"| {_rate(group, 'pipeline_detected'):.1f}% | {rule_avg:.1f} | {pipe_avg:.1f} |"
)

# 데모 핵심: 규칙엔진은 뚫렸지만 문맥분석 포함 파이프라인은 여전히 잡아낸 사례
saved = [r for r in mutated if not r["rule_detected"] and r["pipeline_detected"]]
saved_rate = len(saved) / len(mutated) * 100 if mutated else 0.0
lines += [
"",
"## 3. 규칙엔진 실패 -> 파이프라인 방어 성공 사례",
"",
f"변형 {len(mutated)}건 중 {len(saved)}건 ({saved_rate:.1f}%)이 "
"규칙엔진을 우회했으나 파이프라인이 탐지했습니다.",
"",
"| 샘플 ID | 변형 유형 | 규칙점수 | 파이프라인 점수/등급 | 변형문 발췌 |",
"| --- | --- | ---: | --- | --- |",
]
for row in saved:
excerpt = row["text"][:EXCERPT_LENGTH].replace("\n", " ").replace("|", "/")
lines.append(
f"| {row['sample_id']} | {row['mutation_type']} | {row['rule_score']} "
f"| {row['pipeline_score']} ({row['pipeline_grade']}) | {excerpt}... |"
)

return "\n".join(lines) + "\n"


def main() -> None:
parser = argparse.ArgumentParser(description="변형 공격 평가 결과 집계")
parser.add_argument("--results", type=Path, default=OUTPUT_DIR / "results.csv")
parser.add_argument("--out", type=Path, default=OUTPUT_DIR / "summary.md")
args = parser.parse_args()

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

rows = read_results(args.results)
summary = build_summary(rows)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(summary, encoding="utf-8")
logger.info("[Aggregate] %d건 집계 -> %s", len(rows), args.out)


if __name__ == "__main__":
main()
133 changes: 133 additions & 0 deletions scripts/adversarial_test/corpus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import argparse
import csv
import json
import logging
import random
import re
from pathlib import Path

from app.analysis.rules.analyzer import analyze_text_with_rules

logger = logging.getLogger(__name__)

DEFAULT_CSV_PATH = Path("data_science/Data/SMSData/phishing_total_dataset_2705.csv")
OUTPUT_DIR = Path(__file__).parent / "output"
DEFAULT_OUTPUT_PATH = OUTPUT_DIR / "corpus.json"

_RE_NON_SLUG = re.compile(r"[^0-9a-zA-Z가-힣]+")


# 파일명/ID로 쓸 수 있게 카테고리 한글명을 정규화 (한글은 그대로 두고 공백/기호만 제거)
def _slugify(value: str) -> str:
return _RE_NON_SLUG.sub("_", value).strip("_") or "unknown"


# "금융기관사칭=25,정부공공기관사칭=10" 형태를 카테고리별 목표 건수 dict로 변환
def parse_category_overrides(raw: str | None) -> dict[str, int] | None:
if not raw:
return None
overrides: dict[str, int] = {}
for pair in raw.split(","):
name, _, count = pair.partition("=")
overrides[name.strip()] = int(count)
return overrides


# 사기문자만 골라 카테고리별 샘플링. category_overrides에 없는 카테고리는 per_type을 기본값으로 사용 —
# 특정 시나리오(예: 금융기관사칭)를 집중 검증하면서도 다른 유형과의 비교 기준선은 남겨둔다
def select_corpus(
csv_path: Path,
per_type: int = 6,
seed: int = 42,
category_overrides: dict[str, int] | None = None,
keyword_filter: list[str] | None = None,
min_rule_score: int | None = None,
) -> list[dict]:
by_type: dict[str, list[dict]] = {}

with csv_path.open(encoding="utf-8-sig", newline="") as f:
for row in csv.DictReader(f):
if row.get("label") != "phishing":
continue
sms_type = (row.get("type") or "").strip()
text = (row.get("text") or "").strip()
if not sms_type or not text:
continue
# 키워드 필터: "계좌 정지형" 등 특정 사기 시나리오만 골라 코퍼스를 좁힐 때 사용
if keyword_filter and not any(kw in text for kw in keyword_filter):
continue
# min_rule_score: 변형 실험의 "원본은 규칙엔진이 잡는다"는 전제를 보장하려면 느슨한
# 키워드 매칭보다 실제 규칙엔진 점수로 직접 거르는 편이 정확하다
if min_rule_score is not None and analyze_text_with_rules(text)["rule_score"] < min_rule_score:
continue
by_type.setdefault(sms_type, []).append(
{"text": text, "has_url": (row.get("has_url") or "").strip().lower() == "true"}
)

rng = random.Random(seed)
corpus: list[dict] = []
overrides = category_overrides or {}

for sms_type in sorted(by_type):
rows = by_type[sms_type]
target = overrides.get(sms_type, per_type)
sampled = rng.sample(rows, min(target, len(rows)))
slug = _slugify(sms_type)
for idx, row in enumerate(sampled):
corpus.append(
{
"id": f"{slug}_{idx}",
"text": row["text"],
"type": sms_type,
"has_url": row["has_url"],
}
)
logger.info("[Corpus] %s: %d개 샘플링 (전체 %d개)", sms_type, len(sampled), len(rows))

return corpus


def write_corpus(corpus: list[dict], out_path: Path) -> None:
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(corpus, ensure_ascii=False, indent=2), encoding="utf-8")
logger.info("[Corpus] %d건 저장 -> %s", len(corpus), out_path)


def main() -> None:
parser = argparse.ArgumentParser(description="사기문자 코퍼스 생성")
parser.add_argument("--csv", type=Path, default=DEFAULT_CSV_PATH)
parser.add_argument("--per-type", type=int, default=6)
parser.add_argument(
"--category-overrides",
type=str,
default=None,
help="예: 금융기관사칭=25,정부공공기관사칭=10",
)
parser.add_argument(
"--keywords",
type=str,
default=None,
help="쉼표 구분 키워드. 지정 시 텍스트에 하나라도 포함된 행만 사용 (예: 계좌,이체,정지,동결)",
)
parser.add_argument(
"--min-rule-score",
type=int,
default=None,
help="지정 시 규칙엔진 점수가 이 값 이상인 원문만 사용 (원본은 규칙엔진이 이미 탐지하는 케이스로 한정)",
)
parser.add_argument("--out", type=Path, default=DEFAULT_OUTPUT_PATH)
args = parser.parse_args()

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
corpus = select_corpus(
args.csv,
per_type=args.per_type,
category_overrides=parse_category_overrides(args.category_overrides),
keyword_filter=args.keywords.split(",") if args.keywords else None,
min_rule_score=args.min_rule_score,
)
write_corpus(corpus, args.out)


if __name__ == "__main__":
main()
124 changes: 124 additions & 0 deletions scripts/adversarial_test/daily_batch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import argparse
import asyncio
import csv
import json
import logging
from dataclasses import asdict
from pathlib import Path

from app.analysis.service import SmishingAnalysisService
from scripts.adversarial_test.evaluate import ORIGINAL_LABEL, EvalResult, _evaluate_one
from scripts.adversarial_test.mutations import MUTATIONS

logger = logging.getLogger(__name__)

OUTPUT_DIR = Path(__file__).parent / "output"
DEFAULT_CORPUS_PATH = OUTPUT_DIR / "corpus.json"
DEFAULT_RESULTS_PATH = OUTPUT_DIR / "results.csv"

# LLM 기반 변형은 변형 생성(1콜) + 파이프라인 평가(1콜) = 2콜, 그 외는 파이프라인 평가만 1콜.
# Gemini 무료 티어 일일 한도(모델당 20건)를 며칠에 걸쳐 나눠 쓰기 위한 하루치 예산 산정에 사용.
_GEMINI_MUTATION_NAMES = {"urgency_softening", "tone_normalization", "shortening", "phone_call_redirect"}


def _estimate_cost(mutation_type: str) -> int:
if mutation_type == ORIGINAL_LABEL:
return 1
return 2 if mutation_type in _GEMINI_MUTATION_NAMES else 1


def _load_done(results_path: Path) -> set[tuple[str, str]]:
if not results_path.exists():
return set()
with results_path.open(encoding="utf-8", newline="") as f:
return {(row["sample_id"], row["mutation_type"]) for row in csv.DictReader(f)}


def _append_result(result: EvalResult, out_path: Path) -> None:
is_new = not out_path.exists()
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("a", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(EvalResult.__dataclass_fields__))
if is_new:
writer.writeheader()
writer.writerow(asdict(result))
Comment on lines +37 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Write the header when the file exists but is empty.

is_new is False for a zero-byte results.csv. A crash or an external touch between creation and the first write leaves such a file. _load_done and read_results in scripts/adversarial_test/aggregate.py then read the first data row as the header, and every field lookup fails.

Base the decision on the file size.

🛡️ Proposed fix
 def _append_result(result: EvalResult, out_path: Path) -> None:
-    is_new = not out_path.exists()
     out_path.parent.mkdir(parents=True, exist_ok=True)
+    is_new = not out_path.exists() or out_path.stat().st_size == 0
     with out_path.open("a", encoding="utf-8", newline="") as f:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _append_result(result: EvalResult, out_path: Path) -> None:
is_new = not out_path.exists()
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("a", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(EvalResult.__dataclass_fields__))
if is_new:
writer.writeheader()
writer.writerow(asdict(result))
def _append_result(result: EvalResult, out_path: Path) -> None:
out_path.parent.mkdir(parents=True, exist_ok=True)
is_new = not out_path.exists() or out_path.stat().st_size == 0
with out_path.open("a", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=list(EvalResult.__dataclass_fields__))
if is_new:
writer.writeheader()
writer.writerow(asdict(result))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/adversarial_test/daily_batch.py` around lines 37 - 44, Update
_append_result to determine header writing from the existing output file’s size,
treating a missing or zero-byte file as requiring a header; preserve append
behavior and write the header before the first data row for non-empty
results.csv files.



# 이미 results.csv에 성공 기록된 (샘플, 변형) 조합은 건너뛰고, 남은 대기열에서 예산이 허용하는 만큼만
# 오늘치로 처리한다 — 프로세스가 중간에 죽어도(429 등) append 방식이라 진행분은 보존됨
async def run_daily_batch(
corpus: list[dict],
results_path: Path,
budget: int,
mutation_names: list[str] | None = None,
) -> list[EvalResult]:
names = mutation_names if mutation_names is not None else list(MUTATIONS)
done = _load_done(results_path)

queue = [
(sample, mutation_type)
for sample in corpus
for mutation_type in [ORIGINAL_LABEL, *names]
if (sample["id"], mutation_type) not in done
]

if not queue:
logger.info("[DailyBatch] 남은 작업 없음 - 전체 매트릭스 완료")
return []

selected: list[tuple[dict, str]] = []
spent = 0
for sample, mutation_type in queue:
cost = _estimate_cost(mutation_type)
if spent + cost > budget:
break
selected.append((sample, mutation_type))
spent += cost

logger.info(
"[DailyBatch] 오늘 처리 대상: %d건 (예상 호출 %d/%d), 이후 남는 대기열 %d건",
len(selected),
spent,
budget,
len(queue) - len(selected),
)

service = SmishingAnalysisService()
semaphore = asyncio.Semaphore(1)

results: list[EvalResult] = []
for sample, mutation_type in selected:
result = await _evaluate_one(service, sample, mutation_type, semaphore)
if result is not None:
results.append(result)
_append_result(result, results_path)

logger.info("[DailyBatch] 이번 배치 결과 %d/%d건 성공 저장", len(results), len(selected))
return results


def main() -> None:
parser = argparse.ArgumentParser(
description="Gemini 일일 무료 할당량 내에서 변형 평가 매트릭스를 하루치씩 이어서 채운다"
)
parser.add_argument("--corpus", type=Path, default=DEFAULT_CORPUS_PATH)
parser.add_argument("--results", type=Path, default=DEFAULT_RESULTS_PATH)
parser.add_argument(
"--budget",
type=int,
default=18,
help="오늘 소비할 예상 Gemini 호출 수 상한 (일일 한도 20보다 낮게 잡아 여유분 확보 권장)",
)
parser.add_argument("--mutations", type=str, default=None, help="쉼표 구분 변형 이름 (미지정 시 전체 10종)")
args = parser.parse_args()

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

corpus = json.loads(args.corpus.read_text(encoding="utf-8"))
names = args.mutations.split(",") if args.mutations else None

asyncio.run(run_daily_batch(corpus, args.results, args.budget, names))


if __name__ == "__main__":
main()
Loading
Loading