在威胁情报领域,TTP是Tactics、Techniques、Procedures的缩写,分别对应攻击者的战术目标、技术手段和具体程序流程。安全团队在分析一次入侵事件时,往往需要快速定位攻击行为对应的ATT&CK编号、相关检测建议和历史情报。手动翻阅MITRE ATT&CK官网效率低下,而自建一套检索系统可以把这件事变成几秒钟的查询。本文将基于Node.js完整实现一个TTP Search服务,从数据获取到接口设计逐一展开。

一、整体架构与数据源选择
实现TTP检索的第一步是确定数据从哪来。目前业界最权威的数据源是MITRE官方维护的STIX/JSON格式的ATT&CK知识库,仓库地址为mitre-attack/attack-stix-data,其中包含企业域、移动域和ICS域的全部对象数据。STIX对象中的attack-pattern类型对应技术条目,x_mitre_tactic字段对应战术分类,这些字段结构稳定、更新频繁,非常适合作为检索底座。
整体架构上建议分为三层:数据接入层负责拉取和解析STIX文件,索引层负责把结构化数据写入全文搜索引擎,服务层对外提供REST查询接口。索引引擎可以选择Elasticsearch,它对多字段检索、分词和高亮的支持开箱即用;如果数据规模要求不高,也可以直接用内存中的倒排索引实现,本文两种方案都会涉及。
项目初始化很简单,创建目录后安装依赖即可:
mkdir ttp-search && cd ttp-search npm init -y npm install express axios elasticsearch mitre-attack-lib
依赖说明:express负责HTTP服务,axios用于下载STIX数据,elasticsearch是官方Node客户端,mitre-attack-lib提供了一些便捷的ATT&CK数据加载方法,如果希望自己解析原始STIX文件,这个包也可以省略。
二、数据导入与STIX解析
MITRE提供的STIX数据是标准的JSON Bundle,每个Bundle的objects数组中包含多种类型的对象。我们重点关注attack-pattern(技术)、x-mitre-tactic(战术)和intrusion-set(威胁组织)。解析时需要把STIX的外部引用中的ATT&CK编号提取出来,例如T1059对应的编号存放在external_references数组里。
下面是核心的解析代码,它把STIX对象转换成扁平化的检索文档:
const axios = require('axios');
const ENTERPRISE_URL = 'https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack.json';
async function fetchAndParse() {
const res = await axios.get(ENTERPRISE_URL, { timeout: 60000 });
const objects = res.data.objects;
const docs = [];
for (const obj of objects) {
if (obj.type !== 'attack-pattern') continue;
// 提取 ATT&CK 编号,如 T1059
const extRef = (obj.external_references || []).find(
r => r.source_name === 'mitre-attack'
);
if (!extRef || !extRef.external_id) continue;
docs.push({
attackId: extRef.external_id,
name: obj.name,
description: obj.description || '',
tactics: (obj.kill_chain_phases || []).map(p => p.phase_name),
platforms: obj.x_mitre_platforms || [],
detection: obj.x_mitre_detection || '',
revoked: obj.revoked === true,
deprecated: obj.x_mitre_deprecated === true
});
}
return docs;
}
module.exports = { fetchAndParse };这段代码有几个细节值得注意。第一,revoked和deprecated字段必须保留,很多技术条目已被撤销或替换,检索结果如果不做过滤会给分析人员带来误导。第二,kill_chain_phases中的phase_name就是战术短名称,例如credential-access、persistence,这为后续按战术筛选提供了便利。第三,description字段中包含大量Markdown链接标记,写入索引前可以做一次正则清理,减少索引噪音。
三、构建检索服务与查询接口
数据解析完成后,接下来把它写入Elasticsearch并暴露查询接口。先创建索引并定义映射,name字段设置成text类型并附加keyword子字段,这样既能全文检索又能精确聚合:
const { Client } = require('elasticsearch');
const esClient = new Client({ node: 'http://127.0.0.1:9200' });
async function createIndex() {
await esClient.indices.delete({ index: 'ttp', ignore: [404] });
await esClient.indices.create({
index: 'ttp',
body: {
mappings: {
properties: {
attackId: { type: 'keyword' },
name: { type: 'text', fields: { keyword: { type: 'keyword' } } },
description:{ type: 'text' },
tactics: { type: 'keyword' },
platforms: { type: 'keyword' },
detection: { type: 'text' },
revoked: { type: 'boolean' },
deprecated: { type: 'boolean' }
}
}
}
});
}查询接口的设计要兼顾精确匹配和模糊搜索。用户输入T1059这类编号时应走term查询,输入command line scripting这类自然语言时应走multi_match查询。Express路由实现如下:
const express = require('express');
const app = express();
app.use(express.json());
app.get('/api/ttp/search', async (req, res) => {
const { q, tactic, platform, page = 1, size = 20 } = req.query;
if (!q) return res.status(400).json({ error: '缺少查询参数 q' });
const isAttackId = /^T\d{4}(\.\d{3})?$/i.test(q.trim());
const body = {
query: {
bool: {
must: isAttackId
? { term: { attackId: q.trim().toUpperCase() } }
: {
multi_match: {
query: q,
fields: ['name^3', 'description', 'detection'],
fuzziness: 'AUTO'
}
},
filter: [
{ term: { revoked: false } },
{ term: { deprecated: false } }
]
}
},
from: (page - 1) * size,
size,
highlight: {
fields: { description: {} }
}
};
if (tactic) body.query.bool.filter.push({ term: { tactics: tactic } });
if (platform) body.query.bool.filter.push({ term: { platforms: platform } });
const result = await esClient.search({ index: 'ttp', body });
res.json({
total: result.hits.total.value,
hits: result.hits.hits.map(h => ({
score: h._score,
source: h._source,
highlight: h.highlight
}))
});
});
app.listen(3000, () => console.log('TTP Search 服务已启动: http://127.0.0.1:3000'));这里有几个查询层面的技巧。name^3表示名称字段的权重提升三倍,因为名称匹配通常比描述匹配更相关。fuzziness: 'AUTO'开启了自动模糊匹配,能容忍拼写错误,比如输入powershel也能匹配到PowerShell相关的技术条目。filter子句中的revoked和deprecated过滤放在filter上下文中,既不参与评分又能利用Elasticsearch的缓存机制,性能优于must。
四、轻量方案:不依赖Elasticsearch的内存索引
如果部署环境资源有限,或者只是在内网做一个小工具,完全可以不引入Elasticsearch。ATT&CK企业域的技术条目目前只有六百多条,全部加载进内存做简单的文本匹配绰绰有余。实现思路是把每个文档的名称和描述拼接成一个可搜索字符串,用includes或简单的分词打分来排序:
function buildMemoryIndex(docs) {
return docs.map(d => ({
...d,
searchText: `${d.attackId} ${d.name} ${d.description}`.toLowerCase()
}));
}
function searchInMemory(index, keyword) {
const kw = keyword.toLowerCase().trim();
return index
.filter(d => !d.revoked && !d.deprecated)
.filter(d => d.searchText.includes(kw))
.sort((a, b) => {
// 名称命中优先于描述命中
const aName = a.name.toLowerCase().includes(kw) ? 1 : 0;
const bName = b.name.toLowerCase().includes(kw) ? 1 : 0;
return bName - aName;
})
.slice(0, 50);
}这种方案的优势是零外部依赖、启动即用,缺点是缺乏分词能力,中文关键词和英文混合查询的效果不如Elasticsearch。如果需要支持中文检索,可以引入nodejieba做分词,把切分后的词存入一个简易倒排索引,实现成本依然可控。
五、性能优化与部署建议
数据更新方面,建议用定时任务每周拉取一次attack-stix-data仓库的更新,通过对比对象的modified时间戳做增量更新,避免全量重建索引。Elasticsearch写入时使用bulk批量接口,几百条数据的导入可以在一秒内完成。对于高并发场景,Express前面可以加一层Redis缓存热点查询结果,TTP数据本身变化不频繁,缓存命中率通常能达到很高水平。
部署时要特别注意STIX数据源的网络可达性。国内环境访问GitHub可能不稳定,可以把STIX文件同步到内部制品库或对象存储,代码中的下载地址改成内部源。此外,接口层建议加入简单的鉴权,毕竟内部威胁情报系统暴露在公网会有信息泄露风险。如果需要对接SOC平台或ChatOps机器人,可以再封装一个Webhook转发层,把检索结果推送到钉钉或企业微信,让一线分析人员在群里直接@机器人查询ATT&CK编号,整个链路就完整了。
Node.jsTTP SearchMITRE ATT&CK修改时间:2026-09-13 10:06:38