security-detections-mcp 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +344 -47
- package/dist/db.d.ts +76 -2
- package/dist/db.js +644 -15
- package/dist/index.js +701 -12
- package/dist/indexer.d.ts +7 -1
- package/dist/indexer.js +131 -5
- package/dist/parsers/elastic.d.ts +2 -0
- package/dist/parsers/elastic.js +245 -0
- package/dist/parsers/kql.d.ts +2 -0
- package/dist/parsers/kql.js +348 -0
- package/dist/parsers/sigma.js +310 -0
- package/dist/parsers/splunk.js +151 -1
- package/dist/parsers/story.d.ts +2 -0
- package/dist/parsers/story.js +31 -0
- package/dist/types.d.ts +97 -1
- package/dist/types.js +1 -1
- package/package.json +8 -2
package/dist/db.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
3
|
import { join } from 'path';
|
|
4
|
-
import { mkdirSync, existsSync } from 'fs';
|
|
4
|
+
import { mkdirSync, existsSync, unlinkSync } from 'fs';
|
|
5
5
|
const CACHE_DIR = join(homedir(), '.cache', 'security-detections-mcp');
|
|
6
6
|
const DB_PATH = join(CACHE_DIR, 'detections.sqlite');
|
|
7
7
|
let db = null;
|
|
@@ -16,7 +16,7 @@ export function initDb() {
|
|
|
16
16
|
mkdirSync(CACHE_DIR, { recursive: true });
|
|
17
17
|
}
|
|
18
18
|
db = new Database(DB_PATH);
|
|
19
|
-
// Create main detections table
|
|
19
|
+
// Create main detections table with all enhanced fields
|
|
20
20
|
db.exec(`
|
|
21
21
|
CREATE TABLE IF NOT EXISTS detections (
|
|
22
22
|
id TEXT PRIMARY KEY,
|
|
@@ -37,10 +37,24 @@ export function initDb() {
|
|
|
37
37
|
falsepositives TEXT,
|
|
38
38
|
tags TEXT,
|
|
39
39
|
file_path TEXT,
|
|
40
|
-
raw_yaml TEXT
|
|
40
|
+
raw_yaml TEXT,
|
|
41
|
+
cves TEXT,
|
|
42
|
+
analytic_stories TEXT,
|
|
43
|
+
data_sources TEXT,
|
|
44
|
+
detection_type TEXT,
|
|
45
|
+
asset_type TEXT,
|
|
46
|
+
security_domain TEXT,
|
|
47
|
+
process_names TEXT,
|
|
48
|
+
file_paths TEXT,
|
|
49
|
+
registry_paths TEXT,
|
|
50
|
+
mitre_tactics TEXT,
|
|
51
|
+
platforms TEXT,
|
|
52
|
+
kql_category TEXT,
|
|
53
|
+
kql_tags TEXT,
|
|
54
|
+
kql_keywords TEXT
|
|
41
55
|
)
|
|
42
56
|
`);
|
|
43
|
-
// Create FTS5 virtual table for full-text search
|
|
57
|
+
// Create FTS5 virtual table for full-text search with all searchable fields
|
|
44
58
|
db.exec(`
|
|
45
59
|
CREATE VIRTUAL TABLE IF NOT EXISTS detections_fts USING fts5(
|
|
46
60
|
id,
|
|
@@ -49,6 +63,17 @@ export function initDb() {
|
|
|
49
63
|
query,
|
|
50
64
|
mitre_ids,
|
|
51
65
|
tags,
|
|
66
|
+
cves,
|
|
67
|
+
analytic_stories,
|
|
68
|
+
data_sources,
|
|
69
|
+
process_names,
|
|
70
|
+
file_paths,
|
|
71
|
+
registry_paths,
|
|
72
|
+
mitre_tactics,
|
|
73
|
+
platforms,
|
|
74
|
+
kql_category,
|
|
75
|
+
kql_tags,
|
|
76
|
+
kql_keywords,
|
|
52
77
|
content='detections',
|
|
53
78
|
content_rowid='rowid'
|
|
54
79
|
)
|
|
@@ -56,22 +81,22 @@ export function initDb() {
|
|
|
56
81
|
// Create triggers to keep FTS in sync
|
|
57
82
|
db.exec(`
|
|
58
83
|
CREATE TRIGGER IF NOT EXISTS detections_ai AFTER INSERT ON detections BEGIN
|
|
59
|
-
INSERT INTO detections_fts(rowid, id, name, description, query, mitre_ids, tags)
|
|
60
|
-
VALUES (NEW.rowid, NEW.id, NEW.name, NEW.description, NEW.query, NEW.mitre_ids, NEW.tags);
|
|
84
|
+
INSERT INTO detections_fts(rowid, id, name, description, query, mitre_ids, tags, cves, analytic_stories, data_sources, process_names, file_paths, registry_paths, mitre_tactics, platforms, kql_category, kql_tags, kql_keywords)
|
|
85
|
+
VALUES (NEW.rowid, NEW.id, NEW.name, NEW.description, NEW.query, NEW.mitre_ids, NEW.tags, NEW.cves, NEW.analytic_stories, NEW.data_sources, NEW.process_names, NEW.file_paths, NEW.registry_paths, NEW.mitre_tactics, NEW.platforms, NEW.kql_category, NEW.kql_tags, NEW.kql_keywords);
|
|
61
86
|
END
|
|
62
87
|
`);
|
|
63
88
|
db.exec(`
|
|
64
89
|
CREATE TRIGGER IF NOT EXISTS detections_ad AFTER DELETE ON detections BEGIN
|
|
65
|
-
INSERT INTO detections_fts(detections_fts, rowid, id, name, description, query, mitre_ids, tags)
|
|
66
|
-
VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.description, OLD.query, OLD.mitre_ids, OLD.tags);
|
|
90
|
+
INSERT INTO detections_fts(detections_fts, rowid, id, name, description, query, mitre_ids, tags, cves, analytic_stories, data_sources, process_names, file_paths, registry_paths, mitre_tactics, platforms, kql_category, kql_tags, kql_keywords)
|
|
91
|
+
VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.description, OLD.query, OLD.mitre_ids, OLD.tags, OLD.cves, OLD.analytic_stories, OLD.data_sources, OLD.process_names, OLD.file_paths, OLD.registry_paths, OLD.mitre_tactics, OLD.platforms, OLD.kql_category, OLD.kql_tags, OLD.kql_keywords);
|
|
67
92
|
END
|
|
68
93
|
`);
|
|
69
94
|
db.exec(`
|
|
70
95
|
CREATE TRIGGER IF NOT EXISTS detections_au AFTER UPDATE ON detections BEGIN
|
|
71
|
-
INSERT INTO detections_fts(detections_fts, rowid, id, name, description, query, mitre_ids, tags)
|
|
72
|
-
VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.description, OLD.query, OLD.mitre_ids, OLD.tags);
|
|
73
|
-
INSERT INTO detections_fts(rowid, id, name, description, query, mitre_ids, tags)
|
|
74
|
-
VALUES (NEW.rowid, NEW.id, NEW.name, NEW.description, NEW.query, NEW.mitre_ids, NEW.tags);
|
|
96
|
+
INSERT INTO detections_fts(detections_fts, rowid, id, name, description, query, mitre_ids, tags, cves, analytic_stories, data_sources, process_names, file_paths, registry_paths, mitre_tactics, platforms, kql_category, kql_tags, kql_keywords)
|
|
97
|
+
VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.description, OLD.query, OLD.mitre_ids, OLD.tags, OLD.cves, OLD.analytic_stories, OLD.data_sources, OLD.process_names, OLD.file_paths, OLD.registry_paths, OLD.mitre_tactics, OLD.platforms, OLD.kql_category, OLD.kql_tags, OLD.kql_keywords);
|
|
98
|
+
INSERT INTO detections_fts(rowid, id, name, description, query, mitre_ids, tags, cves, analytic_stories, data_sources, process_names, file_paths, registry_paths, mitre_tactics, platforms, kql_category, kql_tags, kql_keywords)
|
|
99
|
+
VALUES (NEW.rowid, NEW.id, NEW.name, NEW.description, NEW.query, NEW.mitre_ids, NEW.tags, NEW.cves, NEW.analytic_stories, NEW.data_sources, NEW.process_names, NEW.file_paths, NEW.registry_paths, NEW.mitre_tactics, NEW.platforms, NEW.kql_category, NEW.kql_tags, NEW.kql_keywords);
|
|
75
100
|
END
|
|
76
101
|
`);
|
|
77
102
|
// Create indexes for common queries
|
|
@@ -79,22 +104,82 @@ export function initDb() {
|
|
|
79
104
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_severity ON detections(severity)`);
|
|
80
105
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_logsource_product ON detections(logsource_product)`);
|
|
81
106
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_logsource_category ON detections(logsource_category)`);
|
|
107
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_detection_type ON detections(detection_type)`);
|
|
108
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_asset_type ON detections(asset_type)`);
|
|
109
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_security_domain ON detections(security_domain)`);
|
|
110
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_kql_category ON detections(kql_category)`);
|
|
111
|
+
// Create stories table (optional - provides rich context for analytic stories)
|
|
112
|
+
db.exec(`
|
|
113
|
+
CREATE TABLE IF NOT EXISTS stories (
|
|
114
|
+
id TEXT PRIMARY KEY,
|
|
115
|
+
name TEXT NOT NULL,
|
|
116
|
+
description TEXT,
|
|
117
|
+
narrative TEXT,
|
|
118
|
+
author TEXT,
|
|
119
|
+
date TEXT,
|
|
120
|
+
version INTEGER,
|
|
121
|
+
status TEXT,
|
|
122
|
+
refs TEXT,
|
|
123
|
+
category TEXT,
|
|
124
|
+
usecase TEXT,
|
|
125
|
+
detection_names TEXT
|
|
126
|
+
)
|
|
127
|
+
`);
|
|
128
|
+
// Create FTS5 for stories (narrative is key for semantic search!)
|
|
129
|
+
db.exec(`
|
|
130
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS stories_fts USING fts5(
|
|
131
|
+
id,
|
|
132
|
+
name,
|
|
133
|
+
description,
|
|
134
|
+
narrative,
|
|
135
|
+
category,
|
|
136
|
+
usecase,
|
|
137
|
+
content='stories',
|
|
138
|
+
content_rowid='rowid'
|
|
139
|
+
)
|
|
140
|
+
`);
|
|
141
|
+
// Triggers for stories FTS
|
|
142
|
+
db.exec(`
|
|
143
|
+
CREATE TRIGGER IF NOT EXISTS stories_ai AFTER INSERT ON stories BEGIN
|
|
144
|
+
INSERT INTO stories_fts(rowid, id, name, description, narrative, category, usecase)
|
|
145
|
+
VALUES (NEW.rowid, NEW.id, NEW.name, NEW.description, NEW.narrative, NEW.category, NEW.usecase);
|
|
146
|
+
END
|
|
147
|
+
`);
|
|
148
|
+
db.exec(`
|
|
149
|
+
CREATE TRIGGER IF NOT EXISTS stories_ad AFTER DELETE ON stories BEGIN
|
|
150
|
+
INSERT INTO stories_fts(stories_fts, rowid, id, name, description, narrative, category, usecase)
|
|
151
|
+
VALUES ('delete', OLD.rowid, OLD.id, OLD.name, OLD.description, OLD.narrative, OLD.category, OLD.usecase);
|
|
152
|
+
END
|
|
153
|
+
`);
|
|
154
|
+
db.exec(`CREATE INDEX IF NOT EXISTS idx_story_category ON stories(category)`);
|
|
82
155
|
return db;
|
|
83
156
|
}
|
|
84
157
|
export function clearDb() {
|
|
85
158
|
const database = initDb();
|
|
86
159
|
database.exec('DELETE FROM detections');
|
|
87
160
|
}
|
|
161
|
+
// Force recreation of the database (needed when schema changes)
|
|
162
|
+
export function recreateDb() {
|
|
163
|
+
if (db) {
|
|
164
|
+
db.close();
|
|
165
|
+
db = null;
|
|
166
|
+
}
|
|
167
|
+
if (existsSync(DB_PATH)) {
|
|
168
|
+
unlinkSync(DB_PATH);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
88
171
|
export function insertDetection(detection) {
|
|
89
172
|
const database = initDb();
|
|
90
173
|
const stmt = database.prepare(`
|
|
91
174
|
INSERT OR REPLACE INTO detections
|
|
92
175
|
(id, name, description, query, source_type, mitre_ids, logsource_category,
|
|
93
176
|
logsource_product, logsource_service, severity, status, author,
|
|
94
|
-
date_created, date_modified, refs, falsepositives, tags, file_path, raw_yaml
|
|
95
|
-
|
|
177
|
+
date_created, date_modified, refs, falsepositives, tags, file_path, raw_yaml,
|
|
178
|
+
cves, analytic_stories, data_sources, detection_type, asset_type, security_domain,
|
|
179
|
+
process_names, file_paths, registry_paths, mitre_tactics, platforms, kql_category, kql_tags, kql_keywords)
|
|
180
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
96
181
|
`);
|
|
97
|
-
stmt.run(detection.id, detection.name, detection.description, detection.query, detection.source_type, JSON.stringify(detection.mitre_ids), detection.logsource_category, detection.logsource_product, detection.logsource_service, detection.severity, detection.status, detection.author, detection.date_created, detection.date_modified, JSON.stringify(detection.references), JSON.stringify(detection.falsepositives), JSON.stringify(detection.tags), detection.file_path, detection.raw_yaml);
|
|
182
|
+
stmt.run(detection.id, detection.name, detection.description, detection.query, detection.source_type, JSON.stringify(detection.mitre_ids), detection.logsource_category, detection.logsource_product, detection.logsource_service, detection.severity, detection.status, detection.author, detection.date_created, detection.date_modified, JSON.stringify(detection.references), JSON.stringify(detection.falsepositives), JSON.stringify(detection.tags), detection.file_path, detection.raw_yaml, JSON.stringify(detection.cves), JSON.stringify(detection.analytic_stories), JSON.stringify(detection.data_sources), detection.detection_type, detection.asset_type, detection.security_domain, JSON.stringify(detection.process_names), JSON.stringify(detection.file_paths), JSON.stringify(detection.registry_paths), JSON.stringify(detection.mitre_tactics), JSON.stringify(detection.platforms), detection.kql_category, JSON.stringify(detection.kql_tags), JSON.stringify(detection.kql_keywords));
|
|
98
183
|
}
|
|
99
184
|
function rowToDetection(row) {
|
|
100
185
|
return {
|
|
@@ -117,6 +202,20 @@ function rowToDetection(row) {
|
|
|
117
202
|
tags: JSON.parse(row.tags || '[]'),
|
|
118
203
|
file_path: row.file_path,
|
|
119
204
|
raw_yaml: row.raw_yaml,
|
|
205
|
+
cves: JSON.parse(row.cves || '[]'),
|
|
206
|
+
analytic_stories: JSON.parse(row.analytic_stories || '[]'),
|
|
207
|
+
data_sources: JSON.parse(row.data_sources || '[]'),
|
|
208
|
+
detection_type: row.detection_type,
|
|
209
|
+
asset_type: row.asset_type,
|
|
210
|
+
security_domain: row.security_domain,
|
|
211
|
+
process_names: JSON.parse(row.process_names || '[]'),
|
|
212
|
+
file_paths: JSON.parse(row.file_paths || '[]'),
|
|
213
|
+
registry_paths: JSON.parse(row.registry_paths || '[]'),
|
|
214
|
+
mitre_tactics: JSON.parse(row.mitre_tactics || '[]'),
|
|
215
|
+
platforms: JSON.parse(row.platforms || '[]'),
|
|
216
|
+
kql_category: row.kql_category,
|
|
217
|
+
kql_tags: JSON.parse(row.kql_tags || '[]'),
|
|
218
|
+
kql_keywords: JSON.parse(row.kql_keywords || '[]'),
|
|
120
219
|
};
|
|
121
220
|
}
|
|
122
221
|
export function searchDetections(query, limit = 50) {
|
|
@@ -190,11 +289,108 @@ export function listBySeverity(level, limit = 100, offset = 0) {
|
|
|
190
289
|
const rows = stmt.all(level, limit, offset);
|
|
191
290
|
return rows.map(rowToDetection);
|
|
192
291
|
}
|
|
292
|
+
// New query methods for enhanced fields
|
|
293
|
+
export function listByCve(cveId, limit = 100, offset = 0) {
|
|
294
|
+
const database = initDb();
|
|
295
|
+
const stmt = database.prepare(`
|
|
296
|
+
SELECT * FROM detections
|
|
297
|
+
WHERE cves LIKE ?
|
|
298
|
+
ORDER BY name
|
|
299
|
+
LIMIT ? OFFSET ?
|
|
300
|
+
`);
|
|
301
|
+
const rows = stmt.all(`%"${cveId}"%`, limit, offset);
|
|
302
|
+
return rows.map(rowToDetection);
|
|
303
|
+
}
|
|
304
|
+
export function listByAnalyticStory(story, limit = 100, offset = 0) {
|
|
305
|
+
const database = initDb();
|
|
306
|
+
const stmt = database.prepare(`
|
|
307
|
+
SELECT * FROM detections
|
|
308
|
+
WHERE analytic_stories LIKE ?
|
|
309
|
+
ORDER BY name
|
|
310
|
+
LIMIT ? OFFSET ?
|
|
311
|
+
`);
|
|
312
|
+
const rows = stmt.all(`%${story}%`, limit, offset);
|
|
313
|
+
return rows.map(rowToDetection);
|
|
314
|
+
}
|
|
315
|
+
export function listByProcessName(processName, limit = 100, offset = 0) {
|
|
316
|
+
const database = initDb();
|
|
317
|
+
const stmt = database.prepare(`
|
|
318
|
+
SELECT * FROM detections
|
|
319
|
+
WHERE process_names LIKE ?
|
|
320
|
+
ORDER BY name
|
|
321
|
+
LIMIT ? OFFSET ?
|
|
322
|
+
`);
|
|
323
|
+
const rows = stmt.all(`%${processName}%`, limit, offset);
|
|
324
|
+
return rows.map(rowToDetection);
|
|
325
|
+
}
|
|
326
|
+
export function listByDetectionType(detectionType, limit = 100, offset = 0) {
|
|
327
|
+
const database = initDb();
|
|
328
|
+
const stmt = database.prepare('SELECT * FROM detections WHERE detection_type = ? ORDER BY name LIMIT ? OFFSET ?');
|
|
329
|
+
const rows = stmt.all(detectionType, limit, offset);
|
|
330
|
+
return rows.map(rowToDetection);
|
|
331
|
+
}
|
|
332
|
+
export function listByDataSource(dataSource, limit = 100, offset = 0) {
|
|
333
|
+
const database = initDb();
|
|
334
|
+
const stmt = database.prepare(`
|
|
335
|
+
SELECT * FROM detections
|
|
336
|
+
WHERE data_sources LIKE ?
|
|
337
|
+
ORDER BY name
|
|
338
|
+
LIMIT ? OFFSET ?
|
|
339
|
+
`);
|
|
340
|
+
const rows = stmt.all(`%${dataSource}%`, limit, offset);
|
|
341
|
+
return rows.map(rowToDetection);
|
|
342
|
+
}
|
|
343
|
+
export function listByKqlCategory(category, limit = 100, offset = 0) {
|
|
344
|
+
const database = initDb();
|
|
345
|
+
const stmt = database.prepare(`
|
|
346
|
+
SELECT * FROM detections
|
|
347
|
+
WHERE source_type = 'kql' AND kql_category = ?
|
|
348
|
+
ORDER BY name
|
|
349
|
+
LIMIT ? OFFSET ?
|
|
350
|
+
`);
|
|
351
|
+
const rows = stmt.all(category, limit, offset);
|
|
352
|
+
return rows.map(rowToDetection);
|
|
353
|
+
}
|
|
354
|
+
export function listByKqlTag(tag, limit = 100, offset = 0) {
|
|
355
|
+
const database = initDb();
|
|
356
|
+
const stmt = database.prepare(`
|
|
357
|
+
SELECT * FROM detections
|
|
358
|
+
WHERE source_type = 'kql' AND kql_tags LIKE ?
|
|
359
|
+
ORDER BY name
|
|
360
|
+
LIMIT ? OFFSET ?
|
|
361
|
+
`);
|
|
362
|
+
const rows = stmt.all(`%"${tag}"%`, limit, offset);
|
|
363
|
+
return rows.map(rowToDetection);
|
|
364
|
+
}
|
|
365
|
+
export function listByKqlDatasource(dataSource, limit = 100, offset = 0) {
|
|
366
|
+
const database = initDb();
|
|
367
|
+
const stmt = database.prepare(`
|
|
368
|
+
SELECT * FROM detections
|
|
369
|
+
WHERE source_type = 'kql' AND data_sources LIKE ?
|
|
370
|
+
ORDER BY name
|
|
371
|
+
LIMIT ? OFFSET ?
|
|
372
|
+
`);
|
|
373
|
+
const rows = stmt.all(`%${dataSource}%`, limit, offset);
|
|
374
|
+
return rows.map(rowToDetection);
|
|
375
|
+
}
|
|
376
|
+
export function listByMitreTactic(tactic, limit = 100, offset = 0) {
|
|
377
|
+
const database = initDb();
|
|
378
|
+
const stmt = database.prepare(`
|
|
379
|
+
SELECT * FROM detections
|
|
380
|
+
WHERE mitre_tactics LIKE ?
|
|
381
|
+
ORDER BY name
|
|
382
|
+
LIMIT ? OFFSET ?
|
|
383
|
+
`);
|
|
384
|
+
const rows = stmt.all(`%"${tactic}"%`, limit, offset);
|
|
385
|
+
return rows.map(rowToDetection);
|
|
386
|
+
}
|
|
193
387
|
export function getStats() {
|
|
194
388
|
const database = initDb();
|
|
195
389
|
const total = database.prepare('SELECT COUNT(*) as count FROM detections').get().count;
|
|
196
390
|
const sigma = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'sigma'").get().count;
|
|
197
391
|
const splunk = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'splunk_escu'").get().count;
|
|
392
|
+
const elastic = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'elastic'").get().count;
|
|
393
|
+
const kql = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'kql'").get().count;
|
|
198
394
|
// Count by severity
|
|
199
395
|
const severityRows = database.prepare(`
|
|
200
396
|
SELECT severity, COUNT(*) as count FROM detections
|
|
@@ -222,13 +418,65 @@ export function getStats() {
|
|
|
222
418
|
SELECT COUNT(*) as count FROM detections
|
|
223
419
|
WHERE mitre_ids != '[]' AND mitre_ids IS NOT NULL
|
|
224
420
|
`).get().count;
|
|
421
|
+
// Count detections with CVE mappings
|
|
422
|
+
const cve_coverage = database.prepare(`
|
|
423
|
+
SELECT COUNT(*) as count FROM detections
|
|
424
|
+
WHERE cves != '[]' AND cves IS NOT NULL
|
|
425
|
+
`).get().count;
|
|
426
|
+
// Count by MITRE tactic
|
|
427
|
+
const tacticRows = database.prepare(`
|
|
428
|
+
SELECT mitre_tactics FROM detections
|
|
429
|
+
WHERE mitre_tactics != '[]' AND mitre_tactics IS NOT NULL
|
|
430
|
+
`).all();
|
|
431
|
+
const by_mitre_tactic = {};
|
|
432
|
+
for (const row of tacticRows) {
|
|
433
|
+
const tactics = JSON.parse(row.mitre_tactics);
|
|
434
|
+
for (const tactic of tactics) {
|
|
435
|
+
by_mitre_tactic[tactic] = (by_mitre_tactic[tactic] || 0) + 1;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Count by detection type
|
|
439
|
+
const typeRows = database.prepare(`
|
|
440
|
+
SELECT detection_type, COUNT(*) as count FROM detections
|
|
441
|
+
WHERE detection_type IS NOT NULL
|
|
442
|
+
GROUP BY detection_type
|
|
443
|
+
`).all();
|
|
444
|
+
const by_detection_type = {};
|
|
445
|
+
for (const row of typeRows) {
|
|
446
|
+
by_detection_type[row.detection_type] = row.count;
|
|
447
|
+
}
|
|
448
|
+
// Count stories (optional table)
|
|
449
|
+
let stories_count = 0;
|
|
450
|
+
const by_story_category = {};
|
|
451
|
+
try {
|
|
452
|
+
stories_count = database.prepare('SELECT COUNT(*) as count FROM stories').get().count;
|
|
453
|
+
const categoryRows = database.prepare(`
|
|
454
|
+
SELECT category, COUNT(*) as count FROM stories
|
|
455
|
+
WHERE category IS NOT NULL
|
|
456
|
+
GROUP BY category
|
|
457
|
+
`).all();
|
|
458
|
+
for (const row of categoryRows) {
|
|
459
|
+
by_story_category[row.category] = row.count;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// Stories table might not exist or be empty - that's fine
|
|
464
|
+
}
|
|
225
465
|
return {
|
|
226
466
|
total,
|
|
227
467
|
sigma,
|
|
228
468
|
splunk_escu: splunk,
|
|
469
|
+
elastic,
|
|
470
|
+
kql,
|
|
229
471
|
by_severity,
|
|
230
472
|
by_logsource_product,
|
|
231
473
|
mitre_coverage,
|
|
474
|
+
cve_coverage,
|
|
475
|
+
by_mitre_tactic,
|
|
476
|
+
by_detection_type,
|
|
477
|
+
stories_count,
|
|
478
|
+
by_story_category,
|
|
479
|
+
by_elastic_index: {}, // Could be populated if needed
|
|
232
480
|
};
|
|
233
481
|
}
|
|
234
482
|
export function getRawYaml(id) {
|
|
@@ -246,3 +494,384 @@ export function getDetectionCount() {
|
|
|
246
494
|
const database = initDb();
|
|
247
495
|
return database.prepare('SELECT COUNT(*) as count FROM detections').get().count;
|
|
248
496
|
}
|
|
497
|
+
// Story-related functions
|
|
498
|
+
export function insertStory(story) {
|
|
499
|
+
const database = initDb();
|
|
500
|
+
const stmt = database.prepare(`
|
|
501
|
+
INSERT OR REPLACE INTO stories
|
|
502
|
+
(id, name, description, narrative, author, date, version, status, refs, category, usecase, detection_names)
|
|
503
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
504
|
+
`);
|
|
505
|
+
stmt.run(story.id, story.name, story.description, story.narrative, story.author, story.date, story.version, story.status, JSON.stringify(story.references), story.category, story.usecase, JSON.stringify(story.detection_names));
|
|
506
|
+
}
|
|
507
|
+
function rowToStory(row) {
|
|
508
|
+
return {
|
|
509
|
+
id: row.id,
|
|
510
|
+
name: row.name,
|
|
511
|
+
description: row.description || '',
|
|
512
|
+
narrative: row.narrative || '',
|
|
513
|
+
author: row.author,
|
|
514
|
+
date: row.date,
|
|
515
|
+
version: row.version,
|
|
516
|
+
status: row.status,
|
|
517
|
+
references: JSON.parse(row.refs || '[]'),
|
|
518
|
+
category: row.category,
|
|
519
|
+
usecase: row.usecase,
|
|
520
|
+
detection_names: JSON.parse(row.detection_names || '[]'),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
export function getStoryByName(name) {
|
|
524
|
+
const database = initDb();
|
|
525
|
+
const stmt = database.prepare('SELECT * FROM stories WHERE name = ?');
|
|
526
|
+
const row = stmt.get(name);
|
|
527
|
+
return row ? rowToStory(row) : null;
|
|
528
|
+
}
|
|
529
|
+
export function getStoryById(id) {
|
|
530
|
+
const database = initDb();
|
|
531
|
+
const stmt = database.prepare('SELECT * FROM stories WHERE id = ?');
|
|
532
|
+
const row = stmt.get(id);
|
|
533
|
+
return row ? rowToStory(row) : null;
|
|
534
|
+
}
|
|
535
|
+
export function searchStories(query, limit = 20) {
|
|
536
|
+
const database = initDb();
|
|
537
|
+
try {
|
|
538
|
+
const stmt = database.prepare(`
|
|
539
|
+
SELECT s.* FROM stories s
|
|
540
|
+
JOIN stories_fts fts ON s.rowid = fts.rowid
|
|
541
|
+
WHERE stories_fts MATCH ?
|
|
542
|
+
ORDER BY rank
|
|
543
|
+
LIMIT ?
|
|
544
|
+
`);
|
|
545
|
+
const rows = stmt.all(query, limit);
|
|
546
|
+
return rows.map(rowToStory);
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
// If no stories indexed, return empty
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
export function listStories(limit = 100, offset = 0) {
|
|
554
|
+
const database = initDb();
|
|
555
|
+
try {
|
|
556
|
+
const stmt = database.prepare('SELECT * FROM stories ORDER BY name LIMIT ? OFFSET ?');
|
|
557
|
+
const rows = stmt.all(limit, offset);
|
|
558
|
+
return rows.map(rowToStory);
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
return [];
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
export function listStoriesByCategory(category, limit = 100, offset = 0) {
|
|
565
|
+
const database = initDb();
|
|
566
|
+
try {
|
|
567
|
+
const stmt = database.prepare('SELECT * FROM stories WHERE category = ? ORDER BY name LIMIT ? OFFSET ?');
|
|
568
|
+
const rows = stmt.all(category, limit, offset);
|
|
569
|
+
return rows.map(rowToStory);
|
|
570
|
+
}
|
|
571
|
+
catch {
|
|
572
|
+
return [];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
export function getStoryCount() {
|
|
576
|
+
const database = initDb();
|
|
577
|
+
try {
|
|
578
|
+
return database.prepare('SELECT COUNT(*) as count FROM stories').get().count;
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
export function getTechniqueIds(filters = {}) {
|
|
585
|
+
const database = initDb();
|
|
586
|
+
let sql = "SELECT DISTINCT mitre_ids FROM detections WHERE mitre_ids != '[]' AND mitre_ids IS NOT NULL";
|
|
587
|
+
const params = [];
|
|
588
|
+
if (filters.source_type) {
|
|
589
|
+
sql += ' AND source_type = ?';
|
|
590
|
+
params.push(filters.source_type);
|
|
591
|
+
}
|
|
592
|
+
if (filters.tactic) {
|
|
593
|
+
sql += ' AND mitre_tactics LIKE ?';
|
|
594
|
+
params.push(`%"${filters.tactic}"%`);
|
|
595
|
+
}
|
|
596
|
+
if (filters.severity) {
|
|
597
|
+
sql += ' AND severity = ?';
|
|
598
|
+
params.push(filters.severity);
|
|
599
|
+
}
|
|
600
|
+
const stmt = database.prepare(sql);
|
|
601
|
+
const rows = stmt.all(...params);
|
|
602
|
+
// Extract and dedupe all technique IDs
|
|
603
|
+
const techniqueSet = new Set();
|
|
604
|
+
for (const row of rows) {
|
|
605
|
+
const ids = JSON.parse(row.mitre_ids);
|
|
606
|
+
for (const id of ids) {
|
|
607
|
+
techniqueSet.add(id);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
return Array.from(techniqueSet).sort();
|
|
611
|
+
}
|
|
612
|
+
// Threat profiles for gap analysis
|
|
613
|
+
const THREAT_PROFILES = {
|
|
614
|
+
ransomware: [
|
|
615
|
+
'T1486', 'T1490', 'T1027', 'T1547.001', 'T1059.001', 'T1059.003',
|
|
616
|
+
'T1562.001', 'T1112', 'T1070.004', 'T1048', 'T1567', 'T1078',
|
|
617
|
+
'T1566.001', 'T1204.002', 'T1055', 'T1543.003'
|
|
618
|
+
],
|
|
619
|
+
apt: [
|
|
620
|
+
'T1003.001', 'T1003.002', 'T1003.003', 'T1021.001', 'T1021.002',
|
|
621
|
+
'T1053.005', 'T1071.001', 'T1071.004', 'T1105', 'T1027', 'T1055',
|
|
622
|
+
'T1078', 'T1136', 'T1098', 'T1087', 'T1069', 'T1018', 'T1082'
|
|
623
|
+
],
|
|
624
|
+
'initial-access': [
|
|
625
|
+
'T1566.001', 'T1566.002', 'T1190', 'T1078', 'T1133', 'T1200',
|
|
626
|
+
'T1091', 'T1195.002', 'T1199', 'T1189'
|
|
627
|
+
],
|
|
628
|
+
persistence: [
|
|
629
|
+
'T1547.001', 'T1547.004', 'T1543.003', 'T1053.005', 'T1136.001',
|
|
630
|
+
'T1098', 'T1505.003', 'T1546.001', 'T1574.001', 'T1574.002'
|
|
631
|
+
],
|
|
632
|
+
'credential-access': [
|
|
633
|
+
'T1003.001', 'T1003.002', 'T1003.003', 'T1003.004', 'T1003.006',
|
|
634
|
+
'T1555', 'T1552.001', 'T1110', 'T1558.003', 'T1539', 'T1606.001'
|
|
635
|
+
],
|
|
636
|
+
'defense-evasion': [
|
|
637
|
+
'T1027', 'T1070.001', 'T1070.004', 'T1055', 'T1036', 'T1562.001',
|
|
638
|
+
'T1218', 'T1112', 'T1140', 'T1202', 'T1564.001'
|
|
639
|
+
]
|
|
640
|
+
};
|
|
641
|
+
// Analyze coverage efficiently
|
|
642
|
+
export function analyzeCoverage(sourceType) {
|
|
643
|
+
const database = initDb();
|
|
644
|
+
// Get all technique IDs covered
|
|
645
|
+
let countSql = 'SELECT COUNT(DISTINCT id) as count FROM detections';
|
|
646
|
+
if (sourceType)
|
|
647
|
+
countSql += ' WHERE source_type = ?';
|
|
648
|
+
const totalDetections = sourceType
|
|
649
|
+
? database.prepare(countSql).get(sourceType).count
|
|
650
|
+
: database.prepare(countSql).get().count;
|
|
651
|
+
// Get techniques with counts
|
|
652
|
+
let sql = "SELECT mitre_ids, mitre_tactics FROM detections WHERE mitre_ids != '[]'";
|
|
653
|
+
if (sourceType)
|
|
654
|
+
sql += ' AND source_type = ?';
|
|
655
|
+
const rows = sourceType
|
|
656
|
+
? database.prepare(sql).all(sourceType)
|
|
657
|
+
: database.prepare(sql).all();
|
|
658
|
+
// Count techniques and tactics
|
|
659
|
+
const techCounts = {};
|
|
660
|
+
const tacticCounts = {};
|
|
661
|
+
const allTactics = [
|
|
662
|
+
'reconnaissance', 'resource-development', 'initial-access', 'execution',
|
|
663
|
+
'persistence', 'privilege-escalation', 'defense-evasion', 'credential-access',
|
|
664
|
+
'discovery', 'lateral-movement', 'collection', 'command-and-control',
|
|
665
|
+
'exfiltration', 'impact'
|
|
666
|
+
];
|
|
667
|
+
for (const t of allTactics) {
|
|
668
|
+
tacticCounts[t] = new Set();
|
|
669
|
+
}
|
|
670
|
+
for (const row of rows) {
|
|
671
|
+
const ids = JSON.parse(row.mitre_ids);
|
|
672
|
+
const tactics = JSON.parse(row.mitre_tactics || '[]');
|
|
673
|
+
for (const id of ids) {
|
|
674
|
+
techCounts[id] = (techCounts[id] || 0) + 1;
|
|
675
|
+
for (const tactic of tactics) {
|
|
676
|
+
if (tacticCounts[tactic]) {
|
|
677
|
+
tacticCounts[tactic].add(id);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
// Build coverage by tactic (approx totals from ATT&CK)
|
|
683
|
+
const tacticTotals = {
|
|
684
|
+
'reconnaissance': 10, 'resource-development': 8, 'initial-access': 10,
|
|
685
|
+
'execution': 14, 'persistence': 20, 'privilege-escalation': 14,
|
|
686
|
+
'defense-evasion': 43, 'credential-access': 17, 'discovery': 31,
|
|
687
|
+
'lateral-movement': 9, 'collection': 17, 'command-and-control': 18,
|
|
688
|
+
'exfiltration': 9, 'impact': 14
|
|
689
|
+
};
|
|
690
|
+
const coverageByTactic = {};
|
|
691
|
+
for (const tactic of allTactics) {
|
|
692
|
+
const covered = tacticCounts[tactic].size;
|
|
693
|
+
const total = tacticTotals[tactic];
|
|
694
|
+
coverageByTactic[tactic] = {
|
|
695
|
+
covered,
|
|
696
|
+
total,
|
|
697
|
+
percent: Math.round((covered / total) * 100)
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
// Top covered and weak coverage
|
|
701
|
+
const sorted = Object.entries(techCounts).sort((a, b) => b[1] - a[1]);
|
|
702
|
+
const topCovered = sorted.slice(0, 10).map(([t, c]) => ({ technique: t, detection_count: c }));
|
|
703
|
+
const weakCoverage = sorted.filter(([_, c]) => c === 1).slice(0, 10).map(([t, c]) => ({ technique: t, detection_count: c }));
|
|
704
|
+
return {
|
|
705
|
+
summary: {
|
|
706
|
+
total_techniques: Object.keys(techCounts).length,
|
|
707
|
+
total_detections: totalDetections,
|
|
708
|
+
coverage_by_tactic: coverageByTactic,
|
|
709
|
+
},
|
|
710
|
+
top_covered: topCovered,
|
|
711
|
+
weak_coverage: weakCoverage,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
// Identify gaps based on threat profile
|
|
715
|
+
export function identifyGaps(threatProfile, sourceType) {
|
|
716
|
+
const targetTechniques = THREAT_PROFILES[threatProfile.toLowerCase()] || THREAT_PROFILES['apt'];
|
|
717
|
+
// Get what we have coverage for
|
|
718
|
+
const coveredTechs = new Set(getTechniqueIds({ source_type: sourceType }));
|
|
719
|
+
// Find gaps
|
|
720
|
+
const gaps = [];
|
|
721
|
+
const covered = [];
|
|
722
|
+
for (const tech of targetTechniques) {
|
|
723
|
+
if (coveredTechs.has(tech)) {
|
|
724
|
+
covered.push(tech);
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
// Check if we have sub-technique coverage
|
|
728
|
+
const hasSubCoverage = Array.from(coveredTechs).some(t => t.startsWith(tech + '.'));
|
|
729
|
+
const hasParentCoverage = coveredTechs.has(tech.split('.')[0]);
|
|
730
|
+
let priority = 'P0';
|
|
731
|
+
let reason = 'No detection coverage';
|
|
732
|
+
if (hasSubCoverage) {
|
|
733
|
+
priority = 'P2';
|
|
734
|
+
reason = 'Has sub-technique coverage but not parent';
|
|
735
|
+
}
|
|
736
|
+
else if (hasParentCoverage) {
|
|
737
|
+
priority = 'P1';
|
|
738
|
+
reason = 'Has parent technique coverage, may catch this';
|
|
739
|
+
}
|
|
740
|
+
gaps.push({ technique: tech, priority, reason });
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// Sort by priority
|
|
744
|
+
gaps.sort((a, b) => a.priority.localeCompare(b.priority));
|
|
745
|
+
// Generate recommendations
|
|
746
|
+
const recommendations = [
|
|
747
|
+
`${covered.length}/${targetTechniques.length} techniques covered for ${threatProfile}`,
|
|
748
|
+
`${gaps.filter(g => g.priority === 'P0').length} critical gaps (P0) need immediate attention`,
|
|
749
|
+
];
|
|
750
|
+
if (gaps.length > 0) {
|
|
751
|
+
recommendations.push(`Top priority: ${gaps[0].technique} - ${gaps[0].reason}`);
|
|
752
|
+
}
|
|
753
|
+
return {
|
|
754
|
+
threat_profile: threatProfile,
|
|
755
|
+
total_gaps: gaps.length,
|
|
756
|
+
critical_gaps: gaps.slice(0, 15),
|
|
757
|
+
covered,
|
|
758
|
+
recommendations,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
// Suggest detections for a technique
|
|
762
|
+
export function suggestDetections(techniqueId, sourceType) {
|
|
763
|
+
const database = initDb();
|
|
764
|
+
// Find existing detections for this technique
|
|
765
|
+
let sql = "SELECT id, name, source_type, data_sources FROM detections WHERE mitre_ids LIKE ?";
|
|
766
|
+
const params = [`%"${techniqueId}"%`];
|
|
767
|
+
if (sourceType) {
|
|
768
|
+
sql += ' AND source_type = ?';
|
|
769
|
+
params.push(sourceType);
|
|
770
|
+
}
|
|
771
|
+
sql += ' LIMIT 10';
|
|
772
|
+
const rows = database.prepare(sql).all(...params);
|
|
773
|
+
const existingDetections = rows.map(r => ({
|
|
774
|
+
id: r.id,
|
|
775
|
+
name: r.name,
|
|
776
|
+
source: r.source_type
|
|
777
|
+
}));
|
|
778
|
+
// Collect data sources from existing detections
|
|
779
|
+
const dataSources = new Set();
|
|
780
|
+
for (const row of rows) {
|
|
781
|
+
const ds = JSON.parse(row.data_sources || '[]');
|
|
782
|
+
ds.forEach(d => dataSources.add(d));
|
|
783
|
+
}
|
|
784
|
+
// Generate detection ideas based on technique pattern
|
|
785
|
+
const ideas = [];
|
|
786
|
+
const techBase = techniqueId.split('.')[0];
|
|
787
|
+
const ideaMap = {
|
|
788
|
+
'T1059': ['Monitor process creation for script interpreters', 'Track command-line arguments for encoded commands', 'Alert on unusual parent-child process relationships'],
|
|
789
|
+
'T1003': ['Monitor LSASS access patterns', 'Track credential dumping tool signatures', 'Alert on suspicious memory access'],
|
|
790
|
+
'T1547': ['Monitor registry run key modifications', 'Track startup folder changes', 'Alert on new autostart entries'],
|
|
791
|
+
'T1055': ['Monitor for CreateRemoteThread', 'Track process injection patterns', 'Alert on memory allocation in remote processes'],
|
|
792
|
+
'T1027': ['Detect encoded/obfuscated scripts', 'Monitor for packed executables', 'Track file entropy anomalies'],
|
|
793
|
+
'T1071': ['Monitor for beaconing patterns', 'Track DNS query anomalies', 'Alert on unusual HTTP/S traffic'],
|
|
794
|
+
'T1486': ['Monitor for mass file modifications', 'Track encryption-related API calls', 'Alert on ransom note creation'],
|
|
795
|
+
'T1490': ['Monitor vssadmin/wbadmin usage', 'Track backup deletion attempts', 'Alert on bcdedit modifications'],
|
|
796
|
+
};
|
|
797
|
+
ideas.push(...(ideaMap[techBase] || ['Review MITRE ATT&CK for detection guidance', 'Check data source requirements', 'Consider behavioral vs signature detection']));
|
|
798
|
+
return {
|
|
799
|
+
technique_id: techniqueId,
|
|
800
|
+
existing_detections: existingDetections,
|
|
801
|
+
data_sources_needed: Array.from(dataSources).slice(0, 10),
|
|
802
|
+
detection_ideas: ideas,
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
export function generateNavigatorLayer(options) {
|
|
806
|
+
const techniqueIds = getTechniqueIds({
|
|
807
|
+
source_type: options.source_type,
|
|
808
|
+
tactic: options.tactic,
|
|
809
|
+
severity: options.severity,
|
|
810
|
+
});
|
|
811
|
+
// Build techniques array with scores based on detection count
|
|
812
|
+
const database = initDb();
|
|
813
|
+
const techniques = [];
|
|
814
|
+
// Color gradient: red (no coverage) -> yellow (low) -> green (good)
|
|
815
|
+
function getColorForScore(score) {
|
|
816
|
+
if (score >= 80)
|
|
817
|
+
return '#1a8c1a'; // Dark green - excellent
|
|
818
|
+
if (score >= 60)
|
|
819
|
+
return '#8ec843'; // Light green - good
|
|
820
|
+
if (score >= 40)
|
|
821
|
+
return '#ffe766'; // Yellow - moderate
|
|
822
|
+
if (score >= 20)
|
|
823
|
+
return '#ff9933'; // Orange - low
|
|
824
|
+
return '#ff6666'; // Red - minimal
|
|
825
|
+
}
|
|
826
|
+
for (const techId of techniqueIds) {
|
|
827
|
+
let countSql = 'SELECT COUNT(*) as count FROM detections WHERE mitre_ids LIKE ?';
|
|
828
|
+
const countParams = [`%"${techId}"%`];
|
|
829
|
+
if (options.source_type) {
|
|
830
|
+
countSql += ' AND source_type = ?';
|
|
831
|
+
countParams.push(options.source_type);
|
|
832
|
+
}
|
|
833
|
+
if (options.tactic) {
|
|
834
|
+
countSql += ' AND mitre_tactics LIKE ?';
|
|
835
|
+
countParams.push(`%"${options.tactic}"%`);
|
|
836
|
+
}
|
|
837
|
+
const count = database.prepare(countSql).get(...countParams).count;
|
|
838
|
+
const score = Math.min(count * 20, 100); // Scale: 1 detection = 20, max 100
|
|
839
|
+
techniques.push({
|
|
840
|
+
techniqueID: techId,
|
|
841
|
+
score,
|
|
842
|
+
comment: `${count} detection(s)`,
|
|
843
|
+
color: getColorForScore(score),
|
|
844
|
+
enabled: true,
|
|
845
|
+
showSubtechniques: false,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
// ATT&CK Navigator layer format
|
|
849
|
+
return {
|
|
850
|
+
name: options.name,
|
|
851
|
+
versions: {
|
|
852
|
+
attack: '18',
|
|
853
|
+
navigator: '5.1.0',
|
|
854
|
+
layer: '4.5',
|
|
855
|
+
},
|
|
856
|
+
domain: 'enterprise-attack',
|
|
857
|
+
description: options.description || `Generated from ${techniqueIds.length} techniques`,
|
|
858
|
+
filters: { platforms: ['Windows', 'Linux', 'macOS'] },
|
|
859
|
+
sorting: 0,
|
|
860
|
+
layout: { layout: 'side', aggregateFunction: 'average', showID: true, showName: true },
|
|
861
|
+
hideDisabled: false,
|
|
862
|
+
techniques,
|
|
863
|
+
gradient: {
|
|
864
|
+
colors: ['#ff6666', '#ffe766', '#8ec843'],
|
|
865
|
+
minValue: 0,
|
|
866
|
+
maxValue: 100,
|
|
867
|
+
},
|
|
868
|
+
legendItems: [],
|
|
869
|
+
metadata: [],
|
|
870
|
+
links: [],
|
|
871
|
+
showTacticRowBackground: false,
|
|
872
|
+
tacticRowBackground: '#dddddd',
|
|
873
|
+
selectTechniquesAcrossTactics: true,
|
|
874
|
+
selectSubtechniquesWithParent: false,
|
|
875
|
+
selectVisibleTechniques: false,
|
|
876
|
+
};
|
|
877
|
+
}
|