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.
Files changed (181) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +15 -11
  3. package/bin/ferret.js +109 -13
  4. package/dist/__tests__/AgentMonitor.test.d.ts +6 -0
  5. package/dist/__tests__/AgentMonitor.test.js +235 -0
  6. package/dist/__tests__/AtlasNavigatorReporter.test.d.ts +6 -0
  7. package/dist/__tests__/AtlasNavigatorReporter.test.js +193 -0
  8. package/dist/__tests__/CorrelationAnalyzer.test.d.ts +6 -0
  9. package/dist/__tests__/CorrelationAnalyzer.test.js +211 -0
  10. package/dist/__tests__/IndicatorMatcher.test.d.ts +6 -0
  11. package/dist/__tests__/IndicatorMatcher.test.js +245 -0
  12. package/dist/__tests__/MarketplaceScanner.test.d.ts +5 -0
  13. package/dist/__tests__/MarketplaceScanner.test.js +212 -0
  14. package/dist/__tests__/RuleGenerator.test.d.ts +6 -0
  15. package/dist/__tests__/RuleGenerator.test.js +207 -0
  16. package/dist/__tests__/ThreatFeed.test.d.ts +6 -0
  17. package/dist/__tests__/ThreatFeed.test.js +359 -0
  18. package/dist/__tests__/WatchMode.test.d.ts +6 -0
  19. package/dist/__tests__/WatchMode.test.js +104 -0
  20. package/dist/__tests__/astAnalyzerExtra.test.d.ts +6 -0
  21. package/dist/__tests__/astAnalyzerExtra.test.js +67 -0
  22. package/dist/__tests__/astAnalyzerFull.test.d.ts +6 -0
  23. package/dist/__tests__/astAnalyzerFull.test.js +138 -0
  24. package/dist/__tests__/astAnalyzerPatterns.test.d.ts +6 -0
  25. package/dist/__tests__/astAnalyzerPatterns.test.js +143 -0
  26. package/dist/__tests__/atlas.test.d.ts +6 -0
  27. package/dist/__tests__/atlas.test.js +319 -0
  28. package/dist/__tests__/atlasCatalog.test.d.ts +6 -0
  29. package/dist/__tests__/atlasCatalog.test.js +200 -0
  30. package/dist/__tests__/atlasCatalogExtra.test.d.ts +6 -0
  31. package/dist/__tests__/atlasCatalogExtra.test.js +215 -0
  32. package/dist/__tests__/baseline.test.d.ts +6 -0
  33. package/dist/__tests__/baseline.test.js +321 -0
  34. package/dist/__tests__/baselineExtra.test.d.ts +6 -0
  35. package/dist/__tests__/baselineExtra.test.js +317 -0
  36. package/dist/__tests__/capabilityMapping.test.d.ts +5 -0
  37. package/dist/__tests__/capabilityMapping.test.js +49 -0
  38. package/dist/__tests__/capabilityMappingExtra.test.d.ts +5 -0
  39. package/dist/__tests__/capabilityMappingExtra.test.js +200 -0
  40. package/dist/__tests__/complianceExtra.test.d.ts +6 -0
  41. package/dist/__tests__/complianceExtra.test.js +121 -0
  42. package/dist/__tests__/config.test.js +1 -1
  43. package/dist/__tests__/configLoader.test.d.ts +6 -0
  44. package/dist/__tests__/configLoader.test.js +225 -0
  45. package/dist/__tests__/configLoaderExtra.test.d.ts +6 -0
  46. package/dist/__tests__/configLoaderExtra.test.js +186 -0
  47. package/dist/__tests__/correlationAnalyzerExtra.test.d.ts +5 -0
  48. package/dist/__tests__/correlationAnalyzerExtra.test.js +98 -0
  49. package/dist/__tests__/correlationAnalyzerFull.test.d.ts +6 -0
  50. package/dist/__tests__/correlationAnalyzerFull.test.js +154 -0
  51. package/dist/__tests__/customRules.extra.test.d.ts +6 -0
  52. package/dist/__tests__/customRules.extra.test.js +245 -0
  53. package/dist/__tests__/customRules.test.d.ts +7 -0
  54. package/dist/__tests__/customRules.test.js +347 -0
  55. package/dist/__tests__/dependencyRisk.test.d.ts +5 -0
  56. package/dist/__tests__/dependencyRisk.test.js +248 -0
  57. package/dist/__tests__/dependencyRiskExtra.test.d.ts +6 -0
  58. package/dist/__tests__/dependencyRiskExtra.test.js +177 -0
  59. package/dist/__tests__/featureExitCodes.test.d.ts +7 -0
  60. package/dist/__tests__/featureExitCodes.test.js +332 -0
  61. package/dist/__tests__/fileDiscoveryConfigOnly.test.d.ts +6 -0
  62. package/dist/__tests__/fileDiscoveryConfigOnly.test.js +195 -0
  63. package/dist/__tests__/fileDiscoveryExtra.test.d.ts +6 -0
  64. package/dist/__tests__/fileDiscoveryExtra.test.js +149 -0
  65. package/dist/__tests__/fixer.extra.test.d.ts +6 -0
  66. package/dist/__tests__/fixer.extra.test.js +135 -0
  67. package/dist/__tests__/fixerApply.test.d.ts +6 -0
  68. package/dist/__tests__/fixerApply.test.js +132 -0
  69. package/dist/__tests__/gitHooks.test.d.ts +7 -0
  70. package/dist/__tests__/gitHooks.test.js +188 -0
  71. package/dist/__tests__/htmlReporter.extra.test.d.ts +5 -0
  72. package/dist/__tests__/htmlReporter.extra.test.js +126 -0
  73. package/dist/__tests__/interactiveTui.test.d.ts +6 -0
  74. package/dist/__tests__/interactiveTui.test.js +180 -0
  75. package/dist/__tests__/interactiveTuiCommands.test.d.ts +6 -0
  76. package/dist/__tests__/interactiveTuiCommands.test.js +187 -0
  77. package/dist/__tests__/interactiveTuiMore.test.d.ts +6 -0
  78. package/dist/__tests__/interactiveTuiMore.test.js +194 -0
  79. package/dist/__tests__/interactiveTuiSession.test.d.ts +6 -0
  80. package/dist/__tests__/interactiveTuiSession.test.js +173 -0
  81. package/dist/__tests__/llmAnalysis.test.d.ts +6 -0
  82. package/dist/__tests__/llmAnalysis.test.js +229 -0
  83. package/dist/__tests__/llmAnalysisBuildExcerpt.test.d.ts +6 -0
  84. package/dist/__tests__/llmAnalysisBuildExcerpt.test.js +132 -0
  85. package/dist/__tests__/llmAnalysisExtra.test.d.ts +6 -0
  86. package/dist/__tests__/llmAnalysisExtra.test.js +214 -0
  87. package/dist/__tests__/llmAnalysisFilters.test.d.ts +6 -0
  88. package/dist/__tests__/llmAnalysisFilters.test.js +181 -0
  89. package/dist/__tests__/llmAnalysisMitre.test.d.ts +6 -0
  90. package/dist/__tests__/llmAnalysisMitre.test.js +192 -0
  91. package/dist/__tests__/llmGroqTPM.test.d.ts +6 -0
  92. package/dist/__tests__/llmGroqTPM.test.js +89 -0
  93. package/dist/__tests__/llmProviderRetry.test.d.ts +6 -0
  94. package/dist/__tests__/llmProviderRetry.test.js +172 -0
  95. package/dist/__tests__/mcpValidator.extra.test.d.ts +5 -0
  96. package/dist/__tests__/mcpValidator.extra.test.js +270 -0
  97. package/dist/__tests__/patternMatcherExtra.test.d.ts +7 -0
  98. package/dist/__tests__/patternMatcherExtra.test.js +198 -0
  99. package/dist/__tests__/patternsCommon.test.d.ts +6 -0
  100. package/dist/__tests__/patternsCommon.test.js +107 -0
  101. package/dist/__tests__/policyEnforcement.test.d.ts +5 -0
  102. package/dist/__tests__/policyEnforcement.test.js +510 -0
  103. package/dist/__tests__/quarantineExtra.test.d.ts +5 -0
  104. package/dist/__tests__/quarantineExtra.test.js +214 -0
  105. package/dist/__tests__/redactionExtra.test.d.ts +6 -0
  106. package/dist/__tests__/redactionExtra.test.js +228 -0
  107. package/dist/__tests__/scanDiff.test.d.ts +7 -0
  108. package/dist/__tests__/scanDiff.test.js +266 -0
  109. package/dist/__tests__/scanFull.test.d.ts +6 -0
  110. package/dist/__tests__/scanFull.test.js +158 -0
  111. package/dist/__tests__/scannerDampening.test.d.ts +6 -0
  112. package/dist/__tests__/scannerDampening.test.js +160 -0
  113. package/dist/__tests__/scannerExtra.test.d.ts +6 -0
  114. package/dist/__tests__/scannerExtra.test.js +194 -0
  115. package/dist/__tests__/scannerMitre.test.d.ts +5 -0
  116. package/dist/__tests__/scannerMitre.test.js +141 -0
  117. package/dist/__tests__/scannerSSRF.test.d.ts +5 -0
  118. package/dist/__tests__/scannerSSRF.test.js +149 -0
  119. package/dist/__tests__/schemas.test.d.ts +6 -0
  120. package/dist/__tests__/schemas.test.js +125 -0
  121. package/dist/__tests__/webhooks.extra.test.d.ts +6 -0
  122. package/dist/__tests__/webhooks.extra.test.js +144 -0
  123. package/dist/__tests__/webhooks.test.d.ts +6 -0
  124. package/dist/__tests__/webhooks.test.js +154 -0
  125. package/dist/analyzers/AstAnalyzer.d.ts +5 -1
  126. package/dist/analyzers/AstAnalyzer.js +25 -4
  127. package/dist/features/customRules.js +22 -29
  128. package/dist/features/ignoreComments.js +5 -5
  129. package/dist/features/mcpTrustScore.d.ts +17 -0
  130. package/dist/features/mcpTrustScore.js +74 -0
  131. package/dist/features/mcpValidator.d.ts +2 -0
  132. package/dist/features/mcpValidator.js +13 -0
  133. package/dist/features/policyEnforcement.d.ts +22 -22
  134. package/dist/features/policyEnforcement.js +3 -2
  135. package/dist/intelligence/ThreatFeed.js +207 -62
  136. package/dist/remediation/Fixer.js +56 -30
  137. package/dist/remediation/Quarantine.js +79 -11
  138. package/dist/reporters/ConsoleReporter.js +10 -0
  139. package/dist/reporters/HtmlReporter.js +5 -0
  140. package/dist/reporters/SarifReporter.d.ts +1 -0
  141. package/dist/reporters/SarifReporter.js +1 -0
  142. package/dist/rules/ai-specific.js +8 -8
  143. package/dist/rules/backdoors.js +12 -12
  144. package/dist/rules/correlationRules.js +6 -6
  145. package/dist/rules/index.d.ts +1 -0
  146. package/dist/rules/index.js +10 -1
  147. package/dist/rules/injection.js +8 -8
  148. package/dist/rules/patterns/common.d.ts +34 -0
  149. package/dist/rules/patterns/common.js +48 -0
  150. package/dist/scanner/IAnalyzer.d.ts +19 -0
  151. package/dist/scanner/IAnalyzer.js +5 -0
  152. package/dist/scanner/PatternMatcher.js +19 -2
  153. package/dist/scanner/Scanner.js +64 -125
  154. package/dist/scanner/analyzers/CapabilityAnalyzer.d.ts +8 -0
  155. package/dist/scanner/analyzers/CapabilityAnalyzer.js +19 -0
  156. package/dist/scanner/analyzers/DependencyAnalyzer.d.ts +8 -0
  157. package/dist/scanner/analyzers/DependencyAnalyzer.js +18 -0
  158. package/dist/scanner/analyzers/EntropyAnalyzer.d.ts +8 -0
  159. package/dist/scanner/analyzers/EntropyAnalyzer.js +12 -0
  160. package/dist/scanner/analyzers/LlmAnalyzer.d.ts +17 -0
  161. package/dist/scanner/analyzers/LlmAnalyzer.js +36 -0
  162. package/dist/scanner/analyzers/McpAnalyzer.d.ts +8 -0
  163. package/dist/scanner/analyzers/McpAnalyzer.js +19 -0
  164. package/dist/scanner/analyzers/SemanticAnalyzer.d.ts +8 -0
  165. package/dist/scanner/analyzers/SemanticAnalyzer.js +21 -0
  166. package/dist/scanner/analyzers/ThreatIntelAnalyzer.d.ts +8 -0
  167. package/dist/scanner/analyzers/ThreatIntelAnalyzer.js +21 -0
  168. package/dist/types.d.ts +23 -0
  169. package/dist/types.js +1 -1
  170. package/dist/utils/baseline.d.ts +15 -2
  171. package/dist/utils/baseline.js +50 -19
  172. package/dist/utils/contentCache.d.ts +39 -0
  173. package/dist/utils/contentCache.js +77 -0
  174. package/dist/utils/glob.d.ts +50 -0
  175. package/dist/utils/glob.js +84 -0
  176. package/dist/utils/pathSecurity.js +1 -0
  177. package/dist/utils/safeRegex.d.ts +55 -0
  178. package/dist/utils/safeRegex.js +130 -0
  179. package/dist/utils/schemas.d.ts +70 -64
  180. package/dist/utils/schemas.js +13 -0
  181. package/package.json +34 -19
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Additional Webhook Tests
3
+ * Covers sendWebhook with slack/discord/teams includeDetails formatting
4
+ */
5
+ import { sendWebhook } from '../features/webhooks.js';
6
+ function makeFinding(overrides = {}) {
7
+ return {
8
+ ruleId: 'INJ-001',
9
+ ruleName: 'Test Rule',
10
+ severity: 'HIGH',
11
+ category: 'injection',
12
+ file: '/test.md',
13
+ relativePath: 'test.md',
14
+ line: 5,
15
+ match: 'bad content',
16
+ context: [],
17
+ remediation: 'fix it',
18
+ timestamp: new Date(),
19
+ riskScore: 50,
20
+ ...overrides,
21
+ };
22
+ }
23
+ function makeScanResult(findings = [], overrides = {}) {
24
+ return {
25
+ success: true,
26
+ startTime: new Date(),
27
+ endTime: new Date(),
28
+ duration: 1000,
29
+ scannedPaths: ['/project'],
30
+ totalFiles: 5,
31
+ analyzedFiles: 4,
32
+ skippedFiles: 1,
33
+ findings,
34
+ findingsBySeverity: {
35
+ CRITICAL: findings.filter(f => f.severity === 'CRITICAL'),
36
+ HIGH: findings.filter(f => f.severity === 'HIGH'),
37
+ MEDIUM: findings.filter(f => f.severity === 'MEDIUM'),
38
+ LOW: findings.filter(f => f.severity === 'LOW'),
39
+ INFO: findings.filter(f => f.severity === 'INFO'),
40
+ },
41
+ findingsByCategory: {},
42
+ overallRiskScore: 50,
43
+ summary: {
44
+ critical: findings.filter(f => f.severity === 'CRITICAL').length,
45
+ high: findings.filter(f => f.severity === 'HIGH').length,
46
+ medium: findings.filter(f => f.severity === 'MEDIUM').length,
47
+ low: findings.filter(f => f.severity === 'LOW').length,
48
+ info: 0,
49
+ total: findings.length,
50
+ },
51
+ errors: [],
52
+ ...overrides,
53
+ };
54
+ }
55
+ describe('sendWebhook with includeDetails', () => {
56
+ let originalFetch;
57
+ beforeEach(() => {
58
+ originalFetch = globalThis.fetch;
59
+ globalThis.fetch = jest.fn().mockResolvedValue({
60
+ ok: true,
61
+ status: 200,
62
+ text: () => Promise.resolve(''),
63
+ });
64
+ });
65
+ afterEach(() => {
66
+ globalThis.fetch = originalFetch;
67
+ });
68
+ it('sends slack with includeDetails and CRITICAL findings', async () => {
69
+ const result = await sendWebhook(makeScanResult([makeFinding({ severity: 'CRITICAL' })]), {
70
+ url: 'https://hooks.slack.com/services/xxx',
71
+ type: 'slack',
72
+ includeDetails: true,
73
+ });
74
+ expect(result.success).toBe(true);
75
+ expect(globalThis.fetch).toHaveBeenCalled();
76
+ const body = JSON.parse(globalThis.fetch.mock.calls[0][1]?.body);
77
+ expect(body.attachments).toBeDefined();
78
+ });
79
+ it('sends discord with includeDetails', async () => {
80
+ const result = await sendWebhook(makeScanResult([makeFinding({ severity: 'HIGH' })]), {
81
+ url: 'https://discord.com/api/webhooks/x/y',
82
+ type: 'discord',
83
+ includeDetails: true,
84
+ });
85
+ expect(result.success).toBe(true);
86
+ const body = JSON.parse(globalThis.fetch.mock.calls[0][1]?.body);
87
+ expect(body.embeds).toBeDefined();
88
+ });
89
+ it('sends teams with includeDetails', async () => {
90
+ const result = await sendWebhook(makeScanResult([makeFinding({ severity: 'MEDIUM' })]), {
91
+ url: 'https://org.webhook.office.com/webhook',
92
+ type: 'teams',
93
+ includeDetails: true,
94
+ });
95
+ expect(result.success).toBe(true);
96
+ const body = JSON.parse(globalThis.fetch.mock.calls[0][1]?.body);
97
+ expect(body['@type']).toBe('MessageCard');
98
+ });
99
+ it('sends generic webhook without error', async () => {
100
+ const result = await sendWebhook(makeScanResult([makeFinding()]), {
101
+ url: 'https://custom-webhook.example.com/hook',
102
+ type: 'generic',
103
+ includeDetails: true,
104
+ headers: { 'X-Custom-Token': 'abc123' },
105
+ });
106
+ expect(result.success).toBe(true);
107
+ const [, options] = globalThis.fetch.mock.calls[0];
108
+ expect(options.headers['X-Custom-Token']).toBe('abc123');
109
+ });
110
+ it('sends with medium severity findings triggering yellow color', async () => {
111
+ const result = await sendWebhook(makeScanResult([makeFinding({ severity: 'MEDIUM' })]), {
112
+ url: 'https://hooks.slack.com/services/xxx',
113
+ type: 'slack',
114
+ includeDetails: false,
115
+ });
116
+ expect(result.success).toBe(true);
117
+ });
118
+ it('sends with no findings (green color path)', async () => {
119
+ const result = await sendWebhook(makeScanResult([]), {
120
+ url: 'https://hooks.slack.com/services/xxx',
121
+ type: 'slack',
122
+ includeDetails: false,
123
+ });
124
+ expect(result.success).toBe(true);
125
+ });
126
+ it('respects custom timeout option', async () => {
127
+ const result = await sendWebhook(makeScanResult(), {
128
+ url: 'https://hooks.slack.com/services/xxx',
129
+ type: 'slack',
130
+ timeout: 5000,
131
+ });
132
+ expect(result.success).toBe(true);
133
+ });
134
+ it('skips sending when all findings are below minSeverity', async () => {
135
+ const result = await sendWebhook(makeScanResult([makeFinding({ severity: 'LOW' }), makeFinding({ severity: 'INFO' })]), {
136
+ url: 'https://hooks.slack.com/services/xxx',
137
+ type: 'slack',
138
+ minSeverity: 'HIGH',
139
+ });
140
+ expect(result.success).toBe(true);
141
+ expect(globalThis.fetch).not.toHaveBeenCalled();
142
+ });
143
+ });
144
+ //# sourceMappingURL=webhooks.extra.test.js.map
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Webhooks Tests
3
+ * Tests for detectWebhookType and sendWebhook (mocking fetch).
4
+ */
5
+ export {};
6
+ //# sourceMappingURL=webhooks.test.d.ts.map
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Webhooks Tests
3
+ * Tests for detectWebhookType and sendWebhook (mocking fetch).
4
+ */
5
+ import { detectWebhookType, sendWebhook, } from '../features/webhooks.js';
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+ function makeFinding(overrides = {}) {
10
+ return {
11
+ ruleId: 'INJ-001',
12
+ ruleName: 'Test Rule',
13
+ severity: 'HIGH',
14
+ category: 'injection',
15
+ file: '/test.md',
16
+ relativePath: 'test.md',
17
+ line: 5,
18
+ match: 'bad content',
19
+ context: [],
20
+ remediation: 'fix it',
21
+ timestamp: new Date(),
22
+ riskScore: 50,
23
+ ...overrides,
24
+ };
25
+ }
26
+ function makeScanResult(findings = []) {
27
+ return {
28
+ success: true,
29
+ startTime: new Date(),
30
+ endTime: new Date(),
31
+ duration: 1000,
32
+ scannedPaths: ['/project'],
33
+ totalFiles: 5,
34
+ analyzedFiles: 4,
35
+ skippedFiles: 1,
36
+ findings,
37
+ findingsBySeverity: {
38
+ CRITICAL: findings.filter(f => f.severity === 'CRITICAL'),
39
+ HIGH: findings.filter(f => f.severity === 'HIGH'),
40
+ MEDIUM: findings.filter(f => f.severity === 'MEDIUM'),
41
+ LOW: findings.filter(f => f.severity === 'LOW'),
42
+ INFO: findings.filter(f => f.severity === 'INFO'),
43
+ },
44
+ findingsByCategory: {},
45
+ overallRiskScore: 50,
46
+ summary: {
47
+ critical: 0,
48
+ high: findings.filter(f => f.severity === 'HIGH').length,
49
+ medium: 0, low: 0, info: 0,
50
+ total: findings.length,
51
+ },
52
+ errors: [],
53
+ };
54
+ }
55
+ function makeWebhookConfig(overrides = {}) {
56
+ return {
57
+ url: 'https://hooks.example.com/webhook',
58
+ type: 'generic',
59
+ ...overrides,
60
+ };
61
+ }
62
+ // ---------------------------------------------------------------------------
63
+ // detectWebhookType
64
+ // ---------------------------------------------------------------------------
65
+ describe('detectWebhookType', () => {
66
+ it('detects Slack URL', () => {
67
+ expect(detectWebhookType('https://hooks.slack.com/services/xxx')).toBe('slack');
68
+ });
69
+ it('detects Discord URL', () => {
70
+ expect(detectWebhookType('https://discord.com/api/webhooks/123/abc')).toBe('discord');
71
+ });
72
+ it('detects Teams from webhook.office.com', () => {
73
+ expect(detectWebhookType('https://myorg.webhook.office.com/webhookb2/xxx')).toBe('teams');
74
+ });
75
+ it('detects Teams from outlook.office.com', () => {
76
+ expect(detectWebhookType('https://myorg.outlook.office.com/webhook/xxx')).toBe('teams');
77
+ });
78
+ it('returns generic for unknown URLs', () => {
79
+ expect(detectWebhookType('https://my-custom-webhook.example.com/hook')).toBe('generic');
80
+ });
81
+ it('returns generic for empty string', () => {
82
+ expect(detectWebhookType('')).toBe('generic');
83
+ });
84
+ });
85
+ // ---------------------------------------------------------------------------
86
+ // sendWebhook — with mocked fetch
87
+ // ---------------------------------------------------------------------------
88
+ describe('sendWebhook', () => {
89
+ let originalFetch;
90
+ beforeEach(() => {
91
+ originalFetch = globalThis.fetch;
92
+ });
93
+ afterEach(() => {
94
+ globalThis.fetch = originalFetch;
95
+ });
96
+ function mockFetch(status, ok, body = '') {
97
+ globalThis.fetch = jest.fn().mockResolvedValue({
98
+ ok,
99
+ status,
100
+ text: () => Promise.resolve(body),
101
+ });
102
+ }
103
+ it('returns success when fetch succeeds with 200', async () => {
104
+ mockFetch(200, true);
105
+ const result = await sendWebhook(makeScanResult(), makeWebhookConfig());
106
+ expect(result.success).toBe(true);
107
+ expect(result.statusCode).toBe(200);
108
+ });
109
+ it('returns failure when fetch returns non-ok status', async () => {
110
+ mockFetch(500, false, 'Internal Server Error');
111
+ const result = await sendWebhook(makeScanResult(), makeWebhookConfig());
112
+ expect(result.success).toBe(false);
113
+ expect(result.statusCode).toBe(500);
114
+ });
115
+ it('returns failure when fetch throws', async () => {
116
+ globalThis.fetch = jest.fn().mockRejectedValue(new Error('network error'));
117
+ const result = await sendWebhook(makeScanResult(), makeWebhookConfig());
118
+ expect(result.success).toBe(false);
119
+ expect(result.error).toContain('network error');
120
+ });
121
+ it('sends to slack type without error', async () => {
122
+ mockFetch(200, true);
123
+ const result = await sendWebhook(makeScanResult([makeFinding()]), makeWebhookConfig({ type: 'slack', url: 'https://hooks.slack.com/services/xxx' }));
124
+ expect(result.success).toBe(true);
125
+ });
126
+ it('sends to discord type without error', async () => {
127
+ mockFetch(200, true);
128
+ const result = await sendWebhook(makeScanResult([makeFinding()]), makeWebhookConfig({ type: 'discord', url: 'https://discord.com/api/webhooks/x/y' }));
129
+ expect(result.success).toBe(true);
130
+ });
131
+ it('sends to teams type without error', async () => {
132
+ mockFetch(200, true);
133
+ const result = await sendWebhook(makeScanResult([makeFinding()]), makeWebhookConfig({ type: 'teams', url: 'https://org.webhook.office.com/webhook' }));
134
+ expect(result.success).toBe(true);
135
+ });
136
+ it('skips when minSeverity not met and findings exist', async () => {
137
+ mockFetch(200, true);
138
+ const result = await sendWebhook(makeScanResult([makeFinding({ severity: 'LOW' })]), makeWebhookConfig({ minSeverity: 'HIGH' }));
139
+ // Should return success=true without sending
140
+ expect(result.success).toBe(true);
141
+ });
142
+ it('sends when minSeverity is met', async () => {
143
+ mockFetch(200, true);
144
+ const result = await sendWebhook(makeScanResult([makeFinding({ severity: 'CRITICAL' })]), makeWebhookConfig({ minSeverity: 'HIGH' }));
145
+ expect(result.success).toBe(true);
146
+ expect(globalThis.fetch).toHaveBeenCalled();
147
+ });
148
+ it('includes details when includeDetails is true', async () => {
149
+ mockFetch(200, true);
150
+ const result = await sendWebhook(makeScanResult([makeFinding()]), makeWebhookConfig({ includeDetails: true }));
151
+ expect(result.success).toBe(true);
152
+ });
153
+ });
154
+ //# sourceMappingURL=webhooks.test.js.map
@@ -6,7 +6,11 @@ import type { SemanticFinding, DiscoveredFile, Rule } from '../types.js';
6
6
  /**
7
7
  * Analyze a single file for semantic patterns
8
8
  */
9
- export declare function analyzeFile(file: DiscoveredFile, content: string, rules: Rule[]): Promise<SemanticFinding[]>;
9
+ export declare function analyzeFile(file: DiscoveredFile, content: string, rules: Rule[], opts?: {
10
+ maxMs?: number;
11
+ maxNodes?: number;
12
+ maxBlockMs?: number;
13
+ }): Promise<SemanticFinding[]>;
10
14
  /**
11
15
  * Check if semantic analysis should be performed
12
16
  */
@@ -133,11 +133,19 @@ function extractSemanticContext(tsLib, sourceFile) {
133
133
  return context;
134
134
  }
135
135
  /**
136
- * Find security patterns in AST
136
+ * Find security patterns in AST, with optional time and node-count guards.
137
137
  */
138
- function findSecurityPatterns(tsLib, sourceFile, patterns) {
138
+ function findSecurityPatterns(tsLib, sourceFile, patterns, opts) {
139
139
  const matches = [];
140
+ let nodeCount = 0;
141
+ const deadline = opts?.deadline;
142
+ const maxNodes = opts?.maxNodes ?? 50_000;
140
143
  function visit(node) {
144
+ nodeCount++;
145
+ if (nodeCount > maxNodes)
146
+ return;
147
+ if (deadline !== undefined && Date.now() > deadline)
148
+ return;
141
149
  for (const pattern of patterns) {
142
150
  const match = matchSemanticPattern(tsLib, node, pattern, sourceFile);
143
151
  if (match) {
@@ -277,8 +285,11 @@ function createContextLines(sourceFile, node, contextLines = 3) {
277
285
  /**
278
286
  * Analyze a single file for semantic patterns
279
287
  */
280
- export async function analyzeFile(file, content, rules) {
288
+ export async function analyzeFile(file, content, rules, opts) {
281
289
  const findings = [];
290
+ const maxMs = opts?.maxMs ?? 2000;
291
+ const maxNodes = opts?.maxNodes ?? 50_000;
292
+ const perBlockMs = Math.min(maxMs, opts?.maxBlockMs ?? 500);
282
293
  try {
283
294
  // Get rules with semantic patterns
284
295
  const semanticRules = rules.filter(rule => rule.semanticPatterns && rule.semanticPatterns.length > 0);
@@ -296,16 +307,26 @@ export async function analyzeFile(file, content, rules) {
296
307
  // Analyze the entire file for TypeScript/JavaScript files
297
308
  codeBlocksToAnalyze = [{ code: content, language: file.type, line: 1 }];
298
309
  }
310
+ const fileDeadline = Date.now() + maxMs;
299
311
  // Analyze each code block
300
312
  for (const codeBlock of codeBlocksToAnalyze) {
313
+ if (Date.now() > fileDeadline) {
314
+ logger.warn(`AST analysis file deadline (${maxMs}ms) reached for ${file.relativePath}; skipping remaining code blocks`);
315
+ break;
316
+ }
301
317
  try {
302
318
  const sourceFile = createAST(tsLib, codeBlock.code, `${file.relativePath}_block_${codeBlock.line}.${codeBlock.language}`);
303
319
  const semanticContext = extractSemanticContext(tsLib, sourceFile);
320
+ // Per-block deadline: min of (remaining file budget, per-block cap).
321
+ const blockDeadline = Math.min(fileDeadline, Date.now() + perBlockMs);
304
322
  // Check each semantic rule
305
323
  for (const rule of semanticRules) {
306
324
  if (!rule.semanticPatterns)
307
325
  continue;
308
- const patternMatches = findSecurityPatterns(tsLib, sourceFile, rule.semanticPatterns);
326
+ const patternMatches = findSecurityPatterns(tsLib, sourceFile, rule.semanticPatterns, {
327
+ deadline: blockDeadline,
328
+ maxNodes,
329
+ });
309
330
  for (const match of patternMatches) {
310
331
  const position = getPositionFromNode(match.node, sourceFile);
311
332
  const astNodeInfo = createASTNodeInfo(tsLib, match.node, sourceFile);
@@ -7,6 +7,7 @@ import { createHash } from 'node:crypto';
7
7
  import { resolve, extname } from 'node:path';
8
8
  import { parse as parseYaml } from 'yaml';
9
9
  import { z } from 'zod';
10
+ import { compileSafePattern } from '../utils/safeRegex.js';
10
11
  import logger from '../utils/logger.js';
11
12
  /**
12
13
  * Schema for custom rule definition in YAML/JSON
@@ -139,14 +140,15 @@ function parseCustomRulesContent(content, sourceExt, sourceLabel) {
139
140
  * Convert custom rule definition to Rule object
140
141
  */
141
142
  function definitionToRule(def) {
142
- // Compile regex patterns with error handling
143
+ // Compile regex patterns reject unsafe patterns via compileSafePattern
143
144
  const patterns = [];
144
145
  for (const pattern of def.patterns) {
145
- try {
146
- patterns.push(new RegExp(pattern, 'gi'));
146
+ const compiled = compileSafePattern(pattern, 'gi');
147
+ if (compiled === null) {
148
+ logger.warn(`Unsafe or invalid regex pattern in rule ${def.id} (skipped): ${pattern}`);
147
149
  }
148
- catch {
149
- logger.warn(`Invalid regex pattern in rule ${def.id}: ${pattern}`);
150
+ else {
151
+ patterns.push(compiled);
150
152
  }
151
153
  }
152
154
  if (patterns.length === 0) {
@@ -154,33 +156,27 @@ function definitionToRule(def) {
154
156
  }
155
157
  // Compile exclude patterns
156
158
  const excludePatterns = def.excludePatterns?.map((p) => {
157
- try {
158
- return new RegExp(p, 'gi');
159
- }
160
- catch {
161
- logger.warn(`Invalid exclude pattern in rule ${def.id}: ${p}`);
162
- return null;
159
+ const compiled = compileSafePattern(p, 'gi');
160
+ if (compiled === null) {
161
+ logger.warn(`Unsafe or invalid exclude pattern in rule ${def.id} (skipped): ${p}`);
163
162
  }
163
+ return compiled;
164
164
  }).filter((p) => p !== null);
165
165
  // Compile require context patterns
166
166
  const requireContext = def.requireContext?.map((p) => {
167
- try {
168
- return new RegExp(p, 'gi');
169
- }
170
- catch {
171
- logger.warn(`Invalid requireContext pattern in rule ${def.id}: ${p}`);
172
- return null;
167
+ const compiled = compileSafePattern(p, 'gi');
168
+ if (compiled === null) {
169
+ logger.warn(`Unsafe or invalid requireContext pattern in rule ${def.id} (skipped): ${p}`);
173
170
  }
171
+ return compiled;
174
172
  }).filter((p) => p !== null);
175
173
  // Compile exclude context patterns
176
174
  const excludeContext = def.excludeContext?.map((p) => {
177
- try {
178
- return new RegExp(p, 'gi');
179
- }
180
- catch {
181
- logger.warn(`Invalid excludeContext pattern in rule ${def.id}: ${p}`);
182
- return null;
175
+ const compiled = compileSafePattern(p, 'gi');
176
+ if (compiled === null) {
177
+ logger.warn(`Unsafe or invalid excludeContext pattern in rule ${def.id} (skipped): ${p}`);
183
178
  }
179
+ return compiled;
184
180
  }).filter((p) => p !== null);
185
181
  const rule = {
186
182
  id: def.id,
@@ -458,14 +454,11 @@ export function validateCustomRulesFile(filePath) {
458
454
  warnings,
459
455
  };
460
456
  }
461
- // Validate regex patterns
457
+ // Validate regex patterns — also screen for ReDoS risks
462
458
  for (const rule of result.data.rules) {
463
459
  for (const pattern of rule.patterns) {
464
- try {
465
- void new RegExp(pattern, 'gi');
466
- }
467
- catch {
468
- errors.push(`Rule ${rule.id}: Invalid regex pattern "${pattern}"`);
460
+ if (compileSafePattern(pattern, 'gi') === null) {
461
+ errors.push(`Rule ${rule.id}: Unsafe or invalid regex pattern "${pattern}"`);
469
462
  }
470
463
  }
471
464
  }
@@ -8,16 +8,16 @@ import logger from '../utils/logger.js';
8
8
  */
9
9
  const COMMENT_PATTERNS = {
10
10
  default: [
11
- /\/\/\s*ferret-(ignore|disable|enable|ignore-line|ignore-next-line)(?:\s+([^\n]+))?/gi,
12
- /\/\*\s*ferret-(ignore|disable|enable|ignore-line|ignore-next-line)(?:\s+([^*]+))?\s*\*\//gi,
13
- /#\s*ferret-(ignore|disable|enable|ignore-line|ignore-next-line)(?:\s+([^\n]+))?/gi,
11
+ /\/\/\s*ferret-(ignore-next-line|ignore-line|ignore|disable|enable)(?:\s+([^\n]+))?/gi,
12
+ /\/\*\s*ferret-(ignore-next-line|ignore-line|ignore|disable|enable)(?:\s+([^*]+))?\s*\*\//gi,
13
+ /#\s*ferret-(ignore-next-line|ignore-line|ignore|disable|enable)(?:\s+([^\n]+))?/gi,
14
14
  ],
15
15
  html: [
16
16
  // Non-greedy capture so rule ids like "INJ-001" (with hyphens) work correctly.
17
- /<!--\s*ferret-(ignore|disable|enable|ignore-line|ignore-next-line)(?:\s+(.+?))?\s*-->/gi,
17
+ /<!--\s*ferret-(ignore-next-line|ignore-line|ignore|disable|enable)(?:\s+(.+?))?\s*-->/gi,
18
18
  ],
19
19
  sql: [
20
- /--\s*ferret-(ignore|disable|enable|ignore-line|ignore-next-line)(?:\s+([^\n]+))?/gi,
20
+ /--\s*ferret-(ignore-next-line|ignore-line|ignore|disable|enable)(?:\s+([^\n]+))?/gi,
21
21
  ],
22
22
  };
23
23
  /**
@@ -0,0 +1,17 @@
1
+ /**
2
+ * MCP Server Trust Scoring
3
+ * Evaluates the security posture of an MCP server configuration.
4
+ */
5
+ export interface McpTrustResult {
6
+ score: number;
7
+ trustLevel: 'HIGH' | 'MEDIUM' | 'LOW' | 'CRITICAL';
8
+ flags: string[];
9
+ }
10
+ /**
11
+ * Score an MCP server configuration entry.
12
+ *
13
+ * @param serverConfig - A single MCP server config object (value from `mcpServers` map)
14
+ * @returns Trust score (0-100), trust level, and list of flags
15
+ */
16
+ export declare function scoreMcpServer(serverConfig: unknown): McpTrustResult;
17
+ //# sourceMappingURL=mcpTrustScore.d.ts.map
@@ -0,0 +1,74 @@
1
+ /**
2
+ * MCP Server Trust Scoring
3
+ * Evaluates the security posture of an MCP server configuration.
4
+ */
5
+ // Known suspicious package names / name fragments
6
+ const SUSPICIOUS_NAMES = [
7
+ 'shadow', 'stealer', 'exfil', 'beacon', 'c2-', '-c2',
8
+ 'keylog', 'implant', 'dropper', 'exploit',
9
+ ];
10
+ /**
11
+ * Score an MCP server configuration entry.
12
+ *
13
+ * @param serverConfig - A single MCP server config object (value from `mcpServers` map)
14
+ * @returns Trust score (0-100), trust level, and list of flags
15
+ */
16
+ export function scoreMcpServer(serverConfig) {
17
+ const flags = [];
18
+ let score = 100;
19
+ if (typeof serverConfig !== 'object' || serverConfig === null) {
20
+ return { score: 0, trustLevel: 'CRITICAL', flags: ['Invalid config object'] };
21
+ }
22
+ const cfg = serverConfig;
23
+ // Insecure transport
24
+ const transport = cfg['transport'];
25
+ if (transport === 'http' || transport === 'sse') {
26
+ score -= 30;
27
+ flags.push(`Insecure transport: '${transport}' — prefer stdio or wss`);
28
+ }
29
+ // Plain HTTP URL
30
+ const url = typeof cfg['url'] === 'string' ? cfg['url'] : '';
31
+ if (url.startsWith('http://')) {
32
+ score -= 25;
33
+ flags.push('Plain HTTP URL — credentials and tool calls are transmitted in cleartext');
34
+ }
35
+ // Unpinned npx command
36
+ const command = typeof cfg['command'] === 'string' ? cfg['command'] : '';
37
+ if (command === 'npx' || command.endsWith('/npx')) {
38
+ const args = Array.isArray(cfg['args']) ? cfg['args'] : [];
39
+ const firstArg = args[0] ?? '';
40
+ if (firstArg && !firstArg.includes('@') && !firstArg.startsWith('-')) {
41
+ score -= 20;
42
+ flags.push(`Unpinned npx package '${firstArg}' — pin to a specific version to prevent rug pulls`);
43
+ }
44
+ }
45
+ // Dangerous flags in args
46
+ const args = Array.isArray(cfg['args']) ? cfg['args'] : [];
47
+ for (const arg of args) {
48
+ if (typeof arg === 'string' && (arg.includes('--allow-all') || arg.includes('--dangerously-skip'))) {
49
+ score -= 30;
50
+ flags.push(`Dangerous arg '${arg}' — bypasses MCP safety checks`);
51
+ }
52
+ }
53
+ // Suspicious name
54
+ const name = typeof cfg['name'] === 'string' ? cfg['name'].toLowerCase() : '';
55
+ const pkg = args.find(a => typeof a === 'string' && !a.startsWith('-')) ?? '';
56
+ const combined = `${name} ${pkg}`.toLowerCase();
57
+ for (const pattern of SUSPICIOUS_NAMES) {
58
+ if (combined.includes(pattern)) {
59
+ score -= 50;
60
+ flags.push(`Name matches suspicious pattern '${pattern}'`);
61
+ break;
62
+ }
63
+ }
64
+ const clampedScore = Math.max(0, Math.min(100, score));
65
+ return {
66
+ score: clampedScore,
67
+ trustLevel: clampedScore >= 80 ? 'HIGH'
68
+ : clampedScore >= 60 ? 'MEDIUM'
69
+ : clampedScore >= 40 ? 'LOW'
70
+ : 'CRITICAL',
71
+ flags,
72
+ };
73
+ }
74
+ //# sourceMappingURL=mcpTrustScore.js.map
@@ -3,6 +3,7 @@
3
3
  * Validates .mcp.json files for dangerous permissions, untrusted sources, etc.
4
4
  */
5
5
  import type { Finding, Severity } from '../types.js';
6
+ import { type McpTrustResult } from './mcpTrustScore.js';
6
7
  /**
7
8
  * Risk assessment for MCP servers
8
9
  */
@@ -18,6 +19,7 @@ export interface McpRiskAssessment {
18
19
  capabilities: string[];
19
20
  command?: string | undefined;
20
21
  url?: string | undefined;
22
+ trustScore?: McpTrustResult | undefined;
21
23
  }
22
24
  /**
23
25
  * Validate MCP configuration JSON content
@@ -7,6 +7,7 @@
7
7
  import { readFileSync, existsSync } from 'node:fs';
8
8
  import { resolve, basename } from 'node:path';
9
9
  import { z } from 'zod';
10
+ import { scoreMcpServer } from './mcpTrustScore.js';
10
11
  /**
11
12
  * MCP Server configuration schema
12
13
  */
@@ -282,6 +283,18 @@ export function validateMcpConfigContent(content) {
282
283
  for (const [name, config] of Object.entries(servers)) {
283
284
  if (typeof config === 'object' && config !== null) {
284
285
  const assessment = analyzeServer(name, config);
286
+ // Augment with trust score; surface CRITICAL/LOW trust as issues
287
+ assessment.trustScore = scoreMcpServer({ ...config, name });
288
+ if (assessment.trustScore.trustLevel === 'CRITICAL' || assessment.trustScore.trustLevel === 'LOW') {
289
+ for (const flag of assessment.trustScore.flags) {
290
+ assessment.issues.push({
291
+ type: 'trust-score',
292
+ severity: assessment.trustScore.trustLevel === 'CRITICAL' ? 'CRITICAL' : 'HIGH',
293
+ description: `Trust score ${assessment.trustScore.score}/100: ${flag}`,
294
+ remediation: 'Review MCP server configuration and address the flagged concern.',
295
+ });
296
+ }
297
+ }
285
298
  assessments.push(assessment);
286
299
  }
287
300
  }