ferret-scan 2.1.2 → 2.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/CHANGELOG.md +35 -0
- package/README.md +15 -11
- package/bin/ferret.js +109 -13
- package/dist/__tests__/AgentMonitor.test.d.ts +6 -0
- package/dist/__tests__/AgentMonitor.test.js +235 -0
- package/dist/__tests__/AtlasNavigatorReporter.test.d.ts +6 -0
- package/dist/__tests__/AtlasNavigatorReporter.test.js +193 -0
- package/dist/__tests__/CorrelationAnalyzer.test.d.ts +6 -0
- package/dist/__tests__/CorrelationAnalyzer.test.js +211 -0
- package/dist/__tests__/IndicatorMatcher.test.d.ts +6 -0
- package/dist/__tests__/IndicatorMatcher.test.js +245 -0
- package/dist/__tests__/MarketplaceScanner.test.d.ts +5 -0
- package/dist/__tests__/MarketplaceScanner.test.js +212 -0
- package/dist/__tests__/RuleGenerator.test.d.ts +6 -0
- package/dist/__tests__/RuleGenerator.test.js +207 -0
- package/dist/__tests__/ThreatFeed.test.d.ts +6 -0
- package/dist/__tests__/ThreatFeed.test.js +359 -0
- package/dist/__tests__/WatchMode.test.d.ts +6 -0
- package/dist/__tests__/WatchMode.test.js +104 -0
- package/dist/__tests__/astAnalyzerExtra.test.d.ts +6 -0
- package/dist/__tests__/astAnalyzerExtra.test.js +67 -0
- package/dist/__tests__/astAnalyzerFull.test.d.ts +6 -0
- package/dist/__tests__/astAnalyzerFull.test.js +138 -0
- package/dist/__tests__/astAnalyzerPatterns.test.d.ts +6 -0
- package/dist/__tests__/astAnalyzerPatterns.test.js +143 -0
- package/dist/__tests__/atlas.test.d.ts +6 -0
- package/dist/__tests__/atlas.test.js +319 -0
- package/dist/__tests__/atlasCatalog.test.d.ts +6 -0
- package/dist/__tests__/atlasCatalog.test.js +200 -0
- package/dist/__tests__/atlasCatalogExtra.test.d.ts +6 -0
- package/dist/__tests__/atlasCatalogExtra.test.js +215 -0
- package/dist/__tests__/baseline.test.d.ts +6 -0
- package/dist/__tests__/baseline.test.js +321 -0
- package/dist/__tests__/baselineExtra.test.d.ts +6 -0
- package/dist/__tests__/baselineExtra.test.js +317 -0
- package/dist/__tests__/capabilityMapping.test.d.ts +5 -0
- package/dist/__tests__/capabilityMapping.test.js +49 -0
- package/dist/__tests__/capabilityMappingExtra.test.d.ts +5 -0
- package/dist/__tests__/capabilityMappingExtra.test.js +200 -0
- package/dist/__tests__/complianceExtra.test.d.ts +6 -0
- package/dist/__tests__/complianceExtra.test.js +121 -0
- package/dist/__tests__/config.test.js +1 -1
- package/dist/__tests__/configLoader.test.d.ts +6 -0
- package/dist/__tests__/configLoader.test.js +225 -0
- package/dist/__tests__/configLoaderExtra.test.d.ts +6 -0
- package/dist/__tests__/configLoaderExtra.test.js +186 -0
- package/dist/__tests__/correlationAnalyzerExtra.test.d.ts +5 -0
- package/dist/__tests__/correlationAnalyzerExtra.test.js +98 -0
- package/dist/__tests__/correlationAnalyzerFull.test.d.ts +6 -0
- package/dist/__tests__/correlationAnalyzerFull.test.js +154 -0
- package/dist/__tests__/customRules.extra.test.d.ts +6 -0
- package/dist/__tests__/customRules.extra.test.js +245 -0
- package/dist/__tests__/customRules.test.d.ts +7 -0
- package/dist/__tests__/customRules.test.js +347 -0
- package/dist/__tests__/dependencyRisk.test.d.ts +5 -0
- package/dist/__tests__/dependencyRisk.test.js +248 -0
- package/dist/__tests__/dependencyRiskExtra.test.d.ts +6 -0
- package/dist/__tests__/dependencyRiskExtra.test.js +177 -0
- package/dist/__tests__/featureExitCodes.test.d.ts +7 -0
- package/dist/__tests__/featureExitCodes.test.js +332 -0
- package/dist/__tests__/fileDiscoveryConfigOnly.test.d.ts +6 -0
- package/dist/__tests__/fileDiscoveryConfigOnly.test.js +195 -0
- package/dist/__tests__/fileDiscoveryExtra.test.d.ts +6 -0
- package/dist/__tests__/fileDiscoveryExtra.test.js +149 -0
- package/dist/__tests__/fixer.extra.test.d.ts +6 -0
- package/dist/__tests__/fixer.extra.test.js +135 -0
- package/dist/__tests__/fixerApply.test.d.ts +6 -0
- package/dist/__tests__/fixerApply.test.js +132 -0
- package/dist/__tests__/gitHooks.test.d.ts +7 -0
- package/dist/__tests__/gitHooks.test.js +188 -0
- package/dist/__tests__/htmlReporter.extra.test.d.ts +5 -0
- package/dist/__tests__/htmlReporter.extra.test.js +126 -0
- package/dist/__tests__/interactiveTui.test.d.ts +6 -0
- package/dist/__tests__/interactiveTui.test.js +180 -0
- package/dist/__tests__/interactiveTuiCommands.test.d.ts +6 -0
- package/dist/__tests__/interactiveTuiCommands.test.js +187 -0
- package/dist/__tests__/interactiveTuiMore.test.d.ts +6 -0
- package/dist/__tests__/interactiveTuiMore.test.js +194 -0
- package/dist/__tests__/interactiveTuiSession.test.d.ts +6 -0
- package/dist/__tests__/interactiveTuiSession.test.js +173 -0
- package/dist/__tests__/llmAnalysis.test.d.ts +6 -0
- package/dist/__tests__/llmAnalysis.test.js +229 -0
- package/dist/__tests__/llmAnalysisBuildExcerpt.test.d.ts +6 -0
- package/dist/__tests__/llmAnalysisBuildExcerpt.test.js +132 -0
- package/dist/__tests__/llmAnalysisExtra.test.d.ts +6 -0
- package/dist/__tests__/llmAnalysisExtra.test.js +214 -0
- package/dist/__tests__/llmAnalysisFilters.test.d.ts +6 -0
- package/dist/__tests__/llmAnalysisFilters.test.js +181 -0
- package/dist/__tests__/llmAnalysisMitre.test.d.ts +6 -0
- package/dist/__tests__/llmAnalysisMitre.test.js +192 -0
- package/dist/__tests__/llmGroqTPM.test.d.ts +6 -0
- package/dist/__tests__/llmGroqTPM.test.js +89 -0
- package/dist/__tests__/llmProviderRetry.test.d.ts +6 -0
- package/dist/__tests__/llmProviderRetry.test.js +172 -0
- package/dist/__tests__/mcpValidator.extra.test.d.ts +5 -0
- package/dist/__tests__/mcpValidator.extra.test.js +270 -0
- package/dist/__tests__/patternMatcherExtra.test.d.ts +7 -0
- package/dist/__tests__/patternMatcherExtra.test.js +198 -0
- package/dist/__tests__/patternsCommon.test.d.ts +6 -0
- package/dist/__tests__/patternsCommon.test.js +107 -0
- package/dist/__tests__/policyEnforcement.test.d.ts +5 -0
- package/dist/__tests__/policyEnforcement.test.js +510 -0
- package/dist/__tests__/quarantineExtra.test.d.ts +5 -0
- package/dist/__tests__/quarantineExtra.test.js +214 -0
- package/dist/__tests__/redactionExtra.test.d.ts +6 -0
- package/dist/__tests__/redactionExtra.test.js +228 -0
- package/dist/__tests__/scanDiff.test.d.ts +7 -0
- package/dist/__tests__/scanDiff.test.js +266 -0
- package/dist/__tests__/scanFull.test.d.ts +6 -0
- package/dist/__tests__/scanFull.test.js +158 -0
- package/dist/__tests__/scannerDampening.test.d.ts +6 -0
- package/dist/__tests__/scannerDampening.test.js +160 -0
- package/dist/__tests__/scannerExtra.test.d.ts +6 -0
- package/dist/__tests__/scannerExtra.test.js +194 -0
- package/dist/__tests__/scannerMitre.test.d.ts +5 -0
- package/dist/__tests__/scannerMitre.test.js +141 -0
- package/dist/__tests__/scannerSSRF.test.d.ts +5 -0
- package/dist/__tests__/scannerSSRF.test.js +149 -0
- package/dist/__tests__/schemas.test.d.ts +6 -0
- package/dist/__tests__/schemas.test.js +125 -0
- package/dist/__tests__/webhooks.extra.test.d.ts +6 -0
- package/dist/__tests__/webhooks.extra.test.js +144 -0
- package/dist/__tests__/webhooks.test.d.ts +6 -0
- package/dist/__tests__/webhooks.test.js +154 -0
- package/dist/analyzers/AstAnalyzer.d.ts +5 -1
- package/dist/analyzers/AstAnalyzer.js +25 -4
- package/dist/features/customRules.js +22 -29
- package/dist/features/ignoreComments.js +5 -5
- package/dist/features/mcpTrustScore.d.ts +17 -0
- package/dist/features/mcpTrustScore.js +74 -0
- package/dist/features/mcpValidator.d.ts +2 -0
- package/dist/features/mcpValidator.js +13 -0
- package/dist/features/policyEnforcement.d.ts +22 -22
- package/dist/features/policyEnforcement.js +3 -2
- package/dist/intelligence/ThreatFeed.js +207 -62
- package/dist/remediation/Fixer.js +56 -30
- package/dist/remediation/Quarantine.js +79 -11
- package/dist/reporters/ConsoleReporter.js +10 -0
- package/dist/reporters/HtmlReporter.js +5 -0
- package/dist/reporters/SarifReporter.d.ts +1 -0
- package/dist/reporters/SarifReporter.js +1 -0
- package/dist/rules/ai-specific.js +8 -8
- package/dist/rules/backdoors.js +12 -12
- package/dist/rules/correlationRules.js +6 -6
- package/dist/rules/index.d.ts +1 -0
- package/dist/rules/index.js +10 -1
- package/dist/rules/injection.js +8 -8
- package/dist/rules/patterns/common.d.ts +34 -0
- package/dist/rules/patterns/common.js +48 -0
- package/dist/scanner/IAnalyzer.d.ts +19 -0
- package/dist/scanner/IAnalyzer.js +5 -0
- package/dist/scanner/PatternMatcher.js +19 -2
- package/dist/scanner/Scanner.js +64 -125
- package/dist/scanner/analyzers/CapabilityAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/CapabilityAnalyzer.js +19 -0
- package/dist/scanner/analyzers/DependencyAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/DependencyAnalyzer.js +18 -0
- package/dist/scanner/analyzers/EntropyAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/EntropyAnalyzer.js +12 -0
- package/dist/scanner/analyzers/LlmAnalyzer.d.ts +17 -0
- package/dist/scanner/analyzers/LlmAnalyzer.js +36 -0
- package/dist/scanner/analyzers/McpAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/McpAnalyzer.js +19 -0
- package/dist/scanner/analyzers/SemanticAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/SemanticAnalyzer.js +21 -0
- package/dist/scanner/analyzers/ThreatIntelAnalyzer.d.ts +8 -0
- package/dist/scanner/analyzers/ThreatIntelAnalyzer.js +21 -0
- package/dist/types.d.ts +23 -0
- package/dist/types.js +1 -1
- package/dist/utils/baseline.d.ts +15 -2
- package/dist/utils/baseline.js +50 -19
- package/dist/utils/contentCache.d.ts +39 -0
- package/dist/utils/contentCache.js +77 -0
- package/dist/utils/glob.d.ts +50 -0
- package/dist/utils/glob.js +84 -0
- package/dist/utils/pathSecurity.js +1 -0
- package/dist/utils/safeRegex.d.ts +55 -0
- package/dist/utils/safeRegex.js +130 -0
- package/dist/utils/schemas.d.ts +70 -64
- package/dist/utils/schemas.js +13 -0
- package/package.json +34 -19
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MITRE ATLAS Catalog Loader Tests
|
|
3
|
+
* Tests for parsing the STIX bundle and loadMitreAtlasTechniqueCatalog.
|
|
4
|
+
*/
|
|
5
|
+
jest.mock('node:fs');
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import { parseMitreAtlasStixBundle, loadMitreAtlasTechniqueCatalog, } from '../mitre/atlasCatalog.js';
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
const mockFs = fs;
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Helpers
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
function makeStixBundle(techniques = []) {
|
|
14
|
+
return {
|
|
15
|
+
type: 'bundle',
|
|
16
|
+
id: 'bundle--test',
|
|
17
|
+
objects: techniques,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function makeAttackPattern(id, name, tactics = ['execution']) {
|
|
21
|
+
return {
|
|
22
|
+
type: 'attack-pattern',
|
|
23
|
+
name,
|
|
24
|
+
external_references: [
|
|
25
|
+
{
|
|
26
|
+
source_name: 'mitre-atlas',
|
|
27
|
+
external_id: id,
|
|
28
|
+
url: `https://atlas.mitre.org/techniques/${id}`,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
kill_chain_phases: tactics.map(t => ({ kill_chain_name: 'mitre-atlas', phase_name: t })),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function makeConfig(overrides = {}) {
|
|
35
|
+
return {
|
|
36
|
+
enabled: true,
|
|
37
|
+
sourceUrl: 'https://example.com/stix-atlas.json',
|
|
38
|
+
cachePath: '/tmp/test-atlas-cache.json',
|
|
39
|
+
cacheTtlHours: 24,
|
|
40
|
+
autoUpdate: false,
|
|
41
|
+
forceRefresh: false,
|
|
42
|
+
timeoutMs: 5000,
|
|
43
|
+
...overrides,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// parseMitreAtlasStixBundle
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
describe('parseMitreAtlasStixBundle', () => {
|
|
50
|
+
it('returns empty object for null input', () => {
|
|
51
|
+
const result = parseMitreAtlasStixBundle(null);
|
|
52
|
+
expect(result).toEqual({});
|
|
53
|
+
});
|
|
54
|
+
it('returns empty object for non-object input', () => {
|
|
55
|
+
expect(parseMitreAtlasStixBundle('string')).toEqual({});
|
|
56
|
+
expect(parseMitreAtlasStixBundle(42)).toEqual({});
|
|
57
|
+
});
|
|
58
|
+
it('returns empty object when objects field is missing', () => {
|
|
59
|
+
expect(parseMitreAtlasStixBundle({})).toEqual({});
|
|
60
|
+
});
|
|
61
|
+
it('returns empty object when objects is not an array', () => {
|
|
62
|
+
expect(parseMitreAtlasStixBundle({ objects: 'not-array' })).toEqual({});
|
|
63
|
+
});
|
|
64
|
+
it('parses a valid STIX bundle with one technique', () => {
|
|
65
|
+
const bundle = makeStixBundle([makeAttackPattern('AML.T0051', 'LLM Prompt Injection')]);
|
|
66
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
67
|
+
expect(result['AML.T0051']).toBeDefined();
|
|
68
|
+
expect(result['AML.T0051'].name).toBe('LLM Prompt Injection');
|
|
69
|
+
expect(result['AML.T0051'].tactics).toContain('execution');
|
|
70
|
+
});
|
|
71
|
+
it('parses multiple techniques', () => {
|
|
72
|
+
const bundle = makeStixBundle([
|
|
73
|
+
makeAttackPattern('AML.T0051', 'LLM Prompt Injection', ['execution']),
|
|
74
|
+
makeAttackPattern('AML.T0054', 'LLM Jailbreak', ['privilege-escalation']),
|
|
75
|
+
]);
|
|
76
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
77
|
+
expect(Object.keys(result)).toHaveLength(2);
|
|
78
|
+
expect(result['AML.T0054'].tactics).toContain('privilege-escalation');
|
|
79
|
+
});
|
|
80
|
+
it('skips objects that are not attack-patterns', () => {
|
|
81
|
+
const bundle = makeStixBundle([
|
|
82
|
+
{ type: 'identity', name: 'MITRE' },
|
|
83
|
+
makeAttackPattern('AML.T0051', 'LLM Prompt Injection'),
|
|
84
|
+
]);
|
|
85
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
86
|
+
expect(Object.keys(result)).toHaveLength(1);
|
|
87
|
+
});
|
|
88
|
+
it('skips objects with invalid ID format', () => {
|
|
89
|
+
const bundle = makeStixBundle([
|
|
90
|
+
{
|
|
91
|
+
type: 'attack-pattern',
|
|
92
|
+
name: 'Invalid',
|
|
93
|
+
external_references: [
|
|
94
|
+
{ source_name: 'mitre-atlas', external_id: 'INVALID-ID' },
|
|
95
|
+
],
|
|
96
|
+
kill_chain_phases: [],
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
100
|
+
expect(Object.keys(result)).toHaveLength(0);
|
|
101
|
+
});
|
|
102
|
+
it('deduplicates tactics', () => {
|
|
103
|
+
const bundle = makeStixBundle([
|
|
104
|
+
{
|
|
105
|
+
type: 'attack-pattern',
|
|
106
|
+
name: 'Multi Tactic',
|
|
107
|
+
external_references: [
|
|
108
|
+
{ source_name: 'mitre-atlas', external_id: 'AML.T0053', url: 'https://atlas.mitre.org/techniques/AML.T0053' },
|
|
109
|
+
],
|
|
110
|
+
kill_chain_phases: [
|
|
111
|
+
{ kill_chain_name: 'mitre-atlas', phase_name: 'execution' },
|
|
112
|
+
{ kill_chain_name: 'mitre-atlas', phase_name: 'execution' },
|
|
113
|
+
{ kill_chain_name: 'mitre-atlas', phase_name: 'privilege-escalation' },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
118
|
+
const tactics = result['AML.T0053'].tactics;
|
|
119
|
+
expect(tactics).toHaveLength(2);
|
|
120
|
+
expect(new Set(tactics).size).toBe(2);
|
|
121
|
+
});
|
|
122
|
+
it('uses fallback URL when not provided in external reference', () => {
|
|
123
|
+
const bundle = makeStixBundle([
|
|
124
|
+
{
|
|
125
|
+
type: 'attack-pattern',
|
|
126
|
+
name: 'No URL',
|
|
127
|
+
external_references: [
|
|
128
|
+
{ source_name: 'mitre-atlas', external_id: 'AML.T0055' },
|
|
129
|
+
],
|
|
130
|
+
kill_chain_phases: [],
|
|
131
|
+
},
|
|
132
|
+
]);
|
|
133
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
134
|
+
expect(result['AML.T0055'].url).toContain('AML.T0055');
|
|
135
|
+
});
|
|
136
|
+
it('handles subtechnique IDs (AML.T####.###)', () => {
|
|
137
|
+
const bundle = makeStixBundle([
|
|
138
|
+
makeAttackPattern('AML.T0011.002', 'Poisoned AI Agent Tool'),
|
|
139
|
+
]);
|
|
140
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
141
|
+
expect(result['AML.T0011.002']).toBeDefined();
|
|
142
|
+
});
|
|
143
|
+
it('handles array elements that are null or non-object gracefully', () => {
|
|
144
|
+
const bundle = { objects: [null, undefined, 42, makeAttackPattern('AML.T0051', 'LLM Prompt Injection')] };
|
|
145
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
146
|
+
expect(result['AML.T0051']).toBeDefined();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// loadMitreAtlasTechniqueCatalog
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
describe('loadMitreAtlasTechniqueCatalog', () => {
|
|
153
|
+
beforeEach(() => {
|
|
154
|
+
jest.clearAllMocks();
|
|
155
|
+
});
|
|
156
|
+
it('returns null when catalog is disabled', async () => {
|
|
157
|
+
const config = makeConfig({ enabled: false });
|
|
158
|
+
const result = await loadMitreAtlasTechniqueCatalog(config);
|
|
159
|
+
expect(result).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
it('returns techniques from a fresh cache', async () => {
|
|
162
|
+
const bundle = makeStixBundle([makeAttackPattern('AML.T0051', 'LLM Prompt Injection')]);
|
|
163
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
164
|
+
mockFs.statSync.mockReturnValue({ mtimeMs: Date.now() }); // fresh
|
|
165
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(bundle));
|
|
166
|
+
const config = makeConfig();
|
|
167
|
+
const result = await loadMitreAtlasTechniqueCatalog(config);
|
|
168
|
+
expect(result).not.toBeNull();
|
|
169
|
+
expect(result['AML.T0051']).toBeDefined();
|
|
170
|
+
});
|
|
171
|
+
it('returns null when cache is stale and autoUpdate is false and no refresh', async () => {
|
|
172
|
+
const bundle = makeStixBundle([makeAttackPattern('AML.T0051', 'LLM Prompt Injection')]);
|
|
173
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
174
|
+
// stale: modified 48 hours ago
|
|
175
|
+
mockFs.statSync.mockReturnValue({ mtimeMs: Date.now() - 48 * 60 * 60 * 1000 });
|
|
176
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify(bundle));
|
|
177
|
+
const config = makeConfig({ autoUpdate: false, forceRefresh: false });
|
|
178
|
+
// Cache is stale so cacheFresh = false, autoUpdate = false, won't refresh
|
|
179
|
+
// Falls back to stale cache
|
|
180
|
+
const result = await loadMitreAtlasTechniqueCatalog(config);
|
|
181
|
+
// stale cache is read as fallback
|
|
182
|
+
expect(result).not.toBeNull();
|
|
183
|
+
});
|
|
184
|
+
it('returns null when cache does not exist and autoUpdate is false', async () => {
|
|
185
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
186
|
+
const config = makeConfig({ autoUpdate: false, forceRefresh: false });
|
|
187
|
+
const result = await loadMitreAtlasTechniqueCatalog(config);
|
|
188
|
+
expect(result).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
it('returns null when parsed bundle produces empty techniques from fresh cache', async () => {
|
|
191
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
192
|
+
mockFs.statSync.mockReturnValue({ mtimeMs: Date.now() });
|
|
193
|
+
mockFs.readFileSync.mockReturnValue(JSON.stringify({ objects: [] })); // no techniques
|
|
194
|
+
const config = makeConfig({ autoUpdate: false });
|
|
195
|
+
const result = await loadMitreAtlasTechniqueCatalog(config);
|
|
196
|
+
// empty techniques from cache, no autoUpdate, falls through to null
|
|
197
|
+
expect(result).toBeNull();
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
//# sourceMappingURL=atlasCatalog.test.js.map
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MITRE ATLAS Catalog Extra Tests
|
|
3
|
+
* Tests for parseMitreAtlasStixBundle and loadMitreAtlasTechniqueCatalog
|
|
4
|
+
*/
|
|
5
|
+
import { parseMitreAtlasStixBundle, loadMitreAtlasTechniqueCatalog } from '../mitre/atlasCatalog.js';
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import * as os from 'node:os';
|
|
9
|
+
function makeConfig(overrides = {}) {
|
|
10
|
+
return {
|
|
11
|
+
enabled: true,
|
|
12
|
+
autoUpdate: false,
|
|
13
|
+
sourceUrl: 'https://example.com/stix-atlas.json',
|
|
14
|
+
cachePath: '/tmp/nonexistent-cache.json',
|
|
15
|
+
cacheTtlHours: 24,
|
|
16
|
+
timeoutMs: 5000,
|
|
17
|
+
forceRefresh: false,
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function makeStixBundle(techniques) {
|
|
22
|
+
return {
|
|
23
|
+
type: 'bundle',
|
|
24
|
+
objects: techniques.map(t => ({
|
|
25
|
+
type: 'attack-pattern',
|
|
26
|
+
name: t.name ?? 'Test Technique',
|
|
27
|
+
external_references: [
|
|
28
|
+
{
|
|
29
|
+
source_name: 'mitre-atlas',
|
|
30
|
+
external_id: t.externalId ?? 'AML.T0001',
|
|
31
|
+
url: t.url ?? `https://atlas.mitre.org/techniques/${t.externalId ?? 'AML.T0001'}`,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
kill_chain_phases: (t.tactics ?? ['ml-attack-staging']).map(phase => ({
|
|
35
|
+
kill_chain_name: 'mitre-atlas',
|
|
36
|
+
phase_name: phase,
|
|
37
|
+
})),
|
|
38
|
+
})),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
describe('parseMitreAtlasStixBundle', () => {
|
|
42
|
+
it('returns empty object for null input', () => {
|
|
43
|
+
expect(parseMitreAtlasStixBundle(null)).toEqual({});
|
|
44
|
+
});
|
|
45
|
+
it('returns empty object for non-object input', () => {
|
|
46
|
+
expect(parseMitreAtlasStixBundle('string')).toEqual({});
|
|
47
|
+
expect(parseMitreAtlasStixBundle(42)).toEqual({});
|
|
48
|
+
});
|
|
49
|
+
it('returns empty object for bundle with no objects', () => {
|
|
50
|
+
expect(parseMitreAtlasStixBundle({ type: 'bundle' })).toEqual({});
|
|
51
|
+
});
|
|
52
|
+
it('returns empty object for bundle with non-array objects', () => {
|
|
53
|
+
expect(parseMitreAtlasStixBundle({ objects: 'not an array' })).toEqual({});
|
|
54
|
+
});
|
|
55
|
+
it('parses a single technique', () => {
|
|
56
|
+
const bundle = makeStixBundle([{ externalId: 'AML.T0001', name: 'Technique 1', tactics: ['initial-access'] }]);
|
|
57
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
58
|
+
expect(Object.keys(result)).toHaveLength(1);
|
|
59
|
+
expect(result['AML.T0001']).toBeDefined();
|
|
60
|
+
expect(result['AML.T0001']?.name).toBe('Technique 1');
|
|
61
|
+
expect(result['AML.T0001']?.id).toBe('AML.T0001');
|
|
62
|
+
expect(result['AML.T0001']?.tactics).toContain('initial-access');
|
|
63
|
+
});
|
|
64
|
+
it('parses multiple techniques', () => {
|
|
65
|
+
const bundle = makeStixBundle([
|
|
66
|
+
{ externalId: 'AML.T0001', name: 'T1' },
|
|
67
|
+
{ externalId: 'AML.T0002', name: 'T2' },
|
|
68
|
+
{ externalId: 'AML.T0001.001', name: 'T1.1' },
|
|
69
|
+
]);
|
|
70
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
71
|
+
expect(Object.keys(result)).toHaveLength(3);
|
|
72
|
+
});
|
|
73
|
+
it('skips non-attack-pattern objects', () => {
|
|
74
|
+
const bundle = {
|
|
75
|
+
objects: [
|
|
76
|
+
{ type: 'identity', name: 'ATLAS' },
|
|
77
|
+
{ type: 'attack-pattern', name: 'Tech', external_references: [{ source_name: 'mitre-atlas', external_id: 'AML.T0001' }] },
|
|
78
|
+
],
|
|
79
|
+
};
|
|
80
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
81
|
+
expect(Object.keys(result)).toHaveLength(1);
|
|
82
|
+
});
|
|
83
|
+
it('skips objects with invalid AML IDs', () => {
|
|
84
|
+
const bundle = {
|
|
85
|
+
objects: [
|
|
86
|
+
{
|
|
87
|
+
type: 'attack-pattern',
|
|
88
|
+
name: 'Invalid',
|
|
89
|
+
external_references: [{ source_name: 'mitre-atlas', external_id: 'INVALID-ID' }],
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
type: 'attack-pattern',
|
|
93
|
+
name: 'No ATLAS ref',
|
|
94
|
+
external_references: [{ source_name: 'other', external_id: 'AML.T0001' }],
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
99
|
+
expect(Object.keys(result)).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
it('uses external_references URL when available', () => {
|
|
102
|
+
const bundle = makeStixBundle([{ externalId: 'AML.T0001', url: 'https://atlas.mitre.org/techniques/AML.T0001' }]);
|
|
103
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
104
|
+
expect(result['AML.T0001']?.url).toBe('https://atlas.mitre.org/techniques/AML.T0001');
|
|
105
|
+
});
|
|
106
|
+
it('generates URL when not in external_references', () => {
|
|
107
|
+
const bundle = {
|
|
108
|
+
objects: [{
|
|
109
|
+
type: 'attack-pattern',
|
|
110
|
+
name: 'No URL Technique',
|
|
111
|
+
external_references: [{ source_name: 'mitre-atlas', external_id: 'AML.T0099' }],
|
|
112
|
+
}],
|
|
113
|
+
};
|
|
114
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
115
|
+
expect(result['AML.T0099']?.url).toContain('AML.T0099');
|
|
116
|
+
});
|
|
117
|
+
it('deduplicates tactics', () => {
|
|
118
|
+
const bundle = {
|
|
119
|
+
objects: [{
|
|
120
|
+
type: 'attack-pattern',
|
|
121
|
+
name: 'Multi Tactic',
|
|
122
|
+
external_references: [{ source_name: 'mitre-atlas', external_id: 'AML.T0001' }],
|
|
123
|
+
kill_chain_phases: [
|
|
124
|
+
{ phase_name: 'initial-access' },
|
|
125
|
+
{ phase_name: 'initial-access' }, // duplicate
|
|
126
|
+
{ phase_name: 'execution' },
|
|
127
|
+
],
|
|
128
|
+
}],
|
|
129
|
+
};
|
|
130
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
131
|
+
expect(result['AML.T0001']?.tactics).toHaveLength(2);
|
|
132
|
+
});
|
|
133
|
+
it('handles null/non-object entries in objects array', () => {
|
|
134
|
+
const bundle = {
|
|
135
|
+
objects: [
|
|
136
|
+
null,
|
|
137
|
+
'string',
|
|
138
|
+
42,
|
|
139
|
+
{
|
|
140
|
+
type: 'attack-pattern',
|
|
141
|
+
name: 'Valid',
|
|
142
|
+
external_references: [{ source_name: 'mitre-atlas', external_id: 'AML.T0001' }],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
};
|
|
146
|
+
const result = parseMitreAtlasStixBundle(bundle);
|
|
147
|
+
expect(Object.keys(result)).toHaveLength(1);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
describe('loadMitreAtlasTechniqueCatalog', () => {
|
|
151
|
+
let tmpDir;
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ferret-atlas-'));
|
|
154
|
+
});
|
|
155
|
+
afterEach(() => {
|
|
156
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
157
|
+
});
|
|
158
|
+
it('returns null when disabled', async () => {
|
|
159
|
+
const result = await loadMitreAtlasTechniqueCatalog(makeConfig({ enabled: false }));
|
|
160
|
+
expect(result).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
it('loads from fresh cache', async () => {
|
|
163
|
+
const cachePath = path.join(tmpDir, 'atlas-cache.json');
|
|
164
|
+
const bundle = makeStixBundle([{ externalId: 'AML.T0001', name: 'Cached Technique' }]);
|
|
165
|
+
fs.writeFileSync(cachePath, JSON.stringify(bundle));
|
|
166
|
+
const result = await loadMitreAtlasTechniqueCatalog(makeConfig({
|
|
167
|
+
cachePath,
|
|
168
|
+
cacheTtlHours: 168,
|
|
169
|
+
forceRefresh: false,
|
|
170
|
+
}));
|
|
171
|
+
expect(result).not.toBeNull();
|
|
172
|
+
expect(result?.['AML.T0001']?.name).toBe('Cached Technique');
|
|
173
|
+
});
|
|
174
|
+
it('returns null when no cache and no autoUpdate', async () => {
|
|
175
|
+
const cachePath = path.join(tmpDir, 'nonexistent-cache.json');
|
|
176
|
+
const result = await loadMitreAtlasTechniqueCatalog(makeConfig({
|
|
177
|
+
cachePath,
|
|
178
|
+
autoUpdate: false,
|
|
179
|
+
forceRefresh: false,
|
|
180
|
+
}));
|
|
181
|
+
expect(result).toBeNull();
|
|
182
|
+
});
|
|
183
|
+
it('fetches and caches when autoUpdate=true and no cache', async () => {
|
|
184
|
+
const bundle = makeStixBundle([{ externalId: 'AML.T0001', name: 'Fetched Technique' }]);
|
|
185
|
+
globalThis.fetch = jest.fn().mockResolvedValue({
|
|
186
|
+
ok: true,
|
|
187
|
+
json: () => Promise.resolve(bundle),
|
|
188
|
+
});
|
|
189
|
+
const cachePath = path.join(tmpDir, 'new-cache.json');
|
|
190
|
+
const result = await loadMitreAtlasTechniqueCatalog(makeConfig({
|
|
191
|
+
cachePath,
|
|
192
|
+
autoUpdate: true,
|
|
193
|
+
forceRefresh: false,
|
|
194
|
+
}));
|
|
195
|
+
expect(result).not.toBeNull();
|
|
196
|
+
expect(result?.['AML.T0001']?.name).toBe('Fetched Technique');
|
|
197
|
+
expect(fs.existsSync(cachePath)).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
it('falls back to stale cache when fetch fails', async () => {
|
|
200
|
+
const cachePath = path.join(tmpDir, 'stale-cache.json');
|
|
201
|
+
const bundle = makeStixBundle([{ externalId: 'AML.T0001', name: 'Stale Technique' }]);
|
|
202
|
+
fs.writeFileSync(cachePath, JSON.stringify(bundle));
|
|
203
|
+
globalThis.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
|
|
204
|
+
const result = await loadMitreAtlasTechniqueCatalog(makeConfig({
|
|
205
|
+
cachePath,
|
|
206
|
+
cacheTtlHours: 0, // Stale cache
|
|
207
|
+
autoUpdate: true,
|
|
208
|
+
forceRefresh: false,
|
|
209
|
+
}));
|
|
210
|
+
// Should fall back to stale cache
|
|
211
|
+
expect(result).not.toBeNull();
|
|
212
|
+
expect(result?.['AML.T0001']?.name).toBe('Stale Technique');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
//# sourceMappingURL=atlasCatalogExtra.test.js.map
|