security-detections-mcp 3.2.2 → 3.3.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 +39 -8
- package/dist/db/detections.d.ts +8 -8
- package/dist/db/detections.js +13 -1
- package/dist/db.d.ts +3 -3
- package/dist/db.js +13 -1
- package/dist/handlers/resources.js +13 -13
- package/dist/index.js +4 -3
- package/dist/indexer.d.ts +3 -1
- package/dist/indexer.js +21 -2
- package/dist/parsers/jamf_protect.d.ts +2 -0
- package/dist/parsers/jamf_protect.js +220 -0
- package/dist/resources/index.js +17 -22
- package/dist/server.js +1 -1
- package/dist/tools/autonomous/index.js +3 -2
- package/dist/tools/cache/index.js +6 -3
- package/dist/tools/detections/actor-analysis.js +1 -1
- package/dist/tools/detections/analysis.js +7 -7
- package/dist/tools/detections/comparison.js +3 -3
- package/dist/tools/detections/filters.js +1 -1
- package/dist/tools/detections/search.js +1 -1
- package/dist/tools/engineering/index.js +2 -2
- package/dist/types/detection.d.ts +27 -2
- package/dist/types/index.d.ts +1 -1
- package/dist/types/stats.d.ts +2 -0
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -57,9 +57,7 @@ codex mcp add security-detections -- npx -y security-detections-mcp
|
|
|
57
57
|
**Claude Code** (CLI one-liner):
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
|
-
claude mcp add --transport http security-detections
|
|
61
|
-
https://detect.michaelhaag.org/api/mcp/mcp \
|
|
62
|
-
--header "Authorization: Bearer sdmcp_YOUR_TOKEN_HERE"
|
|
60
|
+
claude mcp add --transport http security-detections https://detect.michaelhaag.org/api/mcp/mcp --header "Authorization: Bearer sdmcp_YOUR_TOKEN_HERE"
|
|
63
61
|
```
|
|
64
62
|
|
|
65
63
|
**Claude Desktop** (via [`mcp-remote`](https://github.com/geelen/mcp-remote) — Desktop doesn't speak remote HTTP natively yet):
|
|
@@ -84,14 +82,46 @@ claude mcp add --transport http security-detections \
|
|
|
84
82
|
**OpenAI Codex** (CLI):
|
|
85
83
|
|
|
86
84
|
```bash
|
|
87
|
-
|
|
88
|
-
codex mcp add security-detections \
|
|
89
|
-
--url https://detect.michaelhaag.org/api/mcp/mcp \
|
|
90
|
-
--bearer-token-env-var SDMCP_TOKEN
|
|
85
|
+
export SDMCP_TOKEN="sdmcp_YOUR_TOKEN_HERE" && codex mcp add security-detections --url https://detect.michaelhaag.org/api/mcp/mcp --bearer-token-env-var SDMCP_TOKEN
|
|
91
86
|
```
|
|
92
87
|
|
|
93
88
|
See the [Hosted MCP Setup Guide](./docs/HOSTED_MCP.md) for the full table of clients, the complete tool inventory, and troubleshooting tips.
|
|
94
89
|
|
|
90
|
+
## AI Model Routing (Web App)
|
|
91
|
+
|
|
92
|
+
The web chat supports Free, Pro/Admin, and BYOK (Bring Your Own Key) routing. You can also see the active model at the top of the chat UI.
|
|
93
|
+
|
|
94
|
+
### Free Tier (default)
|
|
95
|
+
|
|
96
|
+
- Default model: `nvidia/nemotron-3-super-120b-a12b:free`
|
|
97
|
+
- Automatic fallback order if the first model is busy:
|
|
98
|
+
1. `nvidia/nemotron-3-super-120b-a12b:free`
|
|
99
|
+
2. `nousresearch/hermes-3-llama-3.1-405b:free`
|
|
100
|
+
3. `meta-llama/llama-3.3-70b-instruct:free`
|
|
101
|
+
4. `openai/gpt-oss-120b:free`
|
|
102
|
+
|
|
103
|
+
### Pro/Admin (no BYOK key set)
|
|
104
|
+
|
|
105
|
+
Uses app-managed OpenRouter routing with your **Preferred Model** setting in `/account`:
|
|
106
|
+
|
|
107
|
+
| Preferred Model | Routed model |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `auto` | Free model pool (default: `nvidia/nemotron-3-super-120b-a12b:free`) |
|
|
110
|
+
| `claude` | `anthropic/claude-sonnet-4-6` |
|
|
111
|
+
| `claude-opus` | `anthropic/claude-opus-4-6` |
|
|
112
|
+
| `gpt` | `openai/gpt-5.4` |
|
|
113
|
+
| `gpt-codex` | `openai/gpt-5.3-codex` |
|
|
114
|
+
|
|
115
|
+
### BYOK behavior (takes precedence over tier routing)
|
|
116
|
+
|
|
117
|
+
If you set your own API key(s), routing priority is:
|
|
118
|
+
|
|
119
|
+
1. Claude key (`sk-ant-...`) -> `claude-sonnet-4-6-20250514` via Anthropic
|
|
120
|
+
2. OpenAI key (`sk-...`) -> `gpt-5.4` via OpenAI
|
|
121
|
+
3. OpenRouter key (`sk-or-...`) -> uses the same Preferred Model mapping table above
|
|
122
|
+
|
|
123
|
+
If multiple keys are present, the first match in that order is used.
|
|
124
|
+
|
|
95
125
|
## Features
|
|
96
126
|
|
|
97
127
|
- **8,200+ detections** across 6 formats — Sigma, Splunk ESCU, Elastic, KQL, Sublime, CrowdStrike CQL
|
|
@@ -121,6 +151,7 @@ Configure env vars to point at your detection repos:
|
|
|
121
151
|
| `KQL_PATHS` | KQL hunting query directories |
|
|
122
152
|
| `SUBLIME_PATHS` | Sublime Security rule directories |
|
|
123
153
|
| `CQL_HUB_PATHS` | CQL Hub (CrowdStrike) query directories |
|
|
154
|
+
| `JAMF_PROTECT_PATHS` | Jamf Protect custom analytic detection directories (macOS) |
|
|
124
155
|
| `STORY_PATHS` | Splunk analytic story directories (optional) |
|
|
125
156
|
| `ATTACK_STIX_PATH` | Path to `enterprise-attack.json` for threat actor data (optional) |
|
|
126
157
|
|
|
@@ -150,7 +181,7 @@ git clone --depth 1 https://github.com/ByteRay-Labs/Query-Hub.git cql-hub
|
|
|
150
181
|
| `search(query, limit)` | Full-text search across all detection fields |
|
|
151
182
|
| `get_by_id(id)` | Get a single detection by ID |
|
|
152
183
|
| `list_all(limit, offset)` | Paginated list of all detections |
|
|
153
|
-
| `list_by_source(source_type)` | Filter by source (`sigma`, `splunk_escu`, `elastic`, `kql`, `sublime`, `crowdstrike_cql`) |
|
|
184
|
+
| `list_by_source(source_type)` | Filter by source (`sigma`, `splunk_escu`, `elastic`, `kql`, `sublime`, `crowdstrike_cql`, `jamf_protect`) |
|
|
154
185
|
| `get_stats()` | Index statistics |
|
|
155
186
|
| `rebuild_index()` | Force re-index from configured paths |
|
|
156
187
|
|
package/dist/db/detections.d.ts
CHANGED
|
@@ -12,7 +12,7 @@ export interface ValidationResult {
|
|
|
12
12
|
similar?: string[];
|
|
13
13
|
}
|
|
14
14
|
export interface TechniqueIdFilters {
|
|
15
|
-
source_type?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql';
|
|
15
|
+
source_type?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect';
|
|
16
16
|
tactic?: string;
|
|
17
17
|
severity?: string;
|
|
18
18
|
}
|
|
@@ -59,7 +59,7 @@ export interface DetectionSuggestion {
|
|
|
59
59
|
export interface NavigatorLayerOptions {
|
|
60
60
|
name: string;
|
|
61
61
|
description?: string;
|
|
62
|
-
source_type?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql';
|
|
62
|
+
source_type?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect';
|
|
63
63
|
tactic?: string;
|
|
64
64
|
severity?: string;
|
|
65
65
|
actor_name?: string;
|
|
@@ -108,7 +108,7 @@ export declare function listDetections(limit?: number, offset?: number): Detecti
|
|
|
108
108
|
/**
|
|
109
109
|
* List detections filtered by source type.
|
|
110
110
|
*/
|
|
111
|
-
export declare function listBySource(sourceType: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql', limit?: number, offset?: number): Detection[];
|
|
111
|
+
export declare function listBySource(sourceType: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect', limit?: number, offset?: number): Detection[];
|
|
112
112
|
/**
|
|
113
113
|
* List detections by MITRE technique ID.
|
|
114
114
|
*/
|
|
@@ -184,15 +184,15 @@ export declare function getTechniqueIds(filters?: TechniqueIdFilters): string[];
|
|
|
184
184
|
/**
|
|
185
185
|
* Analyze coverage by tactic and identify strengths/weaknesses.
|
|
186
186
|
*/
|
|
187
|
-
export declare function analyzeCoverage(sourceType?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql'): CoverageReport;
|
|
187
|
+
export declare function analyzeCoverage(sourceType?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect'): CoverageReport;
|
|
188
188
|
/**
|
|
189
189
|
* Identify gaps based on a threat profile.
|
|
190
190
|
*/
|
|
191
|
-
export declare function identifyGaps(threatProfile: string, sourceType?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql'): GapAnalysis;
|
|
191
|
+
export declare function identifyGaps(threatProfile: string, sourceType?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect'): GapAnalysis;
|
|
192
192
|
/**
|
|
193
193
|
* Suggest detections for a technique.
|
|
194
194
|
*/
|
|
195
|
-
export declare function suggestDetections(techniqueId: string, sourceType?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql'): DetectionSuggestion;
|
|
195
|
+
export declare function suggestDetections(techniqueId: string, sourceType?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect'): DetectionSuggestion;
|
|
196
196
|
/**
|
|
197
197
|
* Generate an ATT&CK Navigator layer from detection coverage.
|
|
198
198
|
*/
|
|
@@ -232,7 +232,7 @@ export declare function searchDetectionList(query: string, limit?: number): Dete
|
|
|
232
232
|
/**
|
|
233
233
|
* List detections by source with optional name filter, returning lightweight results.
|
|
234
234
|
*/
|
|
235
|
-
export declare function listDetectionsBySourceLight(sourceType: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql', nameFilter?: string, limit?: number): DetectionListItem[];
|
|
235
|
+
export declare function listDetectionsBySourceLight(sourceType: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect', nameFilter?: string, limit?: number): DetectionListItem[];
|
|
236
236
|
/**
|
|
237
237
|
* Compare detections across sources for a topic.
|
|
238
238
|
*/
|
|
@@ -240,7 +240,7 @@ export declare function compareDetectionsBySource(topic: string, limit?: number)
|
|
|
240
240
|
/**
|
|
241
241
|
* Get detection names and IDs matching a pattern, grouped by source.
|
|
242
242
|
*/
|
|
243
|
-
export declare function getDetectionNamesByPattern(pattern: string, sourceType?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql'): {
|
|
243
|
+
export declare function getDetectionNamesByPattern(pattern: string, sourceType?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect'): {
|
|
244
244
|
source: string;
|
|
245
245
|
detections: Array<{
|
|
246
246
|
name: string;
|
package/dist/db/detections.js
CHANGED
|
@@ -371,6 +371,8 @@ export function getStats() {
|
|
|
371
371
|
const elastic = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'elastic'").get().count;
|
|
372
372
|
const kql = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'kql'").get().count;
|
|
373
373
|
const sublime = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'sublime'").get().count;
|
|
374
|
+
const crowdstrikeCql = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'crowdstrike_cql'").get().count;
|
|
375
|
+
const jamfProtect = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'jamf_protect'").get().count;
|
|
374
376
|
// Count by severity
|
|
375
377
|
const severityRows = database.prepare(`
|
|
376
378
|
SELECT severity, COUNT(*) as count FROM detections
|
|
@@ -449,6 +451,8 @@ export function getStats() {
|
|
|
449
451
|
elastic,
|
|
450
452
|
kql,
|
|
451
453
|
sublime,
|
|
454
|
+
crowdstrike_cql: crowdstrikeCql,
|
|
455
|
+
jamf_protect: jamfProtect,
|
|
452
456
|
by_severity,
|
|
453
457
|
by_logsource_product,
|
|
454
458
|
mitre_coverage,
|
|
@@ -1264,7 +1268,15 @@ export function countDetectionsBySource(topic) {
|
|
|
1264
1268
|
GROUP BY d.source_type
|
|
1265
1269
|
`);
|
|
1266
1270
|
const rows = stmt.all(topic);
|
|
1267
|
-
const result = {
|
|
1271
|
+
const result = {
|
|
1272
|
+
sigma: 0,
|
|
1273
|
+
splunk_escu: 0,
|
|
1274
|
+
elastic: 0,
|
|
1275
|
+
kql: 0,
|
|
1276
|
+
sublime: 0,
|
|
1277
|
+
crowdstrike_cql: 0,
|
|
1278
|
+
jamf_protect: 0,
|
|
1279
|
+
};
|
|
1268
1280
|
for (const row of rows) {
|
|
1269
1281
|
result[row.source_type] = row.count;
|
|
1270
1282
|
}
|
package/dist/db.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export declare function insertDetection(detection: Detection): void;
|
|
|
8
8
|
export declare function searchDetections(query: string, limit?: number): Detection[];
|
|
9
9
|
export declare function getDetectionById(id: string): Detection | null;
|
|
10
10
|
export declare function listDetections(limit?: number, offset?: number): Detection[];
|
|
11
|
-
export declare function listBySource(sourceType: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql', limit?: number, offset?: number): Detection[];
|
|
11
|
+
export declare function listBySource(sourceType: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect', limit?: number, offset?: number): Detection[];
|
|
12
12
|
export declare function listByMitre(techniqueId: string, limit?: number, offset?: number): Detection[];
|
|
13
13
|
export declare function listByLogsource(category?: string, product?: string, service?: string, limit?: number, offset?: number): Detection[];
|
|
14
14
|
export declare function listBySeverity(level: string, limit?: number, offset?: number): Detection[];
|
|
@@ -118,9 +118,9 @@ export interface SourceComparisonResult {
|
|
|
118
118
|
};
|
|
119
119
|
}
|
|
120
120
|
export declare function searchDetectionList(query: string, limit?: number): DetectionListItem[];
|
|
121
|
-
export declare function listDetectionsBySourceLight(sourceType: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql', nameFilter?: string, limit?: number): DetectionListItem[];
|
|
121
|
+
export declare function listDetectionsBySourceLight(sourceType: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect', nameFilter?: string, limit?: number): DetectionListItem[];
|
|
122
122
|
export declare function compareDetectionsBySource(topic: string, limit?: number): SourceComparisonResult;
|
|
123
|
-
export declare function getDetectionNamesByPattern(pattern: string, sourceType?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql'): {
|
|
123
|
+
export declare function getDetectionNamesByPattern(pattern: string, sourceType?: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect'): {
|
|
124
124
|
source: string;
|
|
125
125
|
detections: Array<{
|
|
126
126
|
name: string;
|
package/dist/db.js
CHANGED
|
@@ -430,6 +430,8 @@ export function getStats() {
|
|
|
430
430
|
const elastic = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'elastic'").get().count;
|
|
431
431
|
const kql = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'kql'").get().count;
|
|
432
432
|
const sublime = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'sublime'").get().count;
|
|
433
|
+
const crowdstrikeCql = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'crowdstrike_cql'").get().count;
|
|
434
|
+
const jamfProtect = database.prepare("SELECT COUNT(*) as count FROM detections WHERE source_type = 'jamf_protect'").get().count;
|
|
433
435
|
// Count by severity
|
|
434
436
|
const severityRows = database.prepare(`
|
|
435
437
|
SELECT severity, COUNT(*) as count FROM detections
|
|
@@ -508,6 +510,8 @@ export function getStats() {
|
|
|
508
510
|
elastic,
|
|
509
511
|
kql,
|
|
510
512
|
sublime,
|
|
513
|
+
crowdstrike_cql: crowdstrikeCql,
|
|
514
|
+
jamf_protect: jamfProtect,
|
|
511
515
|
by_severity,
|
|
512
516
|
by_logsource_product,
|
|
513
517
|
mitre_coverage,
|
|
@@ -1147,7 +1151,15 @@ export function countDetectionsBySource(topic) {
|
|
|
1147
1151
|
GROUP BY d.source_type
|
|
1148
1152
|
`);
|
|
1149
1153
|
const rows = stmt.all(topic);
|
|
1150
|
-
const result = {
|
|
1154
|
+
const result = {
|
|
1155
|
+
sigma: 0,
|
|
1156
|
+
splunk_escu: 0,
|
|
1157
|
+
elastic: 0,
|
|
1158
|
+
kql: 0,
|
|
1159
|
+
sublime: 0,
|
|
1160
|
+
crowdstrike_cql: 0,
|
|
1161
|
+
jamf_protect: 0,
|
|
1162
|
+
};
|
|
1151
1163
|
for (const row of rows) {
|
|
1152
1164
|
result[row.source_type] = row.count;
|
|
1153
1165
|
}
|
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
// Resource definitions and handlers
|
|
2
2
|
import { getStats, analyzeCoverage, identifyGaps, listByMitre, } from '../db/detections.js';
|
|
3
3
|
import { openEntity, listDecisions, listLearnings, getKnowledgeStats, } from '../db/knowledge.js';
|
|
4
|
+
const SOURCE_TYPES = ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'];
|
|
5
|
+
function countBySource(detections) {
|
|
6
|
+
const counts = Object.fromEntries(SOURCE_TYPES.map(source => [source, 0]));
|
|
7
|
+
for (const detection of detections) {
|
|
8
|
+
if (SOURCE_TYPES.includes(detection.source_type)) {
|
|
9
|
+
counts[detection.source_type]++;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return counts;
|
|
13
|
+
}
|
|
4
14
|
// =============================================================================
|
|
5
15
|
// STATIC RESOURCES
|
|
6
16
|
// =============================================================================
|
|
@@ -39,7 +49,7 @@ export const resources = [
|
|
|
39
49
|
{
|
|
40
50
|
uri: 'detection://sources/comparison',
|
|
41
51
|
name: 'Source Comparison',
|
|
42
|
-
description: 'Quick comparison of detection counts across sources
|
|
52
|
+
description: 'Quick comparison of detection counts across all sources',
|
|
43
53
|
mimeType: 'application/json',
|
|
44
54
|
},
|
|
45
55
|
// Knowledge graph resources
|
|
@@ -112,12 +122,7 @@ function getTechniqueResource(techniqueId) {
|
|
|
112
122
|
severity: d.severity,
|
|
113
123
|
description: d.description?.substring(0, 200) + (d.description && d.description.length > 200 ? '...' : ''),
|
|
114
124
|
})),
|
|
115
|
-
by_source:
|
|
116
|
-
sigma: detections.filter(d => d.source_type === 'sigma').length,
|
|
117
|
-
splunk_escu: detections.filter(d => d.source_type === 'splunk_escu').length,
|
|
118
|
-
elastic: detections.filter(d => d.source_type === 'elastic').length,
|
|
119
|
-
kql: detections.filter(d => d.source_type === 'kql').length,
|
|
120
|
-
},
|
|
125
|
+
by_source: countBySource(detections),
|
|
121
126
|
};
|
|
122
127
|
}
|
|
123
128
|
/**
|
|
@@ -228,12 +233,7 @@ function getStaticResourceContent(uri) {
|
|
|
228
233
|
const stats = getStats();
|
|
229
234
|
return {
|
|
230
235
|
total_detections: stats.total,
|
|
231
|
-
by_source:
|
|
232
|
-
sigma: stats.sigma,
|
|
233
|
-
splunk_escu: stats.splunk_escu,
|
|
234
|
-
elastic: stats.elastic,
|
|
235
|
-
kql: stats.kql,
|
|
236
|
-
},
|
|
236
|
+
by_source: Object.fromEntries(SOURCE_TYPES.map(source => [source, stats[source]])),
|
|
237
237
|
by_severity: stats.by_severity,
|
|
238
238
|
mitre_coverage: stats.mitre_coverage,
|
|
239
239
|
by_mitre_tactic: stats.by_mitre_tactic,
|
package/dist/index.js
CHANGED
|
@@ -30,18 +30,19 @@ const STORY_PATHS = parsePaths(process.env.STORY_PATHS);
|
|
|
30
30
|
const KQL_PATHS = parsePaths(process.env.KQL_PATHS);
|
|
31
31
|
const SUBLIME_PATHS = parsePaths(process.env.SUBLIME_PATHS);
|
|
32
32
|
const CQL_HUB_PATHS = parsePaths(process.env.CQL_HUB_PATHS);
|
|
33
|
+
const JAMF_PROTECT_PATHS = parsePaths(process.env.JAMF_PROTECT_PATHS);
|
|
33
34
|
const ATTACK_STIX_PATH = process.env.ATTACK_STIX_PATH;
|
|
34
35
|
// Auto-index on startup if paths are configured and DB is empty
|
|
35
36
|
function autoIndex() {
|
|
36
|
-
if (SIGMA_PATHS.length === 0 && SPLUNK_PATHS.length === 0 && ELASTIC_PATHS.length === 0 && KQL_PATHS.length === 0 && SUBLIME_PATHS.length === 0 && CQL_HUB_PATHS.length === 0) {
|
|
37
|
+
if (SIGMA_PATHS.length === 0 && SPLUNK_PATHS.length === 0 && ELASTIC_PATHS.length === 0 && KQL_PATHS.length === 0 && SUBLIME_PATHS.length === 0 && CQL_HUB_PATHS.length === 0 && JAMF_PROTECT_PATHS.length === 0) {
|
|
37
38
|
return;
|
|
38
39
|
}
|
|
39
40
|
initDb();
|
|
40
41
|
if (needsIndexing()) {
|
|
41
42
|
console.error('[security-detections-mcp] Auto-indexing detections...');
|
|
42
|
-
const result = indexDetections(SIGMA_PATHS, SPLUNK_PATHS, STORY_PATHS, ELASTIC_PATHS, KQL_PATHS, SUBLIME_PATHS, CQL_HUB_PATHS);
|
|
43
|
+
const result = indexDetections(SIGMA_PATHS, SPLUNK_PATHS, STORY_PATHS, ELASTIC_PATHS, KQL_PATHS, SUBLIME_PATHS, CQL_HUB_PATHS, JAMF_PROTECT_PATHS);
|
|
43
44
|
let msg = `[security-detections-mcp] Indexed ${result.total} detections`;
|
|
44
|
-
msg += ` (${result.sigma_indexed} Sigma, ${result.splunk_indexed} Splunk, ${result.elastic_indexed} Elastic, ${result.kql_indexed} KQL, ${result.sublime_indexed} Sublime, ${result.cql_hub_indexed} CrowdStrike CQL)`;
|
|
45
|
+
msg += ` (${result.sigma_indexed} Sigma, ${result.splunk_indexed} Splunk, ${result.elastic_indexed} Elastic, ${result.kql_indexed} KQL, ${result.sublime_indexed} Sublime, ${result.cql_hub_indexed} CrowdStrike CQL, ${result.jamf_protect_indexed} Jamf Protect)`;
|
|
45
46
|
if (result.stories_indexed > 0) {
|
|
46
47
|
msg += `, ${result.stories_indexed} stories`;
|
|
47
48
|
}
|
package/dist/indexer.d.ts
CHANGED
|
@@ -11,9 +11,11 @@ export interface IndexResult {
|
|
|
11
11
|
sublime_failed: number;
|
|
12
12
|
cql_hub_indexed: number;
|
|
13
13
|
cql_hub_failed: number;
|
|
14
|
+
jamf_protect_indexed: number;
|
|
15
|
+
jamf_protect_failed: number;
|
|
14
16
|
stories_indexed: number;
|
|
15
17
|
stories_failed: number;
|
|
16
18
|
total: number;
|
|
17
19
|
}
|
|
18
|
-
export declare function indexDetections(sigmaPaths: string[], splunkPaths: string[], storyPaths?: string[], elasticPaths?: string[], kqlPaths?: string[], sublimePaths?: string[], cqlHubPaths?: string[]): IndexResult;
|
|
20
|
+
export declare function indexDetections(sigmaPaths: string[], splunkPaths: string[], storyPaths?: string[], elasticPaths?: string[], kqlPaths?: string[], sublimePaths?: string[], cqlHubPaths?: string[], jamfProtectPaths?: string[]): IndexResult;
|
|
19
21
|
export declare function needsIndexing(): boolean;
|
package/dist/indexer.js
CHANGED
|
@@ -7,6 +7,7 @@ import { parseElasticFile } from './parsers/elastic.js';
|
|
|
7
7
|
import { parseKqlFile, parseRawKqlFile } from './parsers/kql.js';
|
|
8
8
|
import { parseSublimeFile } from './parsers/sublime.js';
|
|
9
9
|
import { parseCqlHubFile } from './parsers/crowdstrike_cql.js';
|
|
10
|
+
import { parseJamfProtectFile } from './parsers/jamf_protect.js';
|
|
10
11
|
import { recreateDb, insertDetection, insertStory, getDetectionCount, initDb } from './db.js';
|
|
11
12
|
// Recursively find all YAML files in a directory
|
|
12
13
|
function findYamlFiles(dir) {
|
|
@@ -110,7 +111,7 @@ function findKqlFiles(dir) {
|
|
|
110
111
|
}
|
|
111
112
|
return files;
|
|
112
113
|
}
|
|
113
|
-
export function indexDetections(sigmaPaths, splunkPaths, storyPaths = [], elasticPaths = [], kqlPaths = [], sublimePaths = [], cqlHubPaths = []) {
|
|
114
|
+
export function indexDetections(sigmaPaths, splunkPaths, storyPaths = [], elasticPaths = [], kqlPaths = [], sublimePaths = [], cqlHubPaths = [], jamfProtectPaths = []) {
|
|
114
115
|
// Recreate DB to ensure schema is up to date
|
|
115
116
|
recreateDb();
|
|
116
117
|
initDb();
|
|
@@ -126,6 +127,8 @@ export function indexDetections(sigmaPaths, splunkPaths, storyPaths = [], elasti
|
|
|
126
127
|
let sublime_failed = 0;
|
|
127
128
|
let cql_hub_indexed = 0;
|
|
128
129
|
let cql_hub_failed = 0;
|
|
130
|
+
let jamf_protect_indexed = 0;
|
|
131
|
+
let jamf_protect_failed = 0;
|
|
129
132
|
let stories_indexed = 0;
|
|
130
133
|
let stories_failed = 0;
|
|
131
134
|
// Index Sigma rules
|
|
@@ -224,6 +227,20 @@ export function indexDetections(sigmaPaths, splunkPaths, storyPaths = [], elasti
|
|
|
224
227
|
}
|
|
225
228
|
}
|
|
226
229
|
}
|
|
230
|
+
// Index Jamf Protect custom analytic detections (YAML with NSPredicate filter)
|
|
231
|
+
for (const basePath of jamfProtectPaths) {
|
|
232
|
+
const files = findYamlFiles(basePath);
|
|
233
|
+
for (const file of files) {
|
|
234
|
+
const detection = parseJamfProtectFile(file);
|
|
235
|
+
if (detection) {
|
|
236
|
+
insertDetection(detection);
|
|
237
|
+
jamf_protect_indexed++;
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
jamf_protect_failed++;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
227
244
|
// Index Splunk Analytic Stories (optional)
|
|
228
245
|
for (const basePath of storyPaths) {
|
|
229
246
|
const files = findYamlFiles(basePath);
|
|
@@ -251,9 +268,11 @@ export function indexDetections(sigmaPaths, splunkPaths, storyPaths = [], elasti
|
|
|
251
268
|
sublime_failed,
|
|
252
269
|
cql_hub_indexed,
|
|
253
270
|
cql_hub_failed,
|
|
271
|
+
jamf_protect_indexed,
|
|
272
|
+
jamf_protect_failed,
|
|
254
273
|
stories_indexed,
|
|
255
274
|
stories_failed,
|
|
256
|
-
total: sigma_indexed + splunk_indexed + elastic_indexed + kql_indexed + sublime_indexed + cql_hub_indexed,
|
|
275
|
+
total: sigma_indexed + splunk_indexed + elastic_indexed + kql_indexed + sublime_indexed + cql_hub_indexed + jamf_protect_indexed,
|
|
257
276
|
};
|
|
258
277
|
}
|
|
259
278
|
export function needsIndexing() {
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { parse as parseYaml } from 'yaml';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
// MITRE tactic normalization: Jamf's `MitreCategories` mixes CamelCase (`CredentialAccess`),
|
|
5
|
+
// spaced (`Credential Access`), and Jamf-specific labels (`LivingOffTheLand`). Normalize the
|
|
6
|
+
// input by lowercasing and stripping whitespace, then map to MITRE tactic kebab-case.
|
|
7
|
+
const TACTIC_MAP = {
|
|
8
|
+
reconnaissance: 'reconnaissance',
|
|
9
|
+
resourcedevelopment: 'resource-development',
|
|
10
|
+
initialaccess: 'initial-access',
|
|
11
|
+
execution: 'execution',
|
|
12
|
+
persistence: 'persistence',
|
|
13
|
+
privilegeescalation: 'privilege-escalation',
|
|
14
|
+
defenseevasion: 'defense-evasion',
|
|
15
|
+
credentialaccess: 'credential-access',
|
|
16
|
+
discovery: 'discovery',
|
|
17
|
+
lateralmovement: 'lateral-movement',
|
|
18
|
+
collection: 'collection',
|
|
19
|
+
commandandcontrol: 'command-and-control',
|
|
20
|
+
exfiltration: 'exfiltration',
|
|
21
|
+
impact: 'impact',
|
|
22
|
+
};
|
|
23
|
+
// Best-effort mapping for Jamf-specific labels → MITRE techniques + tactics.
|
|
24
|
+
// Claims are approximate and lossy but give the coverage-analysis tooling something to work with.
|
|
25
|
+
const JAMF_LABEL_MAP = {
|
|
26
|
+
livingofftheland: {
|
|
27
|
+
techniques: ['T1059', 'T1218'],
|
|
28
|
+
tactics: ['execution', 'defense-evasion'],
|
|
29
|
+
},
|
|
30
|
+
knownmalware: {
|
|
31
|
+
techniques: ['T1204.002'],
|
|
32
|
+
tactics: ['execution'],
|
|
33
|
+
},
|
|
34
|
+
knownmaliciousfile: {
|
|
35
|
+
techniques: ['T1204.002'],
|
|
36
|
+
tactics: ['execution'],
|
|
37
|
+
},
|
|
38
|
+
adversaryinthemiddle: {
|
|
39
|
+
techniques: ['T1557'],
|
|
40
|
+
tactics: ['credential-access'],
|
|
41
|
+
},
|
|
42
|
+
credentialharvesting: {
|
|
43
|
+
techniques: ['T1555'],
|
|
44
|
+
tactics: ['credential-access'],
|
|
45
|
+
},
|
|
46
|
+
exploitation: {
|
|
47
|
+
techniques: ['T1203', 'T1068'],
|
|
48
|
+
tactics: ['execution', 'privilege-escalation'],
|
|
49
|
+
},
|
|
50
|
+
systemtampering: {
|
|
51
|
+
techniques: ['T1565'],
|
|
52
|
+
tactics: ['impact'],
|
|
53
|
+
},
|
|
54
|
+
visibility: {
|
|
55
|
+
techniques: [],
|
|
56
|
+
tactics: ['discovery'],
|
|
57
|
+
},
|
|
58
|
+
systemvisibility: {
|
|
59
|
+
techniques: [],
|
|
60
|
+
tactics: ['discovery'],
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
const TECHNIQUE_ID_RE = /^t(\d{4}(?:\.\d{3})?)$/;
|
|
64
|
+
function extractMitre(raw) {
|
|
65
|
+
const ids = new Set();
|
|
66
|
+
const tactics = new Set();
|
|
67
|
+
const rawLabels = [];
|
|
68
|
+
if (!raw)
|
|
69
|
+
return { ids: [], tactics: [], rawLabels: [] };
|
|
70
|
+
for (const entry of raw) {
|
|
71
|
+
if (typeof entry !== 'string')
|
|
72
|
+
continue;
|
|
73
|
+
rawLabels.push(entry);
|
|
74
|
+
const normalized = entry.toLowerCase().replace(/[\s_-]/g, '');
|
|
75
|
+
const techMatch = normalized.match(TECHNIQUE_ID_RE);
|
|
76
|
+
if (techMatch) {
|
|
77
|
+
ids.add(`T${techMatch[1].toUpperCase()}`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (TACTIC_MAP[normalized]) {
|
|
81
|
+
tactics.add(TACTIC_MAP[normalized]);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const labelMapping = JAMF_LABEL_MAP[normalized];
|
|
85
|
+
if (labelMapping) {
|
|
86
|
+
labelMapping.techniques.forEach(t => ids.add(t));
|
|
87
|
+
labelMapping.tactics.forEach(t => tactics.add(t));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { ids: [...ids], tactics: [...tactics], rawLabels };
|
|
91
|
+
}
|
|
92
|
+
function extractDataSources(inputType) {
|
|
93
|
+
const base = 'macOS Endpoint Security';
|
|
94
|
+
switch (inputType) {
|
|
95
|
+
case 'GPProcessEvent':
|
|
96
|
+
return ['Process Events', base];
|
|
97
|
+
case 'GPFSEvent':
|
|
98
|
+
return ['File System Events', base];
|
|
99
|
+
case 'GPKeylogRegisterEvent':
|
|
100
|
+
return ['Keylog Register Events', base];
|
|
101
|
+
case 'GPUSBEvent':
|
|
102
|
+
return ['USB Events', base];
|
|
103
|
+
default:
|
|
104
|
+
return inputType ? [inputType, base] : [base];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Pull process names from NSPredicate filter text.
|
|
108
|
+
// Jamf filters reference processes via `path.lastPathComponent == "<name>"` or
|
|
109
|
+
// `signingInfo.appid == "com.apple.<name>"`.
|
|
110
|
+
function extractProcessNames(filter) {
|
|
111
|
+
const names = new Set();
|
|
112
|
+
const lastComponentRe = /lastPathComponent\s*==\s*"([^"]+)"/gi;
|
|
113
|
+
let m;
|
|
114
|
+
while ((m = lastComponentRe.exec(filter)) !== null) {
|
|
115
|
+
if (m[1])
|
|
116
|
+
names.add(m[1]);
|
|
117
|
+
}
|
|
118
|
+
const appidRe = /signingInfo\.appid\s*==\s*"com\.apple\.([A-Za-z0-9_.-]+)"/gi;
|
|
119
|
+
while ((m = appidRe.exec(filter)) !== null) {
|
|
120
|
+
if (m[1])
|
|
121
|
+
names.add(m[1]);
|
|
122
|
+
}
|
|
123
|
+
return [...names];
|
|
124
|
+
}
|
|
125
|
+
// Pull quoted absolute-path references from NSPredicate filter text.
|
|
126
|
+
function extractFilePaths(filter) {
|
|
127
|
+
const paths = new Set();
|
|
128
|
+
const re = /"(\/[^"\s]+)"/g;
|
|
129
|
+
let m;
|
|
130
|
+
while ((m = re.exec(filter)) !== null) {
|
|
131
|
+
const candidate = m[1];
|
|
132
|
+
if (candidate.length < 3)
|
|
133
|
+
continue;
|
|
134
|
+
// Skip bundle identifiers and uri-ish schemes.
|
|
135
|
+
if (!candidate.startsWith('/'))
|
|
136
|
+
continue;
|
|
137
|
+
paths.add(candidate);
|
|
138
|
+
}
|
|
139
|
+
return [...paths];
|
|
140
|
+
}
|
|
141
|
+
function normalizeSeverity(severity) {
|
|
142
|
+
if (!severity)
|
|
143
|
+
return null;
|
|
144
|
+
return severity.toLowerCase();
|
|
145
|
+
}
|
|
146
|
+
function generateId(filePath, name, uuid) {
|
|
147
|
+
// Upstream Jamf content occasionally reuses the same uuid across different files
|
|
148
|
+
// (e.g. openclaw_directory_created vs openclaw_onboard both declare the same uuid).
|
|
149
|
+
// Incorporate the filename into the hash so each file produces a unique id; keep
|
|
150
|
+
// the uuid as a prefix when available so it's still recognizable.
|
|
151
|
+
const basename = filePath.split('/').pop() || filePath;
|
|
152
|
+
const hash = createHash('sha256').update(`${filePath}:${name}`).digest('hex').substring(0, 12);
|
|
153
|
+
if (uuid) {
|
|
154
|
+
return `jamf-${uuid}-${hash}`;
|
|
155
|
+
}
|
|
156
|
+
return `jamf-${createHash('sha256').update(`${basename}:${name}`).digest('hex').substring(0, 32)}`;
|
|
157
|
+
}
|
|
158
|
+
export function parseJamfProtectFile(filePath) {
|
|
159
|
+
try {
|
|
160
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
161
|
+
const rule = parseYaml(content);
|
|
162
|
+
if (!rule || typeof rule !== 'object')
|
|
163
|
+
return null;
|
|
164
|
+
if (!rule.name || !rule.filter)
|
|
165
|
+
return null;
|
|
166
|
+
const mitre = extractMitre(rule.MitreCategories ?? null);
|
|
167
|
+
const baseTags = Array.isArray(rule.tags)
|
|
168
|
+
? rule.tags.filter((t) => typeof t === 'string')
|
|
169
|
+
: [];
|
|
170
|
+
const categoryTags = Array.isArray(rule.categories)
|
|
171
|
+
? rule.categories.filter((t) => typeof t === 'string')
|
|
172
|
+
: [];
|
|
173
|
+
// Preserve the raw Jamf MITRE labels so their original semantics stay searchable.
|
|
174
|
+
const tags = [...new Set([...baseTags, ...categoryTags, ...mitre.rawLabels])];
|
|
175
|
+
const description = rule.longDescription || rule.shortDescription || '';
|
|
176
|
+
const id = generateId(filePath, rule.name, rule.uuid);
|
|
177
|
+
const detection = {
|
|
178
|
+
id,
|
|
179
|
+
name: rule.label || rule.name,
|
|
180
|
+
description,
|
|
181
|
+
query: rule.filter,
|
|
182
|
+
source_type: 'jamf_protect',
|
|
183
|
+
mitre_ids: mitre.ids,
|
|
184
|
+
logsource_category: rule.inputType || null,
|
|
185
|
+
logsource_product: 'macos',
|
|
186
|
+
logsource_service: 'jamf_protect',
|
|
187
|
+
severity: normalizeSeverity(rule.severity),
|
|
188
|
+
status: null,
|
|
189
|
+
author: 'Jamf',
|
|
190
|
+
date_created: null,
|
|
191
|
+
date_modified: null,
|
|
192
|
+
references: [],
|
|
193
|
+
falsepositives: [],
|
|
194
|
+
tags,
|
|
195
|
+
file_path: filePath,
|
|
196
|
+
raw_yaml: content,
|
|
197
|
+
cves: [],
|
|
198
|
+
analytic_stories: [],
|
|
199
|
+
data_sources: extractDataSources(rule.inputType),
|
|
200
|
+
detection_type: 'TTP',
|
|
201
|
+
asset_type: 'Endpoint',
|
|
202
|
+
security_domain: 'endpoint',
|
|
203
|
+
process_names: extractProcessNames(rule.filter),
|
|
204
|
+
file_paths: extractFilePaths(rule.filter),
|
|
205
|
+
registry_paths: [],
|
|
206
|
+
mitre_tactics: mitre.tactics,
|
|
207
|
+
platforms: ['macos'],
|
|
208
|
+
kql_category: null,
|
|
209
|
+
kql_tags: [],
|
|
210
|
+
kql_keywords: [],
|
|
211
|
+
sublime_attack_types: [],
|
|
212
|
+
sublime_detection_methods: [],
|
|
213
|
+
sublime_tactics: [],
|
|
214
|
+
};
|
|
215
|
+
return detection;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
package/dist/resources/index.js
CHANGED
|
@@ -9,6 +9,16 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { getStats, analyzeCoverage, identifyGaps, listByMitre, listByMitreTactic, listBySource, generateNavigatorLayer, isStixLoaded, getActorByName, getActorCoverage, getActorTechniques, getSoftwareForActor, } from '../db/index.js';
|
|
11
11
|
import { openEntity, listDecisions, listLearnings, getKnowledgeStats, } from '../db/knowledge.js';
|
|
12
|
+
const SOURCE_TYPES = ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'];
|
|
13
|
+
function countBySource(detections) {
|
|
14
|
+
const counts = Object.fromEntries(SOURCE_TYPES.map((source) => [source, 0]));
|
|
15
|
+
for (const detection of detections) {
|
|
16
|
+
if (SOURCE_TYPES.includes(detection.source_type)) {
|
|
17
|
+
counts[detection.source_type]++;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return counts;
|
|
21
|
+
}
|
|
12
22
|
// =============================================================================
|
|
13
23
|
// STATIC RESOURCES
|
|
14
24
|
// =============================================================================
|
|
@@ -47,7 +57,7 @@ export const resources = [
|
|
|
47
57
|
{
|
|
48
58
|
uri: 'detection://sources/comparison',
|
|
49
59
|
name: 'Source Comparison',
|
|
50
|
-
description: 'Quick comparison of detection counts across sources
|
|
60
|
+
description: 'Quick comparison of detection counts across all sources',
|
|
51
61
|
mimeType: 'application/json',
|
|
52
62
|
},
|
|
53
63
|
// Navigator layer
|
|
@@ -96,7 +106,7 @@ export const resourceTemplates = [
|
|
|
96
106
|
{
|
|
97
107
|
uriTemplate: 'detection://source/{sourceType}',
|
|
98
108
|
name: 'Source Statistics',
|
|
99
|
-
description: 'Get statistics and detections for a specific source type
|
|
109
|
+
description: 'Get statistics and detections for a specific source type',
|
|
100
110
|
mimeType: 'application/json',
|
|
101
111
|
},
|
|
102
112
|
{
|
|
@@ -114,7 +124,7 @@ export const resourceTemplates = [
|
|
|
114
124
|
{
|
|
115
125
|
uriTemplate: 'detection://navigator/layer/{sourceType}',
|
|
116
126
|
name: 'Source-Filtered Navigator Layer',
|
|
117
|
-
description: 'ATT&CK Navigator layer filtered by detection source
|
|
127
|
+
description: 'ATT&CK Navigator layer filtered by detection source',
|
|
118
128
|
mimeType: 'application/json',
|
|
119
129
|
},
|
|
120
130
|
{
|
|
@@ -152,12 +162,7 @@ function getTechniqueResource(techniqueId) {
|
|
|
152
162
|
description: d.description?.substring(0, 200) +
|
|
153
163
|
(d.description && d.description.length > 200 ? '...' : ''),
|
|
154
164
|
})),
|
|
155
|
-
by_source:
|
|
156
|
-
sigma: detections.filter((d) => d.source_type === 'sigma').length,
|
|
157
|
-
splunk_escu: detections.filter((d) => d.source_type === 'splunk_escu').length,
|
|
158
|
-
elastic: detections.filter((d) => d.source_type === 'elastic').length,
|
|
159
|
-
kql: detections.filter((d) => d.source_type === 'kql').length,
|
|
160
|
-
},
|
|
165
|
+
by_source: countBySource(detections),
|
|
161
166
|
};
|
|
162
167
|
}
|
|
163
168
|
/**
|
|
@@ -189,19 +194,14 @@ function getTacticResource(tactic) {
|
|
|
189
194
|
severity: d.severity,
|
|
190
195
|
mitre_ids: d.mitre_ids,
|
|
191
196
|
})),
|
|
192
|
-
by_source:
|
|
193
|
-
sigma: detections.filter((d) => d.source_type === 'sigma').length,
|
|
194
|
-
splunk_escu: detections.filter((d) => d.source_type === 'splunk_escu').length,
|
|
195
|
-
elastic: detections.filter((d) => d.source_type === 'elastic').length,
|
|
196
|
-
kql: detections.filter((d) => d.source_type === 'kql').length,
|
|
197
|
-
},
|
|
197
|
+
by_source: countBySource(detections),
|
|
198
198
|
};
|
|
199
199
|
}
|
|
200
200
|
/**
|
|
201
201
|
* Get statistics and sample detections for a source type
|
|
202
202
|
*/
|
|
203
203
|
function getSourceResource(sourceType) {
|
|
204
|
-
const validSources = [
|
|
204
|
+
const validSources = [...SOURCE_TYPES];
|
|
205
205
|
const normalizedSource = sourceType.toLowerCase();
|
|
206
206
|
if (!validSources.includes(normalizedSource)) {
|
|
207
207
|
return {
|
|
@@ -388,12 +388,7 @@ function getStaticResourceContent(uri) {
|
|
|
388
388
|
const stats = getStats();
|
|
389
389
|
return {
|
|
390
390
|
total_detections: stats.total,
|
|
391
|
-
by_source:
|
|
392
|
-
sigma: stats.sigma,
|
|
393
|
-
splunk_escu: stats.splunk_escu,
|
|
394
|
-
elastic: stats.elastic,
|
|
395
|
-
kql: stats.kql,
|
|
396
|
-
},
|
|
391
|
+
by_source: Object.fromEntries(SOURCE_TYPES.map((source) => [source, stats[source]])),
|
|
397
392
|
by_severity: stats.by_severity,
|
|
398
393
|
mitre_coverage: stats.mitre_coverage,
|
|
399
394
|
by_mitre_tactic: stats.by_mitre_tactic,
|
package/dist/server.js
CHANGED
|
@@ -133,7 +133,7 @@ export function createServer() {
|
|
|
133
133
|
break;
|
|
134
134
|
case 'source_type':
|
|
135
135
|
case 'source':
|
|
136
|
-
values = ['sigma', 'splunk_escu', 'elastic', 'kql'].filter(s => s.toLowerCase().startsWith(prefix.toLowerCase()));
|
|
136
|
+
values = ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'].filter(s => s.toLowerCase().startsWith(prefix.toLowerCase()));
|
|
137
137
|
break;
|
|
138
138
|
case 'threat_profile':
|
|
139
139
|
case 'profile':
|
|
@@ -23,6 +23,7 @@ import { requestAnalysis, getSamplingStatus } from '../../handlers/sampling.js';
|
|
|
23
23
|
// ============================================================================
|
|
24
24
|
// Helper Functions
|
|
25
25
|
// ============================================================================
|
|
26
|
+
const SOURCE_TYPES = ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'];
|
|
26
27
|
/**
|
|
27
28
|
* Format current timestamp for storage
|
|
28
29
|
*/
|
|
@@ -221,7 +222,7 @@ Use this for executive-level reporting on detection posture.`,
|
|
|
221
222
|
// Compare detections across sources for high-priority techniques
|
|
222
223
|
const highPriorityTechniques = ['T1059.001', 'T1003.001', 'T1547.001', 'T1486', 'T1078'];
|
|
223
224
|
const sourceStats = {};
|
|
224
|
-
for (const source of
|
|
225
|
+
for (const source of SOURCE_TYPES) {
|
|
225
226
|
const detections = listBySource(source, 9999);
|
|
226
227
|
sourceStats[source] = detections.length;
|
|
227
228
|
}
|
|
@@ -368,7 +369,7 @@ Use this to understand the strengths/weaknesses of different detection sources a
|
|
|
368
369
|
const includeQualityAnalysis = args.include_quality_analysis !== false;
|
|
369
370
|
const sessionId = args.session_id;
|
|
370
371
|
ensureDynamicSchema();
|
|
371
|
-
const sources =
|
|
372
|
+
const sources = SOURCE_TYPES;
|
|
372
373
|
// Get overall stats by source
|
|
373
374
|
const sourceStats = {};
|
|
374
375
|
for (const source of sources) {
|
|
@@ -17,6 +17,9 @@ const SPLUNK_PATHS = parsePaths(process.env.SPLUNK_PATHS);
|
|
|
17
17
|
const ELASTIC_PATHS = parsePaths(process.env.ELASTIC_PATHS);
|
|
18
18
|
const STORY_PATHS = parsePaths(process.env.STORY_PATHS);
|
|
19
19
|
const KQL_PATHS = parsePaths(process.env.KQL_PATHS);
|
|
20
|
+
const SUBLIME_PATHS = parsePaths(process.env.SUBLIME_PATHS);
|
|
21
|
+
const CQL_HUB_PATHS = parsePaths(process.env.CQL_HUB_PATHS);
|
|
22
|
+
const JAMF_PROTECT_PATHS = parsePaths(process.env.JAMF_PROTECT_PATHS);
|
|
20
23
|
// =============================================================================
|
|
21
24
|
// Saved Query Tools
|
|
22
25
|
// =============================================================================
|
|
@@ -154,10 +157,10 @@ const rebuildIndexTool = defineTool({
|
|
|
154
157
|
handler: async (args) => {
|
|
155
158
|
const confirmArg = args?.confirm;
|
|
156
159
|
const skipElicitation = args?.skip_elicitation;
|
|
157
|
-
if (SIGMA_PATHS.length === 0 && SPLUNK_PATHS.length === 0 && ELASTIC_PATHS.length === 0 && KQL_PATHS.length === 0) {
|
|
160
|
+
if (SIGMA_PATHS.length === 0 && SPLUNK_PATHS.length === 0 && ELASTIC_PATHS.length === 0 && KQL_PATHS.length === 0 && SUBLIME_PATHS.length === 0 && CQL_HUB_PATHS.length === 0 && JAMF_PROTECT_PATHS.length === 0) {
|
|
158
161
|
return {
|
|
159
162
|
error: true,
|
|
160
|
-
message: 'No paths configured. Set SIGMA_PATHS, SPLUNK_PATHS, ELASTIC_PATHS, and/or
|
|
163
|
+
message: 'No paths configured. Set SIGMA_PATHS, SPLUNK_PATHS, ELASTIC_PATHS, KQL_PATHS, SUBLIME_PATHS, CQL_HUB_PATHS, and/or JAMF_PROTECT_PATHS environment variables.',
|
|
161
164
|
};
|
|
162
165
|
}
|
|
163
166
|
const currentStats = getStats();
|
|
@@ -225,7 +228,7 @@ const rebuildIndexTool = defineTool({
|
|
|
225
228
|
console.error('[security-detections-mcp] Rebuilding index...');
|
|
226
229
|
// Recreate DB to apply schema changes
|
|
227
230
|
recreateDb();
|
|
228
|
-
const result = indexDetections(SIGMA_PATHS, SPLUNK_PATHS, STORY_PATHS, ELASTIC_PATHS, KQL_PATHS);
|
|
231
|
+
const result = indexDetections(SIGMA_PATHS, SPLUNK_PATHS, STORY_PATHS, ELASTIC_PATHS, KQL_PATHS, SUBLIME_PATHS, CQL_HUB_PATHS, JAMF_PROTECT_PATHS);
|
|
229
232
|
// Notify subscribers that resources have changed
|
|
230
233
|
try {
|
|
231
234
|
await notifyResourceChange('detection://stats');
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { defineTool } from '../registry.js';
|
|
8
8
|
import { isStixLoaded, getActorByName, listActors, getActorTechniques, getActorCoverage, getSoftwareForActor, getAttackStats, generateNavigatorLayer, } from '../../db/index.js';
|
|
9
|
-
const SOURCE_TYPES = ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'];
|
|
9
|
+
const SOURCE_TYPES = ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'];
|
|
10
10
|
function stixNotLoadedError() {
|
|
11
11
|
return {
|
|
12
12
|
error: true,
|
|
@@ -24,7 +24,7 @@ export const analysisTools = [
|
|
|
24
24
|
properties: {
|
|
25
25
|
source_type: {
|
|
26
26
|
type: 'string',
|
|
27
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
27
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
28
28
|
description: 'Filter by source type',
|
|
29
29
|
},
|
|
30
30
|
tactic: {
|
|
@@ -67,7 +67,7 @@ export const analysisTools = [
|
|
|
67
67
|
properties: {
|
|
68
68
|
source_type: {
|
|
69
69
|
type: 'string',
|
|
70
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
70
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
71
71
|
description: 'Filter by source type (optional - analyzes all if not specified)',
|
|
72
72
|
},
|
|
73
73
|
},
|
|
@@ -91,7 +91,7 @@ export const analysisTools = [
|
|
|
91
91
|
},
|
|
92
92
|
source_type: {
|
|
93
93
|
type: 'string',
|
|
94
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
94
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
95
95
|
description: 'Filter by source type (optional)',
|
|
96
96
|
},
|
|
97
97
|
},
|
|
@@ -125,7 +125,7 @@ export const analysisTools = [
|
|
|
125
125
|
},
|
|
126
126
|
source_type: {
|
|
127
127
|
type: 'string',
|
|
128
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
128
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
129
129
|
description: 'Filter by source type (optional)',
|
|
130
130
|
},
|
|
131
131
|
},
|
|
@@ -177,7 +177,7 @@ export const analysisTools = [
|
|
|
177
177
|
},
|
|
178
178
|
source_type: {
|
|
179
179
|
type: 'string',
|
|
180
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
180
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
181
181
|
description: 'Filter to specific source type (optional — includes all if omitted)',
|
|
182
182
|
},
|
|
183
183
|
tactic: {
|
|
@@ -228,7 +228,7 @@ export const analysisTools = [
|
|
|
228
228
|
},
|
|
229
229
|
source_type: {
|
|
230
230
|
type: 'string',
|
|
231
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
231
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
232
232
|
description: 'Filter to specific source (optional — analyzes all if omitted)',
|
|
233
233
|
},
|
|
234
234
|
include_query_snippets: {
|
|
@@ -266,7 +266,7 @@ export const analysisTools = [
|
|
|
266
266
|
type: 'array',
|
|
267
267
|
items: {
|
|
268
268
|
type: 'string',
|
|
269
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
269
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
270
270
|
},
|
|
271
271
|
description: 'Sources to compare (default: all available)',
|
|
272
272
|
},
|
|
@@ -15,7 +15,7 @@ export const comparisonTools = [
|
|
|
15
15
|
},
|
|
16
16
|
source_type: {
|
|
17
17
|
type: 'string',
|
|
18
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
18
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
19
19
|
description: 'Optional: filter by source type',
|
|
20
20
|
},
|
|
21
21
|
limit: {
|
|
@@ -147,7 +147,7 @@ export const comparisonTools = [
|
|
|
147
147
|
},
|
|
148
148
|
source_type: {
|
|
149
149
|
type: 'string',
|
|
150
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
150
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
151
151
|
description: 'Optional: filter to specific source',
|
|
152
152
|
},
|
|
153
153
|
},
|
|
@@ -235,7 +235,7 @@ export const comparisonTools = [
|
|
|
235
235
|
properties: {
|
|
236
236
|
source_type: {
|
|
237
237
|
type: 'string',
|
|
238
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
238
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
239
239
|
description: 'Filter by source type (optional)',
|
|
240
240
|
},
|
|
241
241
|
},
|
|
@@ -10,7 +10,7 @@ export const filterTools = [
|
|
|
10
10
|
properties: {
|
|
11
11
|
source_type: {
|
|
12
12
|
type: 'string',
|
|
13
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
13
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
14
14
|
description: 'Source type to filter by',
|
|
15
15
|
},
|
|
16
16
|
limit: {
|
|
@@ -22,7 +22,7 @@ export const searchTools = [
|
|
|
22
22
|
},
|
|
23
23
|
source_type: {
|
|
24
24
|
type: 'string',
|
|
25
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
25
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
26
26
|
description: 'Filter results by detection source type',
|
|
27
27
|
},
|
|
28
28
|
},
|
|
@@ -27,7 +27,7 @@ const getQueryPatternsTool = defineTool({
|
|
|
27
27
|
},
|
|
28
28
|
source_type: {
|
|
29
29
|
type: 'string',
|
|
30
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
30
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
31
31
|
description: 'Filter patterns by source type (optional)',
|
|
32
32
|
},
|
|
33
33
|
},
|
|
@@ -175,7 +175,7 @@ const findSimilarDetectionsTool = defineTool({
|
|
|
175
175
|
},
|
|
176
176
|
source_type: {
|
|
177
177
|
type: 'string',
|
|
178
|
-
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'],
|
|
178
|
+
enum: ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql', 'jamf_protect'],
|
|
179
179
|
description: 'Filter by source type (optional)',
|
|
180
180
|
},
|
|
181
181
|
limit: {
|
|
@@ -10,7 +10,7 @@ export interface Detection {
|
|
|
10
10
|
name: string;
|
|
11
11
|
description: string;
|
|
12
12
|
query: string;
|
|
13
|
-
source_type: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql';
|
|
13
|
+
source_type: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect';
|
|
14
14
|
mitre_ids: string[];
|
|
15
15
|
logsource_category: string | null;
|
|
16
16
|
logsource_product: string | null;
|
|
@@ -49,7 +49,7 @@ export interface Detection {
|
|
|
49
49
|
export interface DetectionSummary {
|
|
50
50
|
id: string;
|
|
51
51
|
name: string;
|
|
52
|
-
source_type: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql';
|
|
52
|
+
source_type: 'sigma' | 'splunk_escu' | 'elastic' | 'kql' | 'sublime' | 'crowdstrike_cql' | 'jamf_protect';
|
|
53
53
|
mitre_ids: string[];
|
|
54
54
|
severity: string | null;
|
|
55
55
|
mitre_tactics: string[];
|
|
@@ -190,6 +190,31 @@ export interface ElasticTechnique {
|
|
|
190
190
|
reference?: string;
|
|
191
191
|
subtechnique?: ElasticTechnique[];
|
|
192
192
|
}
|
|
193
|
+
/**
|
|
194
|
+
* Jamf Protect custom analytic detection structure (YAML with NSPredicate filter).
|
|
195
|
+
* @see https://github.com/jamf/jamfprotect/tree/main/custom_analytic_detections
|
|
196
|
+
*/
|
|
197
|
+
export interface JamfProtectRule {
|
|
198
|
+
name: string;
|
|
199
|
+
uuid?: string;
|
|
200
|
+
label?: string;
|
|
201
|
+
shortDescription?: string;
|
|
202
|
+
longDescription?: string;
|
|
203
|
+
level?: number;
|
|
204
|
+
inputType?: 'GPProcessEvent' | 'GPFSEvent' | 'GPKeylogRegisterEvent' | 'GPUSBEvent' | string;
|
|
205
|
+
filter: string;
|
|
206
|
+
severity?: 'Informational' | 'Low' | 'Medium' | 'High' | 'Critical' | string;
|
|
207
|
+
categories?: string[];
|
|
208
|
+
MitreCategories?: string[] | null;
|
|
209
|
+
tags?: string[] | null;
|
|
210
|
+
snapshotFiles?: string[] | null;
|
|
211
|
+
actions?: Array<{
|
|
212
|
+
name?: string;
|
|
213
|
+
} | string>;
|
|
214
|
+
context?: string[] | null;
|
|
215
|
+
version?: number;
|
|
216
|
+
remediation?: string | null;
|
|
217
|
+
}
|
|
193
218
|
/**
|
|
194
219
|
* CQL Hub rule structure (CrowdStrike Query Language)
|
|
195
220
|
* @see https://github.com/ByteRay-Labs/Query-Hub
|
package/dist/types/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* - dynamic: Dynamic table types for runtime schema extension
|
|
12
12
|
* - meta: Meta-tool types for custom tools and workflows
|
|
13
13
|
*/
|
|
14
|
-
export type { Detection, DetectionSummary, SigmaRule, SplunkDetection, ElasticRule, ElasticThreat, ElasticTechnique, SublimeRule, CqlHubRule, } from './detection.js';
|
|
14
|
+
export type { Detection, DetectionSummary, SigmaRule, SplunkDetection, ElasticRule, ElasticThreat, ElasticTechnique, SublimeRule, CqlHubRule, JamfProtectRule, } from './detection.js';
|
|
15
15
|
export type { AnalyticStory, SplunkStoryYaml, } from './story.js';
|
|
16
16
|
export type { IndexStats, SourceComparison, SavedQuery, } from './stats.js';
|
|
17
17
|
export type { KnowledgeEntity, KnowledgeRelation, KnowledgeObservation, KnowledgeDecision, KnowledgeLearning, KnowledgeQueryOptions, } from './knowledge.js';
|
package/dist/types/stats.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "security-detections-mcp",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "Advanced MCP server for security detections with Detection Engineering Intelligence, Knowledge Graph (Tribal Knowledge), Elicitation, and Resource Subscriptions",
|
|
5
5
|
"sigmaSpecVersion": "2.0.0",
|
|
6
6
|
"type": "module",
|
|
@@ -20,11 +20,12 @@
|
|
|
20
20
|
"build": "tsc",
|
|
21
21
|
"start": "node dist/index.js",
|
|
22
22
|
"dev": "tsc --watch",
|
|
23
|
-
"test": "npm run build && node tests/cross-platform-test.js && node tests/integration-test.js && node tests/engineering-tools-test.js",
|
|
23
|
+
"test": "npm run build && node tests/cross-platform-test.js && node tests/jamf-parser-test.js && node tests/integration-test.js && node tests/engineering-tools-test.js",
|
|
24
24
|
"test:platform": "npm run build && node tests/cross-platform-test.js",
|
|
25
25
|
"test:ci": "npm run build && node tests/ci-integration-test.js",
|
|
26
26
|
"test:integration": "npm run build && node tests/integration-test.js",
|
|
27
27
|
"test:engineering": "npm run build && node tests/engineering-tools-test.js",
|
|
28
|
+
"test:jamf": "npm run build && node tests/jamf-parser-test.js",
|
|
28
29
|
"lint": "tsc --noEmit --strict",
|
|
29
30
|
"prepublishOnly": "npm run build && npm run lint"
|
|
30
31
|
},
|
|
@@ -43,6 +44,8 @@
|
|
|
43
44
|
"sublime",
|
|
44
45
|
"crowdstrike",
|
|
45
46
|
"cql",
|
|
47
|
+
"jamf",
|
|
48
|
+
"macos",
|
|
46
49
|
"siem",
|
|
47
50
|
"mitre",
|
|
48
51
|
"attack"
|