Skip to content

Commit 669f55c

Browse files
committed
Release v1.2.2 incident reconstruction
1 parent 391b168 commit 669f55c

15 files changed

Lines changed: 790 additions & 71 deletions

File tree

README.md

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -46,32 +46,19 @@ BLA 的结果分成两类:给人看的应急判断,和给系统继续处理
4646

4747
默认 `--out report/` 会落地 `index.html``report.json``events.csv``iocs.txt``report.sarif`,人能看,脚本也能继续处理。
4848

49-
## v1.2.1 Update
49+
## v1.2.2 Update
5050

51-
v1.2.1 先补实战里最容易误导人的两个点:EVTX 缺依赖时不再生成“看似完成”的空报告,Windows Security 事件会先区分初始化噪音、字段不完整和真正敏感的账号/加组行为
51+
v1.2.2 把 Windows Security 分析从“高危告警列表”推进到“应急案件还原”:围绕账号、操作者、来源 IP、工作站和目标资产做通用关联
5252

5353
| 能力 | 说明 |
5454
| --- | --- |
55-
| EVTX 依赖阻断 | 未安装 `python-evtx` 时明确停止 EVTX 解析,并给出安装或 `wevtutil` 转 XML 路径 |
56-
| Windows 误报压降 | `WDAGUtilityAccount``Users``IIS_IUSRS``None` 等初始化/低风险场景不再直接打成高危 |
57-
| 字段级证据 | 账户创建和加组事件补充操作者、目标账户、目标组、成员 SID 与证据强度 |
58-
| 凭据判断收敛 | 本机 `localhost` / 机器账号显式凭据使用不再直接定性为 Pass-the-Hash 横向移动 |
55+
| Windows 账号链路 | 通用识别 `4720/4722/4724/4732 + 4624 Type 10/3`:新建账户、启用/改密、加入特权组后短时间内远程登录 |
56+
| 案件核心实体 | Incident 优先展示核心账号、操作者、来源 IP、来源工作站、资产和阶段,减少“未知来源/其他” |
57+
| 攻击路径还原 | 终端和 HTML 报告把关键事件按时间串起来,便于直接进入应急复盘或工单 |
58+
| ATT&CK 降噪 | 普通 `4688` 进程创建不再默认映射为 `T1059`,仅高危命令/LOLBins 才升级为执行阶段 |
59+
| 报告细节修正 | Top IP 过滤空值/本机来源;有时区日志默认按 UTC+8 展示并保留原始 UTC;内置 YAML 正则解析不再输出 escape warning |
5960

60-
更多变更见 [v1.2.1 发布说明](docs/releases/v1.2.1.md)
61-
62-
## v1.2.0 Update
63-
64-
v1.2.0 重点更新项目架构和扩展接口,为后续 Remote Collector、Host Triage、厂商日志适配和本地工作台打基础。
65-
66-
| 能力 | 说明 |
67-
| --- | --- |
68-
| Parser Registry | 新日志源通过注册接入,不再继续扩大中心路由 |
69-
| Detector Registry | 新检测器通过注册组合,不再把所有规则塞进一个引擎文件 |
70-
| `parse_content()` | 给 Remote Collector、内存日志输入和未来 UI/服务层预留统一入口 |
71-
| `--type` | 自动识别不准时可强制指定 `web-access``linux-auth``p0-security` 等解析器 |
72-
| 单次 enrichment | 解析、富化、检测、关联的职责边界更清楚,便于维护和性能基准 |
73-
74-
更多设计边界见 [架构说明](docs/architecture.md),v1.2.0 变更见 [发布说明](docs/releases/v1.2.0.md)
61+
更多变更见 [v1.2.2 发布说明](docs/releases/v1.2.2.md),历史版本见 [docs/releases](docs/releases/)
7562

7663
## 核心能力
7764

@@ -381,7 +368,7 @@ fi
381368
```
382369
╔══════════════════════════════════════════════════════════════════════════════╗
383370
║ BlueTeam Log Analyzer (BLA) - Blue Team Incident Response ║
384-
║ Version 1.2.1 | 100% Offline | No AI ║
371+
║ Version 1.2.2 | 100% Offline | No AI ║
385372
╚══════════════════════════════════════════════════════════════════════════════╝
386373
387374
📊 分析总览

bla/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Single source of truth for the BLA package version."""
22

3-
__version__ = "1.2.1"
3+
__version__ = "1.2.2"

bla/detection/correlation.py

Lines changed: 169 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
"initial-access": "初始访问",
2121
"identity": "身份突破",
2222
"execution": "执行",
23+
"persistence": "持久化",
24+
"privilege-escalation": "权限提升",
2325
"compromise": "主机失陷",
26+
"remote-access": "远程访问",
2427
"lateral-movement": "横向移动",
2528
"command-control": "命令控制",
2629
"exfiltration": "数据外传",
@@ -36,7 +39,10 @@
3639
"初始访问",
3740
"身份突破",
3841
"执行",
42+
"持久化",
43+
"权限提升",
3944
"主机失陷",
45+
"远程访问",
4046
"横向移动",
4147
"命令控制",
4248
"数据外传",
@@ -96,6 +102,7 @@ def correlate_incidents(events: Sequence[LogEvent], alerts: Sequence[DetectionAl
96102

97103
incidents.sort(key=lambda item: (_LEVEL_ORDER[item.level], len(item.source_types), len(item.affected_events)), reverse=True)
98104
incidents = _drop_subset_incidents(incidents)
105+
incidents = [incident for incident in incidents if not _is_low_value_windows_standalone_incident(incident)]
99106
for idx, incident in enumerate(incidents, 1):
100107
incident.id = f"inc-{idx:03d}"
101108
return incidents[:50]
@@ -109,8 +116,13 @@ def _correlation_keys(events: Iterable[LogEvent]) -> List[Tuple[str, str]]:
109116
candidates = [
110117
("session", event.details.get("session_id", "")),
111118
("trace", event.details.get("trace_id", "")),
112-
("ip", event.details.get("src_ip", "") or event.ip or ""),
113-
("account", event.details.get("account", "") or event.user or ""),
119+
("ip", event.details.get("src_ip", "") or event.details.get("source_ip", "") or event.ip or ""),
120+
("workstation", event.details.get("workstation", "")),
121+
("account", event.details.get("account", "") or event.details.get("target_account", "") or event.user or ""),
122+
("account", event.details.get("account_name", "")),
123+
("account", event.details.get("member_account", "")),
124+
("sid", event.details.get("target_sid", "")),
125+
("sid", event.details.get("member_sid", "")),
114126
("asset", event.details.get("asset", "") or event.host or ""),
115127
]
116128
for kind, value in candidates:
@@ -173,14 +185,26 @@ def _is_duplicate_incident(candidate: Incident, existing: Incident) -> bool:
173185
)
174186

175187

188+
def _is_low_value_windows_standalone_incident(incident: Incident) -> bool:
189+
return (
190+
incident.source_types == ["windows-event"]
191+
and incident.confidence == "low"
192+
and len(incident.affected_events) <= 1
193+
and not incident.source_ips
194+
)
195+
196+
176197
def _build_incident(index: int, events: Sequence[LogEvent], alerts: Sequence[DetectionAlert]) -> Incident:
198+
events, alerts = _focus_windows_chain_scope(events, alerts)
177199
max_level = _max_level(
178200
[event.level for event in events] + [alert.level for alert in alerts],
179201
default=ThreatLevel.INFO,
180202
)
181-
source_ips = _sorted_values(event.details.get("src_ip") or event.ip for event in events)
203+
source_ips = _sorted_values(event.details.get("src_ip") or event.details.get("source_ip") or event.ip for event in events)
182204
accounts = _sorted_values(event.details.get("account") or event.user for event in events)
183205
assets = _sorted_values(event.details.get("asset") or event.host for event in events)
206+
operators = _sorted_values(event.details.get("operator_account") or event.details.get("subject_account") for event in events)
207+
workstations = _sorted_values(event.details.get("source_workstation") or event.details.get("workstation") for event in events)
184208
source_types = _sorted_values(event.details.get("source_type") for event in events)
185209
families = _sorted_values(event.details.get("event_family") for event in events)
186210
raw_phases = [_FAMILY_PHASE.get(family, family) for family in families]
@@ -190,10 +214,10 @@ def _build_incident(index: int, events: Sequence[LogEvent], alerts: Sequence[Det
190214
key=lambda phase: (_PHASE_INDEX.get(phase, len(_PHASE_INDEX)), phase),
191215
)
192216
confidence = _confidence(events, alerts, source_types, families)
193-
title = _title(source_ips, assets, source_types, phases, max_level)
194-
description = _description(source_ips, accounts, assets, source_types, phases, alerts, events)
217+
title = _title(source_ips, accounts, assets, source_types, phases, max_level, alerts)
218+
description = _description(source_ips, accounts, assets, operators, workstations, source_types, phases, alerts, events)
195219
timeline = _timeline(events)
196-
evidence = _evidence(events, alerts, source_types, phases)
220+
evidence = _evidence(events, alerts, source_ips, accounts, assets, operators, workstations, source_types, phases)
197221

198222
return Incident(
199223
id=f"inc-{index:03d}",
@@ -223,12 +247,112 @@ def _sorted_values(values: Iterable[object]) -> List[str]:
223247
return sorted({str(value) for value in values if value not in (None, "", "-", "null", "None")})
224248

225249

250+
def _focus_windows_chain_scope(
251+
events: Sequence[LogEvent],
252+
alerts: Sequence[DetectionAlert],
253+
) -> Tuple[List[LogEvent], List[DetectionAlert]]:
254+
chain_alerts = [alert for alert in alerts if alert.rule_id == "WIN-CHAIN-001"]
255+
if not chain_alerts:
256+
return list(events), list(alerts)
257+
258+
chain_event_ids = {event_id for alert in chain_alerts for event_id in alert.affected_events}
259+
chain_events = [event for event in events if event.id in chain_event_ids]
260+
if not chain_events:
261+
return list(events), list(alerts)
262+
263+
account_keys = {
264+
_account_key(value)
265+
for event in chain_events
266+
for value in (
267+
event.details.get("account"),
268+
event.details.get("target_account"),
269+
event.details.get("account_name"),
270+
)
271+
if _account_key(value)
272+
}
273+
sids = {
274+
str(value).strip().lower()
275+
for event in chain_events
276+
for value in (event.details.get("target_sid"), event.details.get("member_sid"))
277+
if value not in (None, "", "-")
278+
}
279+
focused = [
280+
event for event in events
281+
if event.id in chain_event_ids
282+
or _event_matches_account(event, account_keys, sids)
283+
]
284+
focused_ids = {event.id for event in focused}
285+
focused_alerts = [
286+
alert for alert in alerts
287+
if alert.rule_id == "WIN-CHAIN-001" or set(alert.affected_events) & focused_ids
288+
]
289+
return focused, focused_alerts
290+
291+
292+
def _event_matches_account(event: LogEvent, account_keys: Set[str], sids: Set[str]) -> bool:
293+
if not account_keys and not sids:
294+
return False
295+
event_keys = {
296+
_account_key(value)
297+
for value in (
298+
event.details.get("account"),
299+
event.details.get("target_account"),
300+
event.details.get("target_user"),
301+
event.details.get("member_account"),
302+
event.details.get("member_name"),
303+
event.details.get("account_name"),
304+
)
305+
if _account_key(value)
306+
}
307+
if event_keys & account_keys:
308+
return True
309+
event_sids = {
310+
str(value).strip().lower()
311+
for value in (event.details.get("target_sid"), event.details.get("member_sid"))
312+
if value not in (None, "", "-")
313+
}
314+
return bool(event_sids & sids)
315+
316+
317+
def _account_key(value: object) -> str:
318+
text = str(value or "").strip().strip("\\/")
319+
if "\\" in text:
320+
text = text.rsplit("\\", 1)[-1]
321+
if "/" in text:
322+
text = text.rsplit("/", 1)[-1]
323+
return text.lower()
324+
325+
326+
def _first_human(values: Sequence[str]) -> str:
327+
for value in values:
328+
if not value.startswith("S-1-"):
329+
return value
330+
return values[0] if values else ""
331+
332+
333+
def _human_accounts(values: Sequence[str]) -> List[str]:
334+
human = [value for value in values if not value.startswith("S-1-")]
335+
return human or list(values)
336+
337+
338+
def _chain_account(alerts: Sequence[DetectionAlert]) -> str:
339+
for alert in alerts:
340+
if alert.rule_id != "WIN-CHAIN-001":
341+
continue
342+
for item in alert.evidence:
343+
if item.startswith("目标账户:"):
344+
return item.split(":", 1)[1].strip()
345+
return ""
346+
347+
226348
def _confidence(
227349
events: Sequence[LogEvent],
228350
alerts: Sequence[DetectionAlert],
229351
source_types: Sequence[str],
230352
families: Sequence[str],
231353
) -> str:
354+
if any(alert.rule_id == "WIN-CHAIN-001" for alert in alerts):
355+
return "high"
232356
if len(source_types) >= 3 or (len(source_types) >= 2 and len(families) >= 3):
233357
return "high"
234358
if len(source_types) >= 2 or len(alerts) >= 2 or len(events) >= 5:
@@ -238,11 +362,16 @@ def _confidence(
238362

239363
def _title(
240364
source_ips: Sequence[str],
365+
accounts: Sequence[str],
241366
assets: Sequence[str],
242367
source_types: Sequence[str],
243368
phases: Sequence[str],
244369
level: ThreatLevel,
370+
alerts: Sequence[DetectionAlert],
245371
) -> str:
372+
if any(alert.rule_id == "WIN-CHAIN-001" for alert in alerts):
373+
account = _chain_account(alerts) or _first_human(accounts)
374+
return f"可疑本地管理员账号与远程登录: {account}" if account else "可疑本地管理员账号与远程登录"
246375
subject = source_ips[0] if source_ips else (assets[0] if assets else "未知实体")
247376
if len(source_types) >= 2:
248377
return f"P0 多源关联案件: {subject}"
@@ -255,12 +384,23 @@ def _description(
255384
source_ips: Sequence[str],
256385
accounts: Sequence[str],
257386
assets: Sequence[str],
387+
operators: Sequence[str],
388+
workstations: Sequence[str],
258389
source_types: Sequence[str],
259390
phases: Sequence[str],
260391
alerts: Sequence[DetectionAlert],
261392
events: Sequence[LogEvent],
262393
) -> str:
263-
subject = source_ips[0] if source_ips else "未知来源"
394+
subject = source_ips[0] if source_ips else (workstations[0] if workstations else "未知来源")
395+
if any(alert.rule_id == "WIN-CHAIN-001" for alert in alerts):
396+
return (
397+
f"{subject} 关联到 Windows 账号创建、特权组加入和远程登录链路;"
398+
f"核心账号: {_chain_account(alerts) or _first_human(accounts) or '?'};"
399+
f"操作者: {', '.join(operators[:3]) or '?'};"
400+
f"来源工作站: {', '.join(workstations[:3]) or '?'};"
401+
f"资产: {', '.join(assets[:5]) or '?'};"
402+
f"阶段: {', '.join(phases[:6]) or '未分类'}。"
403+
)
264404
return (
265405
f"{subject}{len(source_types) or 1} 类日志源中关联到 "
266406
f"{len(alerts)} 个告警、{len(events)} 条关键事件;"
@@ -289,15 +429,31 @@ def _timeline(events: Sequence[LogEvent]) -> List[TimelineEntry]:
289429
def _evidence(
290430
events: Sequence[LogEvent],
291431
alerts: Sequence[DetectionAlert],
432+
source_ips: Sequence[str],
433+
accounts: Sequence[str],
434+
assets: Sequence[str],
435+
operators: Sequence[str],
436+
workstations: Sequence[str],
292437
source_types: Sequence[str],
293438
phases: Sequence[str],
294439
) -> List[str]:
295-
evidence = [
440+
evidence = []
441+
if accounts:
442+
evidence.append(f"核心账号: {', '.join(_human_accounts(accounts)[:5])}")
443+
if operators:
444+
evidence.append(f"操作者: {', '.join(operators[:5])}")
445+
if source_ips:
446+
evidence.append(f"来源IP: {', '.join(source_ips[:5])}")
447+
if workstations:
448+
evidence.append(f"来源工作站: {', '.join(workstations[:5])}")
449+
if assets:
450+
evidence.append(f"资产: {', '.join(assets[:5])}")
451+
evidence.extend([
296452
f"日志源: {', '.join(source_types) or '?'}",
297453
f"攻击阶段: {', '.join(phases) or '?'}",
298454
f"关联告警: {len(alerts)} 个",
299455
f"关键事件: {len(events)} 条",
300-
]
456+
])
301457
for alert in alerts[:3]:
302458
evidence.append(f"{alert.rule_id}: {alert.rule_name} ({alert.level.label})")
303459
for event in sorted(events, key=lambda item: item.timestamp or "")[:3]:
@@ -314,6 +470,8 @@ def _recommended_actions(source_types: Sequence[str], families: Sequence[str], l
314470
actions.append("核查入口 URL、漏洞命中规则和应用同时间窗口异常,确认是否利用成功。")
315471
if "identity" in families or "vpn" in source_types:
316472
actions.append("核查账号登录源、MFA 状态和登录后操作,必要时冻结账号并重置凭据。")
473+
if "persistence" in families or "privilege-escalation" in families or "remote-access" in families:
474+
actions.append("禁用可疑新建账号,移出特权组,核查 RDP/NTLM 来源工作站和同时间窗口管理员操作。")
317475
if "command-control" in families or "proxy" in source_types or "dns" in source_types:
318476
actions.append("封禁恶意域名/IP,回溯 DNS、代理和防火墙外联链路。")
319477
if "exfiltration" in families:
@@ -329,6 +487,8 @@ def _next_logs(source_types: Sequence[str], families: Sequence[str]) -> List[str
329487
wanted.extend(["Web access/error 日志", "业务应用日志", "WAF 原始命中详情"])
330488
if "identity" in families:
331489
wanted.extend(["VPN/SSO/MFA 审计", "AD/域控 Security 日志", "堡垒机会话审计"])
490+
if "persistence" in families or "privilege-escalation" in families or "remote-access" in families:
491+
wanted.extend(["Windows Security 4720/4722/4724/4732/4624/4776", "TerminalServices RDP 会话日志", "EDR/XDR 进程树", "防火墙/NAT 会话日志"])
332492
if "compromise" in families or "execution" in families:
333493
wanted.extend(["EDR/XDR 进程树", "Windows Sysmon", "Linux auditd/auth.log"])
334494
if "command-control" in families or "network" in families:

0 commit comments

Comments
 (0)