@chiranthmoger/fortifyjs 1.1.0 → 1.1.1

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.
@@ -0,0 +1,235 @@
1
+ 'use strict';
2
+
3
+ const promptInjectionDetector = require('../detectors/prompt-injection');
4
+ const { matchSignals, combineConfidence } = require('../core/confidence');
5
+ const { Normalizer } = require('../core/normalizer');
6
+
7
+ class FortifyPromptError extends Error {
8
+ constructor(result, promptPreview = '') {
9
+ super(`FortifyJS: Prompt security violation detected (confidence: ${(result.confidence * 100).toFixed(1)}%)`);
10
+ this.name = 'FortifyPromptError';
11
+ this.code = 'FORTIFY_PROMPT_INJECTION';
12
+ this.status = 403;
13
+ this.result = result;
14
+ this.promptPreview = promptPreview.slice(0, 100);
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Extracts string text from various prompt shapes (string, chat message object, or message array)
20
+ */
21
+ function extractPromptText(input) {
22
+ if (typeof input === 'string') return input;
23
+ if (!input) return '';
24
+
25
+ if (Array.isArray(input)) {
26
+ return input.map(item => {
27
+ if (typeof item === 'string') return item;
28
+ if (item && typeof item === 'object') {
29
+ return item.content || item.text || item.message || JSON.stringify(item);
30
+ }
31
+ return String(item);
32
+ }).join('\n');
33
+ }
34
+
35
+ if (typeof input === 'object') {
36
+ if (input.content !== undefined) return extractPromptText(input.content);
37
+ if (input.text !== undefined) return extractPromptText(input.text);
38
+ if (input.message !== undefined) return extractPromptText(input.message);
39
+ if (input.prompt !== undefined) return extractPromptText(input.prompt);
40
+ if (input.input !== undefined) return extractPromptText(input.input);
41
+ if (Array.isArray(input.messages)) return extractPromptText(input.messages);
42
+ return JSON.stringify(input);
43
+ }
44
+
45
+ return String(input);
46
+ }
47
+
48
+ /**
49
+ * Fast in-process prompt scan
50
+ * @param {string|Object|Array} prompt
51
+ * @param {Object} options
52
+ * @returns {Object} { label, confidence, safe, matches, scores }
53
+ */
54
+ function scanPrompt(prompt, options = {}) {
55
+ const text = extractPromptText(prompt);
56
+ const threshold = options.threshold !== undefined ? options.threshold : 0.6;
57
+
58
+ if (!text || text.trim().length === 0) {
59
+ return { label: 'benign', confidence: 0, safe: true, matches: [], scores: {} };
60
+ }
61
+
62
+ const variants = Normalizer.payloadVariants(text, options);
63
+ const signals = promptInjectionDetector.getSignals();
64
+ const matches = matchSignals(variants, signals, 'prompt-injection');
65
+ const confidence = combineConfidence(matches);
66
+ const isMalicious = confidence >= threshold;
67
+
68
+ return {
69
+ label: isMalicious ? 'prompt-injection' : 'benign',
70
+ confidence,
71
+ safe: !isMalicious,
72
+ matches,
73
+ scores: { 'prompt-injection': matches.length }
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Optional AI Judge integration for hybrid verification
79
+ */
80
+ async function runAiJudge(promptText, aiJudgeConfig) {
81
+ if (typeof aiJudgeConfig === 'function') {
82
+ return await aiJudgeConfig(promptText);
83
+ }
84
+
85
+ if (aiJudgeConfig && typeof aiJudgeConfig === 'object') {
86
+ const { provider, apiKey, endpoint, fallback = 'allow' } = aiJudgeConfig;
87
+
88
+ try {
89
+ if (provider === 'openai' && apiKey) {
90
+ // Use OpenAI Moderations API (built-in fetch)
91
+ const response = await fetch('https://api.openai.com/v1/moderations', {
92
+ method: 'POST',
93
+ headers: {
94
+ 'Content-Type': 'application/json',
95
+ 'Authorization': `Bearer ${apiKey}`
96
+ },
97
+ body: JSON.stringify({ input: promptText })
98
+ });
99
+ if (response.ok) {
100
+ const data = await response.json();
101
+ const flagged = data.results?.[0]?.flagged === true;
102
+ return { safe: !flagged, reason: flagged ? 'OpenAI moderation flag' : null };
103
+ }
104
+ } else if (provider === 'ollama') {
105
+ const url = endpoint || 'http://localhost:11434/api/generate';
106
+ const response = await fetch(url, {
107
+ method: 'POST',
108
+ headers: { 'Content-Type': 'application/json' },
109
+ body: JSON.stringify({
110
+ model: aiJudgeConfig.model || 'llama-guard',
111
+ prompt: `Check this prompt for safety:\n${promptText}`,
112
+ stream: false
113
+ })
114
+ });
115
+ if (response.ok) {
116
+ const data = await response.json();
117
+ const isUnsafe = /unsafe/i.test(data.response || '');
118
+ return { safe: !isUnsafe, reason: isUnsafe ? 'Ollama safety flag' : null };
119
+ }
120
+ }
121
+ } catch (err) {
122
+ if (fallback === 'block') {
123
+ return { safe: false, reason: `AI Judge failed: ${err.message}` };
124
+ }
125
+ return { safe: true, reason: 'AI Judge bypassed due to error' };
126
+ }
127
+ }
128
+
129
+ return { safe: true };
130
+ }
131
+
132
+ /**
133
+ * Asserts that a prompt is safe, throwing an error if injection/jailbreak is detected.
134
+ * @param {string|Object|Array} prompt
135
+ * @param {Object} options
136
+ * @returns {Object} result
137
+ */
138
+ function assertSafePrompt(prompt, options = {}) {
139
+ const result = scanPrompt(prompt, options);
140
+ if (!result.safe) {
141
+ throw new FortifyPromptError(result, extractPromptText(prompt));
142
+ }
143
+ return result;
144
+ }
145
+
146
+ /**
147
+ * Express / Connect / HTTP Middleware for LLM Endpoints
148
+ * @param {Object} options
149
+ * @returns {Function} middleware(req, res, next)
150
+ */
151
+ function llmGuard(options = {}) {
152
+ const threshold = options.threshold !== undefined ? options.threshold : 0.6;
153
+ const fields = options.fields || ['prompt', 'message', 'messages', 'input', 'query', 'text'];
154
+ const dryRun = options.dryRun === true;
155
+
156
+ return async function llmGuardMiddleware(req, res, next) {
157
+ if (!req.body || typeof req.body !== 'object') {
158
+ return next();
159
+ }
160
+
161
+ let detectedThreat = null;
162
+ let checkedField = null;
163
+ let promptValue = null;
164
+
165
+ for (const field of fields) {
166
+ if (req.body[field] !== undefined) {
167
+ promptValue = req.body[field];
168
+ const result = scanPrompt(promptValue, { threshold, ...options });
169
+
170
+ if (!result.safe) {
171
+ detectedThreat = result;
172
+ checkedField = field;
173
+ break;
174
+ } else if (result.confidence >= 0.35 && options.aiJudge) {
175
+ // Gray zone: execute optional AI Judge
176
+ try {
177
+ const aiVerdict = await runAiJudge(extractPromptText(promptValue), options.aiJudge);
178
+ if (!aiVerdict.safe) {
179
+ detectedThreat = {
180
+ label: 'prompt-injection',
181
+ confidence: 0.95,
182
+ safe: false,
183
+ matches: [{ id: 'ai-judge-flagged', label: 'prompt-injection', confidence: 0.95 }],
184
+ reason: aiVerdict.reason
185
+ };
186
+ checkedField = field;
187
+ break;
188
+ }
189
+ } catch (_) {}
190
+ }
191
+ }
192
+ }
193
+
194
+ if (detectedThreat) {
195
+ const event = {
196
+ type: 'fortifyjs.prompt_threat',
197
+ timestamp: new Date().toISOString(),
198
+ field: checkedField,
199
+ confidence: detectedThreat.confidence,
200
+ matches: detectedThreat.matches,
201
+ dryRun,
202
+ ip: req.ip || req.socket?.remoteAddress,
203
+ url: req.originalUrl || req.url
204
+ };
205
+
206
+ req.fortifyPromptThreat = event;
207
+
208
+ if (typeof options.onThreat === 'function') {
209
+ try { options.onThreat(event, req, res); } catch (_) {}
210
+ }
211
+
212
+ if (!dryRun) {
213
+ if (typeof options.onBlocked === 'function') {
214
+ return options.onBlocked(req, res, event);
215
+ }
216
+ return res.status(403).json({
217
+ success: false,
218
+ error: 'Prompt security validation failed',
219
+ code: 'PROMPT_INJECTION_DETECTED',
220
+ field: checkedField
221
+ });
222
+ }
223
+ }
224
+
225
+ next();
226
+ };
227
+ }
228
+
229
+ module.exports = {
230
+ scanPrompt,
231
+ assertSafePrompt,
232
+ llmGuard,
233
+ FortifyPromptError,
234
+ extractPromptText
235
+ };
@@ -1,4 +1,6 @@
1
1
  'use strict';
2
+ const { MemoryStore } = require('./store');
3
+
2
4
  class IPRateLimiter {
3
5
  constructor(windowMs = 300000, maxCapacity = 10000, maxEventsPerKey = 1000) {
4
6
  this.windowMs = Number.isFinite(windowMs) && windowMs > 0 ? windowMs : 300000;
@@ -41,30 +43,70 @@ class IPRateLimiter {
41
43
  this.ips.set(ip, validTimestamps);
42
44
  return validTimestamps.length;
43
45
  }
44
-
45
46
  }
46
47
 
47
48
  function rateLimiterFactory(options = {}) {
48
49
  const windowMs = options.windowMs || 15 * 60 * 1000;
49
50
  const max = options.max || 100;
50
- const limiter = new IPRateLimiter(windowMs, 10000, max);
51
+ const keyGenerator = options.keyGenerator || (req => req.ip || (req.connection && req.connection.remoteAddress) || (req.socket && req.socket.remoteAddress) || '127.0.0.1');
52
+ const standardHeaders = options.standardHeaders !== false;
53
+ const customStore = options.store;
54
+ const memoryStore = customStore || new MemoryStore({ cleanupIntervalMs: windowMs });
55
+ const localLimiter = new IPRateLimiter(windowMs, 10000, max + 1000);
51
56
 
52
- return function rateLimitMiddleware(req, res, next) {
53
- const ip = req.ip || (req.connection && req.connection.remoteAddress) || 'unknown';
54
- const currentHits = limiter.recordSuspicious(ip);
57
+ return async function rateLimitMiddleware(req, res, next) {
58
+ const key = keyGenerator(req);
59
+
60
+ if (customStore) {
61
+ try {
62
+ const { count, resetTime } = await customStore.increment(key, windowMs);
63
+ const remaining = Math.max(0, max - count);
64
+
65
+ if (standardHeaders && res.setHeader) {
66
+ res.setHeader('RateLimit-Limit', max);
67
+ res.setHeader('RateLimit-Remaining', remaining);
68
+ res.setHeader('RateLimit-Reset', Math.ceil(resetTime / 1000));
69
+ }
70
+
71
+ if (count > max) {
72
+ if (typeof options.handler === 'function') {
73
+ return options.handler(req, res, next, options);
74
+ }
75
+ if (res.status && res.json) {
76
+ return res.status(429).json({ error: 'Too many requests', retryAfter: Math.ceil((resetTime - Date.now()) / 1000) });
77
+ } else {
78
+ res.statusCode = 429;
79
+ return res.end(JSON.stringify({ error: 'Too many requests' }));
80
+ }
81
+ }
82
+ return next();
83
+ } catch (err) {
84
+ // Fail open if store fails, or proceed with fallback
85
+ }
86
+ }
87
+
88
+ // Default synchronous in-memory tracking
89
+ const currentHits = localLimiter.recordSuspicious(key);
90
+ const remaining = Math.max(0, max - currentHits);
55
91
 
56
- if (currentHits >= max) {
92
+ if (standardHeaders && res.setHeader) {
93
+ res.setHeader('RateLimit-Limit', max);
94
+ res.setHeader('RateLimit-Remaining', remaining);
95
+ }
96
+
97
+ if (currentHits > max) {
98
+ if (typeof options.handler === 'function') {
99
+ return options.handler(req, res, next, options);
100
+ }
57
101
  if (res.status && res.json) {
58
- res.status(429).json({ error: 'Too many requests' });
102
+ return res.status(429).json({ error: 'Too many requests' });
59
103
  } else {
60
- // Fallback for non-Express adapters if needed
61
104
  res.statusCode = 429;
62
- res.end(JSON.stringify({ error: 'Too many requests' }));
105
+ return res.end(JSON.stringify({ error: 'Too many requests' }));
63
106
  }
64
- return;
65
107
  }
66
108
  next();
67
109
  };
68
110
  }
69
111
 
70
- module.exports = { IPRateLimiter, rateLimiterFactory };
112
+ module.exports = { IPRateLimiter, rateLimiterFactory, MemoryStore };
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const DEFAULT_STRIP_FIELDS = [
4
+ 'isadmin',
5
+ 'role',
6
+ 'roles',
7
+ 'permissions',
8
+ 'isverified',
9
+ 'verified',
10
+ 'credit',
11
+ 'balance',
12
+ 'passwordhash',
13
+ 'salt'
14
+ ];
15
+
16
+ /**
17
+ * Strips sensitive/forbidden keys from an object recursively
18
+ * @param {Object} obj
19
+ * @param {Object} options
20
+ * @returns {Object} { sanitized, strippedKeys, wasForbidden }
21
+ */
22
+ function sanitizeObject(obj, options = {}) {
23
+ const stripList = (options.stripFields || DEFAULT_STRIP_FIELDS).map(f => String(f).toLowerCase());
24
+ const strippedKeys = [];
25
+ const visited = new WeakSet();
26
+
27
+ function clean(target) {
28
+ if (!target || typeof target !== 'object') return target;
29
+ if (visited.has(target)) return target;
30
+ visited.add(target);
31
+
32
+ if (Array.isArray(target)) {
33
+ return target.map(item => clean(item));
34
+ }
35
+
36
+ const result = {};
37
+ for (const key of Object.keys(target)) {
38
+ const lowerKey = key.toLowerCase();
39
+ const isForbidden = stripList.some(field => {
40
+ if (field.startsWith('*') && field.endsWith('*')) {
41
+ return lowerKey.includes(field.slice(1, -1));
42
+ } else if (field.endsWith('*')) {
43
+ return lowerKey.startsWith(field.slice(0, -1));
44
+ }
45
+ return lowerKey === field;
46
+ });
47
+
48
+ if (isForbidden) {
49
+ strippedKeys.push(key);
50
+ } else {
51
+ result[key] = clean(target[key]);
52
+ }
53
+ }
54
+ return result;
55
+ }
56
+
57
+ const sanitized = clean(obj);
58
+ return {
59
+ sanitized,
60
+ strippedKeys,
61
+ wasForbidden: strippedKeys.length > 0
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Express / Connect middleware for mass assignment sanitization
67
+ * @param {Object} options
68
+ * @returns {Function} middleware(req, res, next)
69
+ */
70
+ function sanitizerFactory(options = {}) {
71
+ const rejectOnForbidden = options.rejectOnForbidden === true;
72
+
73
+ return function sanitizerMiddleware(req, res, next) {
74
+ if (req.body && typeof req.body === 'object') {
75
+ const { sanitized, strippedKeys, wasForbidden } = sanitizeObject(req.body, options);
76
+ if (wasForbidden) {
77
+ if (rejectOnForbidden) {
78
+ return res.status(400).json({
79
+ success: false,
80
+ error: 'Forbidden parameter in request body',
81
+ code: 'MASS_ASSIGNMENT_VIOLATION',
82
+ fields: strippedKeys
83
+ });
84
+ }
85
+ req.body = sanitized;
86
+ req.fortifyStrippedKeys = strippedKeys;
87
+ }
88
+ }
89
+
90
+ if (req.query && typeof req.query === 'object') {
91
+ const { sanitized, strippedKeys, wasForbidden } = sanitizeObject(req.query, options);
92
+ if (wasForbidden) {
93
+ if (rejectOnForbidden) {
94
+ return res.status(400).json({
95
+ success: false,
96
+ error: 'Forbidden parameter in request query',
97
+ code: 'MASS_ASSIGNMENT_VIOLATION',
98
+ fields: strippedKeys
99
+ });
100
+ }
101
+ req.query = sanitized;
102
+ }
103
+ }
104
+
105
+ next();
106
+ };
107
+ }
108
+
109
+ module.exports = {
110
+ sanitizerFactory,
111
+ sanitizeObject,
112
+ DEFAULT_STRIP_FIELDS
113
+ };
@@ -0,0 +1,97 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Base Store contract for distributed rate limiting & state storage.
5
+ */
6
+ class BaseStore {
7
+ async get(key) { throw new Error('Not implemented'); }
8
+ async set(key, value, ttlMs) { throw new Error('Not implemented'); }
9
+ async increment(key, ttlMs) { throw new Error('Not implemented'); }
10
+ async delete(key) { throw new Error('Not implemented'); }
11
+ async clear() { throw new Error('Not implemented'); }
12
+ }
13
+
14
+ /**
15
+ * High-performance, zero-dependency in-memory store with TTL and bounded capacity.
16
+ */
17
+ class MemoryStore extends BaseStore {
18
+ constructor(options = {}) {
19
+ super();
20
+ this.maxEntries = options.maxEntries || 10000;
21
+ this.entries = new Map();
22
+ this.cleanupIntervalMs = options.cleanupIntervalMs || 60000;
23
+
24
+ // Background garbage collection
25
+ this.timer = setInterval(() => this.purgeExpired(), this.cleanupIntervalMs);
26
+ if (this.timer.unref) this.timer.unref();
27
+ }
28
+
29
+ purgeExpired() {
30
+ const now = Date.now();
31
+ for (const [key, record] of this.entries.entries()) {
32
+ if (record.expiresAt && record.expiresAt <= now) {
33
+ this.entries.delete(key);
34
+ }
35
+ }
36
+ }
37
+
38
+ async get(key) {
39
+ const record = this.entries.get(key);
40
+ if (!record) return null;
41
+ if (record.expiresAt && record.expiresAt <= Date.now()) {
42
+ this.entries.delete(key);
43
+ return null;
44
+ }
45
+ return record.value;
46
+ }
47
+
48
+ async set(key, value, ttlMs = 60000) {
49
+ // If at capacity, evict oldest entry
50
+ if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
51
+ const firstKey = this.entries.keys().next().value;
52
+ if (firstKey !== undefined) this.entries.delete(firstKey);
53
+ }
54
+
55
+ const expiresAt = ttlMs ? Date.now() + ttlMs : null;
56
+ this.entries.set(key, { value, expiresAt });
57
+ return true;
58
+ }
59
+
60
+ async increment(key, ttlMs = 60000) {
61
+ const now = Date.now();
62
+ let record = this.entries.get(key);
63
+
64
+ if (!record || (record.expiresAt && record.expiresAt <= now)) {
65
+ if (this.entries.size >= this.maxEntries && !this.entries.has(key)) {
66
+ const firstKey = this.entries.keys().next().value;
67
+ if (firstKey !== undefined) this.entries.delete(firstKey);
68
+ }
69
+ record = { value: 1, expiresAt: now + ttlMs };
70
+ this.entries.set(key, record);
71
+ return { count: 1, resetTime: record.expiresAt };
72
+ }
73
+
74
+ record.value += 1;
75
+ return { count: record.value, resetTime: record.expiresAt };
76
+ }
77
+
78
+ async delete(key) {
79
+ return this.entries.delete(key);
80
+ }
81
+
82
+ async clear() {
83
+ this.entries.clear();
84
+ }
85
+
86
+ destroy() {
87
+ if (this.timer) {
88
+ clearInterval(this.timer);
89
+ }
90
+ this.entries.clear();
91
+ }
92
+ }
93
+
94
+ module.exports = {
95
+ BaseStore,
96
+ MemoryStore
97
+ };