shieldstack-ts 0.1.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/.dockerignore +9 -0
- package/.gitattributes +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.yml +61 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +35 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +27 -0
- package/.github/workflows/ci.yml +69 -0
- package/CHANGELOG.md +59 -0
- package/CONTRIBUTING.md +83 -0
- package/Dockerfile +45 -0
- package/LICENSE +21 -0
- package/README.md +277 -0
- package/SECURITY.md +42 -0
- package/demo.ts +41 -0
- package/docker-compose.yml +49 -0
- package/examples/demo/AGENTS.md +5 -0
- package/examples/demo/CLAUDE.md +1 -0
- package/examples/demo/README.md +36 -0
- package/examples/demo/eslint.config.mjs +18 -0
- package/examples/demo/next.config.ts +8 -0
- package/examples/demo/package-lock.json +6041 -0
- package/examples/demo/package.json +25 -0
- package/examples/demo/public/file.svg +1 -0
- package/examples/demo/public/globe.svg +1 -0
- package/examples/demo/public/next.svg +1 -0
- package/examples/demo/public/vercel.svg +1 -0
- package/examples/demo/public/window.svg +1 -0
- package/examples/demo/src/app/api/chat/route.ts +38 -0
- package/examples/demo/src/app/favicon.ico +0 -0
- package/examples/demo/src/app/globals.css +75 -0
- package/examples/demo/src/app/layout.tsx +30 -0
- package/examples/demo/src/app/page.module.css +142 -0
- package/examples/demo/src/app/page.tsx +162 -0
- package/examples/demo/tsconfig.json +34 -0
- package/package.json +44 -0
- package/src/adapters/express.ts +28 -0
- package/src/adapters/hono.ts +22 -0
- package/src/adapters/index.ts +4 -0
- package/src/adapters/next.ts +26 -0
- package/src/budgeting/InMemoryStore.ts +26 -0
- package/src/budgeting/RedisStore.ts +41 -0
- package/src/budgeting/index.ts +5 -0
- package/src/budgeting/tokenLimiter.ts +60 -0
- package/src/budgeting/types.ts +10 -0
- package/src/core/ShieldStack.ts +119 -0
- package/src/index.ts +7 -0
- package/src/observability/index.ts +2 -0
- package/src/observability/logger.ts +62 -0
- package/src/sanitizers/index.ts +4 -0
- package/src/sanitizers/injection.ts +49 -0
- package/src/sanitizers/pii.ts +97 -0
- package/src/sanitizers/secrets.ts +49 -0
- package/src/streams/StreamSanitizer.ts +46 -0
- package/src/streams/index.ts +2 -0
- package/src/validation/index.ts +2 -0
- package/src/validation/zodValidator.ts +46 -0
- package/tests/injection.test.ts +23 -0
- package/tests/pii.test.ts +21 -0
- package/tests/redis.integration.ts +65 -0
- package/tests/redisStore.test.ts +107 -0
- package/tests/tokenLimiter.test.ts +27 -0
- package/tsconfig.json +20 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { PIIRedactor } from '../src/sanitizers/pii';
|
|
3
|
+
|
|
4
|
+
describe('PIIRedactor', () => {
|
|
5
|
+
it('should redact emails', () => {
|
|
6
|
+
const redactor = new PIIRedactor({ emails: true, policy: 'redact' });
|
|
7
|
+
const result = redactor.redact('Contact me at admin@shieldstack.com please.');
|
|
8
|
+
expect(result.redactedText).toBe('Contact me at [REDACTED_EMAIL] please.');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should hash credit cards', () => {
|
|
12
|
+
const redactor = new PIIRedactor({ creditCards: true, phoneNumbers: false, policy: 'hash' });
|
|
13
|
+
const result = redactor.redact('My card is 1234-5678-9012-3456');
|
|
14
|
+
expect(result.redactedText).toBe('My card is [HASHED_CREDITCARD]');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should handle blocking policy', () => {
|
|
18
|
+
const redactor = new PIIRedactor({ phoneNumbers: true, policy: 'block' });
|
|
19
|
+
expect(() => redactor.redact('Call 1-800-555-0199')).toThrowError('PII blocked');
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { ShieldStack, RedisStore } from './dist/index';
|
|
2
|
+
|
|
3
|
+
async function main() {
|
|
4
|
+
let Redis: any;
|
|
5
|
+
try {
|
|
6
|
+
// Dynamic import so this doesn't fail at compile time
|
|
7
|
+
const mod = await import('ioredis');
|
|
8
|
+
Redis = mod.default;
|
|
9
|
+
} catch {
|
|
10
|
+
console.error('❌ Please install ioredis first: npm install ioredis');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const client = new Redis({ host: '127.0.0.1', port: 6379 });
|
|
15
|
+
|
|
16
|
+
console.log('🔌 Connecting to Redis at 127.0.0.1:6379...');
|
|
17
|
+
await client.ping();
|
|
18
|
+
console.log('✅ Redis connected!\n');
|
|
19
|
+
|
|
20
|
+
// Flush test keys before starting
|
|
21
|
+
await client.del('shieldstack:test:live-user');
|
|
22
|
+
|
|
23
|
+
const shield = new ShieldStack({
|
|
24
|
+
tokenLimiter: {
|
|
25
|
+
maxTokens: 100,
|
|
26
|
+
windowMs: 30000, // 30 seconds
|
|
27
|
+
store: new RedisStore(client, 'shieldstack:test:'),
|
|
28
|
+
},
|
|
29
|
+
pii: { policy: 'redact', emails: true },
|
|
30
|
+
injectionDetection: { threshold: 0.8 },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
console.log('--- Test 1: Token limit stored in Redis ---');
|
|
34
|
+
const r1 = await shield.evaluateRequest('Hello from server pod 1', 'live-user', 60);
|
|
35
|
+
console.log(`✅ Pod 1 allowed. Safe text: "${r1}"`);
|
|
36
|
+
|
|
37
|
+
console.log('--- Test 2: Second request from a different pod (shared Redis) ---');
|
|
38
|
+
// New ShieldStack instance (simulating a second pod) pointing to same Redis
|
|
39
|
+
const shield2 = new ShieldStack({
|
|
40
|
+
tokenLimiter: {
|
|
41
|
+
maxTokens: 100,
|
|
42
|
+
windowMs: 30000,
|
|
43
|
+
store: new RedisStore(client, 'shieldstack:test:'),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await shield2.evaluateRequest('Hello from server pod 2', 'live-user', 50);
|
|
49
|
+
console.log('❌ This should have been blocked!');
|
|
50
|
+
} catch (e: any) {
|
|
51
|
+
console.log(`✅ Pod 2 blocked (60 + 50 > 100). Reason: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log('\n--- Test 3: Verify key in Redis ---');
|
|
55
|
+
const raw = await client.get('shieldstack:test:live-user');
|
|
56
|
+
console.log(`✅ Raw Redis value: ${raw}`);
|
|
57
|
+
|
|
58
|
+
await client.quit();
|
|
59
|
+
console.log('\n🎉 Live Redis verification complete!');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
main().catch(err => {
|
|
63
|
+
console.error('Error:', err);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { RedisStore } from '../src/budgeting/RedisStore';
|
|
3
|
+
import { TokenLimiter } from '../src/budgeting/tokenLimiter';
|
|
4
|
+
|
|
5
|
+
function createMockRedisClient() {
|
|
6
|
+
const store: Map<string, { value: string; expiresAt?: number }> = new Map();
|
|
7
|
+
|
|
8
|
+
return {
|
|
9
|
+
async get(key: string): Promise<string | null> {
|
|
10
|
+
const entry = store.get(key);
|
|
11
|
+
if (!entry) return null;
|
|
12
|
+
if (entry.expiresAt && Date.now() > entry.expiresAt) {
|
|
13
|
+
store.delete(key);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
return entry.value;
|
|
17
|
+
},
|
|
18
|
+
async set(key: string, value: string, px?: 'PX', ms?: number): Promise<'OK'> {
|
|
19
|
+
store.set(key, {
|
|
20
|
+
value,
|
|
21
|
+
expiresAt: px && ms ? Date.now() + ms : undefined,
|
|
22
|
+
});
|
|
23
|
+
return 'OK';
|
|
24
|
+
},
|
|
25
|
+
async del(key: string): Promise<number> {
|
|
26
|
+
return store.delete(key) ? 1 : 0;
|
|
27
|
+
},
|
|
28
|
+
_store: store,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('RedisStore', () => {
|
|
33
|
+
it('should store and retrieve a bucket', async () => {
|
|
34
|
+
const client = createMockRedisClient();
|
|
35
|
+
const redisStore = new RedisStore(client, 'test:');
|
|
36
|
+
|
|
37
|
+
const bucket = { tokensUsed: 50, resetAt: Date.now() + 60000 };
|
|
38
|
+
await redisStore.set('user1', bucket);
|
|
39
|
+
|
|
40
|
+
const retrieved = await redisStore.get('user1');
|
|
41
|
+
expect(retrieved).toEqual(bucket);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should return null for missing keys', async () => {
|
|
45
|
+
const client = createMockRedisClient();
|
|
46
|
+
const redisStore = new RedisStore(client, 'test:');
|
|
47
|
+
|
|
48
|
+
const result = await redisStore.get('nonexistent');
|
|
49
|
+
expect(result).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should delete a bucket', async () => {
|
|
53
|
+
const client = createMockRedisClient();
|
|
54
|
+
const redisStore = new RedisStore(client, 'test:');
|
|
55
|
+
|
|
56
|
+
await redisStore.set('user1', { tokensUsed: 10, resetAt: Date.now() + 60000 });
|
|
57
|
+
await redisStore.delete('user1');
|
|
58
|
+
|
|
59
|
+
const result = await redisStore.get('user1');
|
|
60
|
+
expect(result).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should use key prefix correctly', async () => {
|
|
64
|
+
const client = createMockRedisClient();
|
|
65
|
+
const redisStore = new RedisStore(client, 'shieldstack:prod:');
|
|
66
|
+
|
|
67
|
+
await redisStore.set('userA', { tokensUsed: 100, resetAt: Date.now() + 60000 });
|
|
68
|
+
|
|
69
|
+
const rawKeys = [...client._store.keys()];
|
|
70
|
+
expect(rawKeys[0]).toBe('shieldstack:prod:userA');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('TokenLimiter with RedisStore', () => {
|
|
75
|
+
it('should enforce token limits via Redis', async () => {
|
|
76
|
+
const client = createMockRedisClient();
|
|
77
|
+
const redisStore = new RedisStore(client, 'test:limiter:');
|
|
78
|
+
|
|
79
|
+
const limiter = new TokenLimiter({
|
|
80
|
+
maxTokens: 100,
|
|
81
|
+
windowMs: 60000,
|
|
82
|
+
store: redisStore,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// First request should be allowed
|
|
86
|
+
const result1 = await limiter.checkLimit('user1', 60);
|
|
87
|
+
expect(result1.allowed).toBe(true);
|
|
88
|
+
expect(result1.remainingTokens).toBe(40);
|
|
89
|
+
|
|
90
|
+
// Second request pushes over limit
|
|
91
|
+
const result2 = await limiter.checkLimit('user1', 50);
|
|
92
|
+
expect(result2.allowed).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should persist state across limiter instances (simulating distributed behavior)', async () => {
|
|
96
|
+
const client = createMockRedisClient();
|
|
97
|
+
const redisStore = new RedisStore(client, 'test:distributed:');
|
|
98
|
+
|
|
99
|
+
const limiterA = new TokenLimiter({ maxTokens: 100, windowMs: 60000, store: redisStore });
|
|
100
|
+
await limiterA.checkLimit('shared-user', 70);
|
|
101
|
+
|
|
102
|
+
const limiterB = new TokenLimiter({ maxTokens: 100, windowMs: 60000, store: redisStore });
|
|
103
|
+
const result = await limiterB.checkLimit('shared-user', 40);
|
|
104
|
+
|
|
105
|
+
expect(result.allowed).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { TokenLimiter } from '../src/budgeting/tokenLimiter';
|
|
3
|
+
|
|
4
|
+
describe('TokenLimiter', () => {
|
|
5
|
+
it('should allow requests under the limit', async () => {
|
|
6
|
+
const limiter = new TokenLimiter({ maxTokens: 100, windowMs: 10000 });
|
|
7
|
+
const result = await limiter.checkLimit('user1', 50);
|
|
8
|
+
expect(result.allowed).toBe(true);
|
|
9
|
+
expect(result.remainingTokens).toBe(50);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should block requests over the limit', async () => {
|
|
13
|
+
const limiter = new TokenLimiter({ maxTokens: 100, windowMs: 10000 });
|
|
14
|
+
await limiter.checkLimit('user1', 80);
|
|
15
|
+
const result2 = await limiter.checkLimit('user1', 30);
|
|
16
|
+
expect(result2.allowed).toBe(false);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should support token refunds', async () => {
|
|
20
|
+
const limiter = new TokenLimiter({ maxTokens: 100, windowMs: 10000 });
|
|
21
|
+
await limiter.checkLimit('user1', 60);
|
|
22
|
+
await limiter.refund('user1', 20);
|
|
23
|
+
|
|
24
|
+
const result = await limiter.checkLimit('user1', 50);
|
|
25
|
+
expect(result.allowed).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"lib": ["ESNext", "DOM"],
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"outDir": "./dist",
|
|
13
|
+
"baseUrl": ".",
|
|
14
|
+
"paths": {
|
|
15
|
+
"@/*": ["src/*"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*"],
|
|
19
|
+
"exclude": ["node_modules", "dist", "examples"]
|
|
20
|
+
}
|