security-detections-mcp 3.2.1 → 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.
@@ -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) {