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 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
- # Set your token as an env var first: export SDMCP_TOKEN="sdmcp_YOUR_TOKEN_HERE"
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
 
@@ -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;
@@ -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 = { sigma: 0, splunk_escu: 0, elastic: 0, kql: 0, sublime: 0, crowdstrike_cql: 0 };
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 = { sigma: 0, splunk_escu: 0, elastic: 0, kql: 0 };
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 (sigma, splunk, elastic, kql)',
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,2 @@
1
+ import type { Detection } from '../types.js';
2
+ export declare function parseJamfProtectFile(filePath: string): Detection | null;
@@ -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
+ }
@@ -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 (sigma, splunk, elastic, kql)',
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 (sigma, splunk_escu, elastic, kql)',
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 (sigma, splunk_escu, elastic, kql, sublime, crowdstrike_cql)',
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 = ['sigma', 'splunk_escu', 'elastic', 'kql', 'sublime', 'crowdstrike_cql'];
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 ['sigma', 'splunk_escu', 'elastic', 'kql']) {
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 = ['sigma', 'splunk_escu', 'elastic', 'kql'];
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 KQL_PATHS environment variables.',
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
@@ -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';
@@ -12,6 +12,8 @@ export interface IndexStats {
12
12
  elastic: number;
13
13
  kql: number;
14
14
  sublime: number;
15
+ crowdstrike_cql: number;
16
+ jamf_protect: number;
15
17
  by_severity: Record<string, number>;
16
18
  by_logsource_product: Record<string, number>;
17
19
  mitre_coverage: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "security-detections-mcp",
3
- "version": "3.2.2",
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"