tlc-claude-code 1.4.8 → 1.4.9
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/package.json +1 -1
- package/server/index.js +229 -14
- package/server/lib/compliance/control-mapper.js +401 -0
- package/server/lib/compliance/control-mapper.test.js +117 -0
- package/server/lib/compliance/evidence-linker.js +296 -0
- package/server/lib/compliance/evidence-linker.test.js +121 -0
- package/server/lib/compliance/gdpr-checklist.js +416 -0
- package/server/lib/compliance/gdpr-checklist.test.js +131 -0
- package/server/lib/compliance/hipaa-checklist.js +277 -0
- package/server/lib/compliance/hipaa-checklist.test.js +101 -0
- package/server/lib/compliance/iso27001-checklist.js +287 -0
- package/server/lib/compliance/iso27001-checklist.test.js +99 -0
- package/server/lib/compliance/multi-framework-reporter.js +284 -0
- package/server/lib/compliance/multi-framework-reporter.test.js +127 -0
- package/server/lib/compliance/pci-dss-checklist.js +214 -0
- package/server/lib/compliance/pci-dss-checklist.test.js +95 -0
- package/server/lib/compliance/trust-centre.js +187 -0
- package/server/lib/compliance/trust-centre.test.js +93 -0
- package/server/lib/dashboard/api-server.js +155 -0
- package/server/lib/dashboard/api-server.test.js +155 -0
- package/server/lib/dashboard/health-api.js +199 -0
- package/server/lib/dashboard/health-api.test.js +122 -0
- package/server/lib/dashboard/notes-api.js +234 -0
- package/server/lib/dashboard/notes-api.test.js +134 -0
- package/server/lib/dashboard/router-api.js +176 -0
- package/server/lib/dashboard/router-api.test.js +132 -0
- package/server/lib/dashboard/tasks-api.js +289 -0
- package/server/lib/dashboard/tasks-api.test.js +161 -0
- package/server/lib/dashboard/tlc-introspection.js +197 -0
- package/server/lib/dashboard/tlc-introspection.test.js +138 -0
- package/server/lib/dashboard/version-api.js +222 -0
- package/server/lib/dashboard/version-api.test.js +112 -0
- package/server/lib/dashboard/websocket-server.js +104 -0
- package/server/lib/dashboard/websocket-server.test.js +118 -0
- package/server/lib/deploy/branch-classifier.js +163 -0
- package/server/lib/deploy/branch-classifier.test.js +164 -0
- package/server/lib/deploy/deployment-approval.js +299 -0
- package/server/lib/deploy/deployment-approval.test.js +296 -0
- package/server/lib/deploy/deployment-audit.js +374 -0
- package/server/lib/deploy/deployment-audit.test.js +307 -0
- package/server/lib/deploy/deployment-executor.js +335 -0
- package/server/lib/deploy/deployment-executor.test.js +329 -0
- package/server/lib/deploy/deployment-rules.js +163 -0
- package/server/lib/deploy/deployment-rules.test.js +188 -0
- package/server/lib/deploy/rollback-manager.js +379 -0
- package/server/lib/deploy/rollback-manager.test.js +321 -0
- package/server/lib/deploy/security-gates.js +236 -0
- package/server/lib/deploy/security-gates.test.js +222 -0
- package/server/lib/k8s/gitops-config.js +188 -0
- package/server/lib/k8s/gitops-config.test.js +59 -0
- package/server/lib/k8s/helm-generator.js +196 -0
- package/server/lib/k8s/helm-generator.test.js +59 -0
- package/server/lib/k8s/kustomize-generator.js +176 -0
- package/server/lib/k8s/kustomize-generator.test.js +58 -0
- package/server/lib/k8s/network-policy.js +114 -0
- package/server/lib/k8s/network-policy.test.js +53 -0
- package/server/lib/k8s/pod-security.js +114 -0
- package/server/lib/k8s/pod-security.test.js +55 -0
- package/server/lib/k8s/rbac-generator.js +132 -0
- package/server/lib/k8s/rbac-generator.test.js +57 -0
- package/server/lib/k8s/resource-manager.js +172 -0
- package/server/lib/k8s/resource-manager.test.js +60 -0
- package/server/lib/k8s/secrets-encryption.js +168 -0
- package/server/lib/k8s/secrets-encryption.test.js +49 -0
- package/server/lib/monitoring/alert-manager.js +238 -0
- package/server/lib/monitoring/alert-manager.test.js +106 -0
- package/server/lib/monitoring/health-check.js +226 -0
- package/server/lib/monitoring/health-check.test.js +176 -0
- package/server/lib/monitoring/incident-manager.js +230 -0
- package/server/lib/monitoring/incident-manager.test.js +98 -0
- package/server/lib/monitoring/log-aggregator.js +147 -0
- package/server/lib/monitoring/log-aggregator.test.js +89 -0
- package/server/lib/monitoring/metrics-collector.js +337 -0
- package/server/lib/monitoring/metrics-collector.test.js +172 -0
- package/server/lib/monitoring/status-page.js +214 -0
- package/server/lib/monitoring/status-page.test.js +105 -0
- package/server/lib/monitoring/uptime-monitor.js +194 -0
- package/server/lib/monitoring/uptime-monitor.test.js +109 -0
- package/server/lib/network/fail2ban-config.js +294 -0
- package/server/lib/network/fail2ban-config.test.js +275 -0
- package/server/lib/network/firewall-manager.js +252 -0
- package/server/lib/network/firewall-manager.test.js +254 -0
- package/server/lib/network/geoip-filter.js +282 -0
- package/server/lib/network/geoip-filter.test.js +264 -0
- package/server/lib/network/rate-limiter.js +229 -0
- package/server/lib/network/rate-limiter.test.js +293 -0
- package/server/lib/network/request-validator.js +351 -0
- package/server/lib/network/request-validator.test.js +345 -0
- package/server/lib/network/security-headers.js +251 -0
- package/server/lib/network/security-headers.test.js +283 -0
- package/server/lib/network/tls-config.js +210 -0
- package/server/lib/network/tls-config.test.js +248 -0
- package/server/lib/security/auth-security.js +369 -0
- package/server/lib/security/auth-security.test.js +448 -0
- package/server/lib/security/cis-benchmark.js +152 -0
- package/server/lib/security/cis-benchmark.test.js +137 -0
- package/server/lib/security/compose-templates.js +312 -0
- package/server/lib/security/compose-templates.test.js +229 -0
- package/server/lib/security/container-runtime.js +456 -0
- package/server/lib/security/container-runtime.test.js +503 -0
- package/server/lib/security/cors-validator.js +278 -0
- package/server/lib/security/cors-validator.test.js +310 -0
- package/server/lib/security/crypto-utils.js +253 -0
- package/server/lib/security/crypto-utils.test.js +409 -0
- package/server/lib/security/dockerfile-linter.js +459 -0
- package/server/lib/security/dockerfile-linter.test.js +483 -0
- package/server/lib/security/dockerfile-templates.js +278 -0
- package/server/lib/security/dockerfile-templates.test.js +164 -0
- package/server/lib/security/error-sanitizer.js +426 -0
- package/server/lib/security/error-sanitizer.test.js +331 -0
- package/server/lib/security/headers-generator.js +368 -0
- package/server/lib/security/headers-generator.test.js +398 -0
- package/server/lib/security/image-scanner.js +83 -0
- package/server/lib/security/image-scanner.test.js +106 -0
- package/server/lib/security/input-validator.js +352 -0
- package/server/lib/security/input-validator.test.js +330 -0
- package/server/lib/security/network-policy.js +174 -0
- package/server/lib/security/network-policy.test.js +164 -0
- package/server/lib/security/output-encoder.js +237 -0
- package/server/lib/security/output-encoder.test.js +276 -0
- package/server/lib/security/path-validator.js +359 -0
- package/server/lib/security/path-validator.test.js +293 -0
- package/server/lib/security/query-builder.js +421 -0
- package/server/lib/security/query-builder.test.js +318 -0
- package/server/lib/security/secret-detector.js +290 -0
- package/server/lib/security/secret-detector.test.js +354 -0
- package/server/lib/security/secrets-validator.js +137 -0
- package/server/lib/security/secrets-validator.test.js +120 -0
- package/server/lib/security-testing/dast-runner.js +154 -0
- package/server/lib/security-testing/dast-runner.test.js +62 -0
- package/server/lib/security-testing/dependency-scanner.js +172 -0
- package/server/lib/security-testing/dependency-scanner.test.js +64 -0
- package/server/lib/security-testing/pentest-runner.js +230 -0
- package/server/lib/security-testing/pentest-runner.test.js +60 -0
- package/server/lib/security-testing/sast-runner.js +136 -0
- package/server/lib/security-testing/sast-runner.test.js +62 -0
- package/server/lib/security-testing/secret-scanner.js +153 -0
- package/server/lib/security-testing/secret-scanner.test.js +66 -0
- package/server/lib/security-testing/security-gate.js +216 -0
- package/server/lib/security-testing/security-gate.test.js +115 -0
- package/server/lib/security-testing/security-reporter.js +303 -0
- package/server/lib/security-testing/security-reporter.test.js +114 -0
- package/server/lib/standards/audit-checker.js +546 -0
- package/server/lib/standards/audit-checker.test.js +415 -0
- package/server/lib/standards/cleanup-executor.js +452 -0
- package/server/lib/standards/cleanup-executor.test.js +293 -0
- package/server/lib/standards/refactor-stepper.js +425 -0
- package/server/lib/standards/refactor-stepper.test.js +298 -0
- package/server/lib/standards/standards-injector.js +167 -0
- package/server/lib/standards/standards-injector.test.js +232 -0
- package/server/lib/user-management.test.js +284 -0
- package/server/lib/vps/backup-manager.js +157 -0
- package/server/lib/vps/backup-manager.test.js +59 -0
- package/server/lib/vps/caddy-config.js +159 -0
- package/server/lib/vps/caddy-config.test.js +48 -0
- package/server/lib/vps/compose-orchestrator.js +219 -0
- package/server/lib/vps/compose-orchestrator.test.js +50 -0
- package/server/lib/vps/database-config.js +208 -0
- package/server/lib/vps/database-config.test.js +47 -0
- package/server/lib/vps/deploy-script.js +211 -0
- package/server/lib/vps/deploy-script.test.js +53 -0
- package/server/lib/vps/secrets-manager.js +148 -0
- package/server/lib/vps/secrets-manager.test.js +58 -0
- package/server/lib/vps/server-hardening.js +174 -0
- package/server/lib/vps/server-hardening.test.js +70 -0
- package/server/package-lock.json +19 -0
- package/server/package.json +1 -0
- package/server/templates/CLAUDE.md +37 -0
- package/server/templates/CODING-STANDARDS.md +408 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path Validator Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for path traversal prevention.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
8
|
+
import {
|
|
9
|
+
validatePath,
|
|
10
|
+
normalizePath,
|
|
11
|
+
isWithinBase,
|
|
12
|
+
validateExtension,
|
|
13
|
+
createPathValidator,
|
|
14
|
+
PathTraversalError,
|
|
15
|
+
} from './path-validator.js';
|
|
16
|
+
|
|
17
|
+
describe('path-validator', () => {
|
|
18
|
+
describe('validatePath', () => {
|
|
19
|
+
it('allows path within base directory', () => {
|
|
20
|
+
const result = validatePath('/var/app/uploads/file.txt', {
|
|
21
|
+
baseDir: '/var/app/uploads',
|
|
22
|
+
});
|
|
23
|
+
expect(result.valid).toBe(true);
|
|
24
|
+
expect(result.normalizedPath).toBe('/var/app/uploads/file.txt');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('allows nested paths within base', () => {
|
|
28
|
+
const result = validatePath('/var/app/uploads/user/123/file.txt', {
|
|
29
|
+
baseDir: '/var/app/uploads',
|
|
30
|
+
});
|
|
31
|
+
expect(result.valid).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('blocks ../ traversal', () => {
|
|
35
|
+
const result = validatePath('/var/app/uploads/../secrets/password.txt', {
|
|
36
|
+
baseDir: '/var/app/uploads',
|
|
37
|
+
});
|
|
38
|
+
expect(result.valid).toBe(false);
|
|
39
|
+
expect(result.threat).toBe('path_traversal');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('blocks multiple ../ traversal', () => {
|
|
43
|
+
const result = validatePath('/var/app/uploads/../../../etc/passwd', {
|
|
44
|
+
baseDir: '/var/app/uploads',
|
|
45
|
+
});
|
|
46
|
+
expect(result.valid).toBe(false);
|
|
47
|
+
expect(result.threat).toBe('path_traversal');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('blocks URL-encoded traversal (..%2f)', () => {
|
|
51
|
+
const result = validatePath('/var/app/uploads/..%2f..%2fetc/passwd', {
|
|
52
|
+
baseDir: '/var/app/uploads',
|
|
53
|
+
});
|
|
54
|
+
expect(result.valid).toBe(false);
|
|
55
|
+
expect(result.threat).toBe('path_traversal');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('blocks double URL-encoded traversal (..%252f)', () => {
|
|
59
|
+
const result = validatePath('/var/app/uploads/..%252f..%252fetc/passwd', {
|
|
60
|
+
baseDir: '/var/app/uploads',
|
|
61
|
+
});
|
|
62
|
+
expect(result.valid).toBe(false);
|
|
63
|
+
expect(result.threat).toBe('path_traversal');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('blocks null byte in path', () => {
|
|
67
|
+
const result = validatePath('/var/app/uploads/file.txt\x00.jpg', {
|
|
68
|
+
baseDir: '/var/app/uploads',
|
|
69
|
+
});
|
|
70
|
+
expect(result.valid).toBe(false);
|
|
71
|
+
expect(result.threat).toBe('null_byte');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('blocks Windows path traversal (..\\)', () => {
|
|
75
|
+
const result = validatePath('C:\\app\\uploads\\..\\..\\windows\\system32', {
|
|
76
|
+
baseDir: 'C:\\app\\uploads',
|
|
77
|
+
});
|
|
78
|
+
expect(result.valid).toBe(false);
|
|
79
|
+
expect(result.threat).toBe('path_traversal');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('blocks absolute path outside base', () => {
|
|
83
|
+
const result = validatePath('/etc/passwd', {
|
|
84
|
+
baseDir: '/var/app/uploads',
|
|
85
|
+
});
|
|
86
|
+
expect(result.valid).toBe(false);
|
|
87
|
+
expect(result.threat).toBe('outside_base');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('handles relative paths by resolving against base', () => {
|
|
91
|
+
const result = validatePath('subdir/file.txt', {
|
|
92
|
+
baseDir: '/var/app/uploads',
|
|
93
|
+
});
|
|
94
|
+
expect(result.valid).toBe(true);
|
|
95
|
+
expect(result.normalizedPath).toBe('/var/app/uploads/subdir/file.txt');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('normalizePath', () => {
|
|
100
|
+
it('resolves single dots', () => {
|
|
101
|
+
const result = normalizePath('/var/app/./uploads/./file.txt');
|
|
102
|
+
expect(result).toBe('/var/app/uploads/file.txt');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('resolves double dots within allowed bounds', () => {
|
|
106
|
+
const result = normalizePath('/var/app/uploads/temp/../file.txt');
|
|
107
|
+
expect(result).toBe('/var/app/uploads/file.txt');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('removes trailing slashes', () => {
|
|
111
|
+
const result = normalizePath('/var/app/uploads/');
|
|
112
|
+
expect(result).toBe('/var/app/uploads');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('collapses multiple slashes', () => {
|
|
116
|
+
const result = normalizePath('/var//app///uploads////file.txt');
|
|
117
|
+
expect(result).toBe('/var/app/uploads/file.txt');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('decodes URL-encoded characters', () => {
|
|
121
|
+
const result = normalizePath('/var/app/uploads/my%20file.txt');
|
|
122
|
+
expect(result).toBe('/var/app/uploads/my file.txt');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('handles Windows paths', () => {
|
|
126
|
+
const result = normalizePath('C:\\Users\\app\\uploads\\file.txt');
|
|
127
|
+
expect(result).toContain('Users');
|
|
128
|
+
expect(result).toContain('file.txt');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('isWithinBase', () => {
|
|
133
|
+
it('returns true for path within base', () => {
|
|
134
|
+
const result = isWithinBase('/var/app/uploads/file.txt', '/var/app/uploads');
|
|
135
|
+
expect(result).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns false for path outside base', () => {
|
|
139
|
+
const result = isWithinBase('/etc/passwd', '/var/app/uploads');
|
|
140
|
+
expect(result).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('returns false for sibling directory', () => {
|
|
144
|
+
const result = isWithinBase('/var/app/secrets/key.txt', '/var/app/uploads');
|
|
145
|
+
expect(result).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('handles trailing slashes consistently', () => {
|
|
149
|
+
const result1 = isWithinBase('/var/app/uploads/file.txt', '/var/app/uploads');
|
|
150
|
+
const result2 = isWithinBase('/var/app/uploads/file.txt', '/var/app/uploads/');
|
|
151
|
+
expect(result1).toBe(result2);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe('validateExtension', () => {
|
|
156
|
+
it('allows whitelisted extension', () => {
|
|
157
|
+
const result = validateExtension('file.jpg', {
|
|
158
|
+
allowed: ['.jpg', '.png', '.gif'],
|
|
159
|
+
});
|
|
160
|
+
expect(result.valid).toBe(true);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('rejects non-whitelisted extension', () => {
|
|
164
|
+
const result = validateExtension('file.exe', {
|
|
165
|
+
allowed: ['.jpg', '.png', '.gif'],
|
|
166
|
+
});
|
|
167
|
+
expect(result.valid).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('handles case insensitivity', () => {
|
|
171
|
+
const result = validateExtension('file.JPG', {
|
|
172
|
+
allowed: ['.jpg', '.png'],
|
|
173
|
+
caseSensitive: false,
|
|
174
|
+
});
|
|
175
|
+
expect(result.valid).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('rejects double extensions', () => {
|
|
179
|
+
const result = validateExtension('file.jpg.exe', {
|
|
180
|
+
allowed: ['.jpg'],
|
|
181
|
+
checkDoubleExtension: true,
|
|
182
|
+
});
|
|
183
|
+
expect(result.valid).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('rejects hidden files when configured', () => {
|
|
187
|
+
const result = validateExtension('.htaccess', {
|
|
188
|
+
allowed: ['.txt'],
|
|
189
|
+
rejectHidden: true,
|
|
190
|
+
});
|
|
191
|
+
expect(result.valid).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('blocks blacklisted extensions', () => {
|
|
195
|
+
const result = validateExtension('script.php', {
|
|
196
|
+
blocked: ['.php', '.exe', '.sh'],
|
|
197
|
+
});
|
|
198
|
+
expect(result.valid).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('createPathValidator', () => {
|
|
203
|
+
it('creates validator with multiple base directories', () => {
|
|
204
|
+
const validator = createPathValidator({
|
|
205
|
+
baseDirs: ['/var/app/uploads', '/var/app/public'],
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
expect(validator.validate('/var/app/uploads/file.txt').valid).toBe(true);
|
|
209
|
+
expect(validator.validate('/var/app/public/file.txt').valid).toBe(true);
|
|
210
|
+
expect(validator.validate('/etc/passwd').valid).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('creates validator with extension whitelist', () => {
|
|
214
|
+
const validator = createPathValidator({
|
|
215
|
+
baseDirs: ['/var/app/uploads'],
|
|
216
|
+
allowedExtensions: ['.jpg', '.png'],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(validator.validate('/var/app/uploads/image.jpg').valid).toBe(true);
|
|
220
|
+
expect(validator.validate('/var/app/uploads/script.php').valid).toBe(false);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('creates validator with max path length', () => {
|
|
224
|
+
const validator = createPathValidator({
|
|
225
|
+
baseDirs: ['/var/app/uploads'],
|
|
226
|
+
maxPathLength: 100,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const longPath = '/var/app/uploads/' + 'a'.repeat(100) + '.txt';
|
|
230
|
+
expect(validator.validate(longPath).valid).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('creates validator with custom forbidden patterns', () => {
|
|
234
|
+
const validator = createPathValidator({
|
|
235
|
+
baseDirs: ['/var/app/uploads'],
|
|
236
|
+
forbiddenPatterns: [/\.git/, /node_modules/],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
expect(validator.validate('/var/app/uploads/.git/config').valid).toBe(false);
|
|
240
|
+
expect(validator.validate('/var/app/uploads/node_modules/pkg').valid).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe('symlink handling', () => {
|
|
245
|
+
it('blocks symlinks pointing outside base', async () => {
|
|
246
|
+
const validator = createPathValidator({
|
|
247
|
+
baseDirs: ['/var/app/uploads'],
|
|
248
|
+
followSymlinks: false,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Mock fs.lstat to return symlink info
|
|
252
|
+
const result = await validator.validateAsync('/var/app/uploads/link-to-etc', {
|
|
253
|
+
checkSymlinks: true,
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// This would need actual fs mocking in implementation
|
|
257
|
+
expect(result).toBeDefined();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('edge cases', () => {
|
|
262
|
+
it('handles empty path', () => {
|
|
263
|
+
const result = validatePath('', { baseDir: '/var/app/uploads' });
|
|
264
|
+
expect(result.valid).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('handles path with only dots', () => {
|
|
268
|
+
const result = validatePath('....', { baseDir: '/var/app/uploads' });
|
|
269
|
+
expect(result.valid).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('handles unicode in path', () => {
|
|
273
|
+
const result = validatePath('/var/app/uploads/文件.txt', {
|
|
274
|
+
baseDir: '/var/app/uploads',
|
|
275
|
+
});
|
|
276
|
+
expect(result.valid).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('handles path with spaces', () => {
|
|
280
|
+
const result = validatePath('/var/app/uploads/my file.txt', {
|
|
281
|
+
baseDir: '/var/app/uploads',
|
|
282
|
+
});
|
|
283
|
+
expect(result.valid).toBe(true);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('rejects path with control characters', () => {
|
|
287
|
+
const result = validatePath('/var/app/uploads/file\x1f.txt', {
|
|
288
|
+
baseDir: '/var/app/uploads',
|
|
289
|
+
});
|
|
290
|
+
expect(result.valid).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
});
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query Builder Module
|
|
3
|
+
*
|
|
4
|
+
* Safe parameterized query building to prevent SQL injection.
|
|
5
|
+
* Addresses OWASP A03: Injection
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Custom error for security violations
|
|
10
|
+
*/
|
|
11
|
+
export class SecurityError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'SecurityError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* SQL reserved words that need quoting
|
|
20
|
+
*/
|
|
21
|
+
const RESERVED_WORDS = new Set([
|
|
22
|
+
'order', 'group', 'select', 'table', 'user', 'index', 'key', 'from', 'to',
|
|
23
|
+
'where', 'and', 'or', 'not', 'null', 'true', 'false', 'like', 'in', 'as',
|
|
24
|
+
'join', 'left', 'right', 'inner', 'outer', 'on', 'using', 'limit', 'offset',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Escape identifier (table/column name)
|
|
29
|
+
* @param {string} identifier - The identifier to escape
|
|
30
|
+
* @returns {string} Escaped identifier
|
|
31
|
+
*/
|
|
32
|
+
function escapeIdentifier(identifier) {
|
|
33
|
+
// Handle schema-qualified names (e.g., public.users)
|
|
34
|
+
if (identifier.includes('.') && !identifier.includes('..')) {
|
|
35
|
+
return identifier.split('.').map(escapeIdentifier).join('.');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Check for dangerous SQL injection patterns in identifier (but allow hyphens)
|
|
39
|
+
if (/[;'"\\]/.test(identifier) || /--/.test(identifier)) {
|
|
40
|
+
throw new SecurityError(`Invalid identifier: ${identifier}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Quote if contains special chars or is reserved word
|
|
44
|
+
if (/-/.test(identifier) || RESERVED_WORDS.has(identifier.toLowerCase())) {
|
|
45
|
+
return `"${identifier}"`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return identifier;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a SELECT query builder
|
|
53
|
+
* @param {string} table - The table name
|
|
54
|
+
* @returns {Object} Query builder
|
|
55
|
+
*/
|
|
56
|
+
export function select(table) {
|
|
57
|
+
return new SelectBuilder(table);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Create an INSERT query builder
|
|
62
|
+
* @param {string} table - The table name
|
|
63
|
+
* @returns {Object} Query builder
|
|
64
|
+
*/
|
|
65
|
+
export function insert(table) {
|
|
66
|
+
return new InsertBuilder(table);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create an UPDATE query builder
|
|
71
|
+
* @param {string} table - The table name
|
|
72
|
+
* @returns {Object} Query builder
|
|
73
|
+
*/
|
|
74
|
+
export function update(table) {
|
|
75
|
+
return new UpdateBuilder(table);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a DELETE query builder
|
|
80
|
+
* @param {string} table - The table name
|
|
81
|
+
* @returns {Object} Query builder
|
|
82
|
+
*/
|
|
83
|
+
export function del(table) {
|
|
84
|
+
return new DeleteBuilder(table);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Create a query builder with options
|
|
89
|
+
* @param {Object} options - Builder options
|
|
90
|
+
* @returns {Object} Query builder factory
|
|
91
|
+
*/
|
|
92
|
+
export function createQueryBuilder(options = {}) {
|
|
93
|
+
const { dialect = 'postgresql', allowedTables = null, allowedColumns = null } = options;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
select(table) {
|
|
97
|
+
if (allowedTables && !allowedTables.includes(table)) {
|
|
98
|
+
throw new SecurityError(`Table not allowed: ${table}`);
|
|
99
|
+
}
|
|
100
|
+
const builder = new SelectBuilder(table, { dialect, allowedColumns });
|
|
101
|
+
return builder;
|
|
102
|
+
},
|
|
103
|
+
insert(table) {
|
|
104
|
+
if (allowedTables && !allowedTables.includes(table)) {
|
|
105
|
+
throw new SecurityError(`Table not allowed: ${table}`);
|
|
106
|
+
}
|
|
107
|
+
return new InsertBuilder(table, { dialect });
|
|
108
|
+
},
|
|
109
|
+
update(table) {
|
|
110
|
+
if (allowedTables && !allowedTables.includes(table)) {
|
|
111
|
+
throw new SecurityError(`Table not allowed: ${table}`);
|
|
112
|
+
}
|
|
113
|
+
return new UpdateBuilder(table, { dialect });
|
|
114
|
+
},
|
|
115
|
+
delete(table) {
|
|
116
|
+
if (allowedTables && !allowedTables.includes(table)) {
|
|
117
|
+
throw new SecurityError(`Table not allowed: ${table}`);
|
|
118
|
+
}
|
|
119
|
+
return new DeleteBuilder(table, { dialect });
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Base query builder class
|
|
126
|
+
*/
|
|
127
|
+
class BaseBuilder {
|
|
128
|
+
constructor(table, options = {}) {
|
|
129
|
+
this._table = escapeIdentifier(table);
|
|
130
|
+
this._dialect = options.dialect || 'postgresql';
|
|
131
|
+
this._params = [];
|
|
132
|
+
this._paramIndex = 0;
|
|
133
|
+
this._allowedColumns = options.allowedColumns;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
_placeholder() {
|
|
137
|
+
this._paramIndex++;
|
|
138
|
+
if (this._dialect === 'postgresql') {
|
|
139
|
+
return `$${this._paramIndex}`;
|
|
140
|
+
}
|
|
141
|
+
return '?';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_validateColumn(column) {
|
|
145
|
+
if (this._allowedColumns && this._allowedColumns[this._table]) {
|
|
146
|
+
if (!this._allowedColumns[this._table].includes(column)) {
|
|
147
|
+
throw new SecurityError(`Column not allowed: ${column}`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* SELECT query builder
|
|
155
|
+
*/
|
|
156
|
+
class SelectBuilder extends BaseBuilder {
|
|
157
|
+
constructor(table, options) {
|
|
158
|
+
super(table, options);
|
|
159
|
+
this._columns = ['*'];
|
|
160
|
+
this._where = [];
|
|
161
|
+
this._orderBy = [];
|
|
162
|
+
this._limit = null;
|
|
163
|
+
this._offset = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
columns(cols) {
|
|
167
|
+
if (cols.length > 0 && cols[0] !== '*') {
|
|
168
|
+
cols.forEach((col) => this._validateColumn(col));
|
|
169
|
+
}
|
|
170
|
+
this._columns = cols.map((col) => col === '*' ? '*' : escapeIdentifier(col));
|
|
171
|
+
return this;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
where(column, operator, value) {
|
|
175
|
+
this._where.push({
|
|
176
|
+
column: escapeIdentifier(column),
|
|
177
|
+
operator,
|
|
178
|
+
value,
|
|
179
|
+
type: 'AND',
|
|
180
|
+
});
|
|
181
|
+
return this;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
orWhere(column, operator, value) {
|
|
185
|
+
this._where.push({
|
|
186
|
+
column: escapeIdentifier(column),
|
|
187
|
+
operator,
|
|
188
|
+
value,
|
|
189
|
+
type: 'OR',
|
|
190
|
+
});
|
|
191
|
+
return this;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
whereIn(column, values) {
|
|
195
|
+
this._where.push({
|
|
196
|
+
column: escapeIdentifier(column),
|
|
197
|
+
operator: 'IN',
|
|
198
|
+
value: values,
|
|
199
|
+
type: 'AND',
|
|
200
|
+
isIn: true,
|
|
201
|
+
});
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
whereRaw(/* raw */) {
|
|
206
|
+
throw new SecurityError('Raw WHERE clauses are not allowed - use parameterized queries');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
orderBy(column, direction = 'ASC') {
|
|
210
|
+
const dir = direction.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
|
211
|
+
this._orderBy.push(`${escapeIdentifier(column)} ${dir}`);
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
limit(n) {
|
|
216
|
+
this._limit = n;
|
|
217
|
+
return this;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
offset(n) {
|
|
221
|
+
this._offset = n;
|
|
222
|
+
return this;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
build() {
|
|
226
|
+
const parts = [`SELECT ${this._columns.join(', ')} FROM ${this._table}`];
|
|
227
|
+
|
|
228
|
+
if (this._where.length > 0) {
|
|
229
|
+
const whereClauses = this._where.map((w, i) => {
|
|
230
|
+
let clause;
|
|
231
|
+
if (w.isIn) {
|
|
232
|
+
const placeholders = w.value.map(() => this._placeholder());
|
|
233
|
+
this._params.push(...w.value);
|
|
234
|
+
clause = `${w.column} IN (${placeholders.join(', ')})`;
|
|
235
|
+
} else {
|
|
236
|
+
clause = `${w.column} ${w.operator} ${this._placeholder()}`;
|
|
237
|
+
this._params.push(w.value);
|
|
238
|
+
}
|
|
239
|
+
return i === 0 ? clause : `${w.type} ${clause}`;
|
|
240
|
+
});
|
|
241
|
+
parts.push(`WHERE ${whereClauses.join(' ')}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (this._orderBy.length > 0) {
|
|
245
|
+
parts.push(`ORDER BY ${this._orderBy.join(', ')}`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (this._limit !== null) {
|
|
249
|
+
parts.push(`LIMIT ${this._placeholder()}`);
|
|
250
|
+
this._params.push(this._limit);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (this._offset !== null) {
|
|
254
|
+
parts.push(`OFFSET ${this._placeholder()}`);
|
|
255
|
+
this._params.push(this._offset);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { sql: parts.join(' '), params: this._params };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* INSERT query builder
|
|
264
|
+
*/
|
|
265
|
+
class InsertBuilder extends BaseBuilder {
|
|
266
|
+
constructor(table, options) {
|
|
267
|
+
super(table, options);
|
|
268
|
+
this._values = null;
|
|
269
|
+
this._returning = [];
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
values(data) {
|
|
273
|
+
this._values = Array.isArray(data) ? data : [data];
|
|
274
|
+
return this;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
returning(cols) {
|
|
278
|
+
this._returning = cols;
|
|
279
|
+
return this;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
build() {
|
|
283
|
+
if (!this._values || this._values.length === 0) {
|
|
284
|
+
throw new SecurityError('INSERT requires values');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const columns = Object.keys(this._values[0]);
|
|
288
|
+
|
|
289
|
+
// Validate column names
|
|
290
|
+
columns.forEach((col) => {
|
|
291
|
+
if (/[;'"\\\/]/.test(col) || /\b(drop|delete|truncate|alter)\b/i.test(col)) {
|
|
292
|
+
throw new SecurityError(`Invalid column name: ${col}`);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const escapedColumns = columns.map((col) => escapeIdentifier(col));
|
|
297
|
+
const valueSets = this._values.map((row) => {
|
|
298
|
+
const placeholders = columns.map(() => this._placeholder());
|
|
299
|
+
columns.forEach((col) => this._params.push(row[col]));
|
|
300
|
+
return `(${placeholders.join(', ')})`;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
let sql = `INSERT INTO ${this._table} (${escapedColumns.join(', ')}) VALUES ${valueSets.join(', ')}`;
|
|
304
|
+
|
|
305
|
+
if (this._returning.length > 0) {
|
|
306
|
+
sql += ` RETURNING ${this._returning.map((c) => escapeIdentifier(c)).join(', ')}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { sql, params: this._params };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* UPDATE query builder
|
|
315
|
+
*/
|
|
316
|
+
class UpdateBuilder extends BaseBuilder {
|
|
317
|
+
constructor(table, options) {
|
|
318
|
+
super(table, options);
|
|
319
|
+
this._set = {};
|
|
320
|
+
this._where = [];
|
|
321
|
+
this._returning = [];
|
|
322
|
+
this._unsafe = false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
set(data) {
|
|
326
|
+
this._set = data;
|
|
327
|
+
return this;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
where(column, operator, value) {
|
|
331
|
+
this._where.push({
|
|
332
|
+
column: escapeIdentifier(column),
|
|
333
|
+
operator,
|
|
334
|
+
value,
|
|
335
|
+
});
|
|
336
|
+
return this;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
returning(cols) {
|
|
340
|
+
this._returning = cols;
|
|
341
|
+
return this;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
unsafe() {
|
|
345
|
+
this._unsafe = true;
|
|
346
|
+
return this;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
build() {
|
|
350
|
+
if (!this._unsafe && this._where.length === 0) {
|
|
351
|
+
throw new SecurityError('UPDATE without WHERE clause requires .unsafe() call');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const setClauses = Object.entries(this._set).map(([col, val]) => {
|
|
355
|
+
const placeholder = this._placeholder();
|
|
356
|
+
this._params.push(val);
|
|
357
|
+
return `${escapeIdentifier(col)} = ${placeholder}`;
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
let sql = `UPDATE ${this._table} SET ${setClauses.join(', ')}`;
|
|
361
|
+
|
|
362
|
+
if (this._where.length > 0) {
|
|
363
|
+
const whereClauses = this._where.map((w, i) => {
|
|
364
|
+
const clause = `${w.column} ${w.operator} ${this._placeholder()}`;
|
|
365
|
+
this._params.push(w.value);
|
|
366
|
+
return i === 0 ? clause : `AND ${clause}`;
|
|
367
|
+
});
|
|
368
|
+
sql += ` WHERE ${whereClauses.join(' ')}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (this._returning.length > 0) {
|
|
372
|
+
sql += ` RETURNING ${this._returning.map((c) => escapeIdentifier(c)).join(', ')}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return { sql, params: this._params };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* DELETE query builder
|
|
381
|
+
*/
|
|
382
|
+
class DeleteBuilder extends BaseBuilder {
|
|
383
|
+
constructor(table, options) {
|
|
384
|
+
super(table, options);
|
|
385
|
+
this._where = [];
|
|
386
|
+
this._unsafe = false;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
where(column, operator, value) {
|
|
390
|
+
this._where.push({
|
|
391
|
+
column: escapeIdentifier(column),
|
|
392
|
+
operator,
|
|
393
|
+
value,
|
|
394
|
+
});
|
|
395
|
+
return this;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
unsafe() {
|
|
399
|
+
this._unsafe = true;
|
|
400
|
+
return this;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
build() {
|
|
404
|
+
if (!this._unsafe && this._where.length === 0) {
|
|
405
|
+
throw new SecurityError('DELETE without WHERE clause requires .unsafe() call');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let sql = `DELETE FROM ${this._table}`;
|
|
409
|
+
|
|
410
|
+
if (this._where.length > 0) {
|
|
411
|
+
const whereClauses = this._where.map((w, i) => {
|
|
412
|
+
const clause = `${w.column} ${w.operator} ${this._placeholder()}`;
|
|
413
|
+
this._params.push(w.value);
|
|
414
|
+
return i === 0 ? clause : `AND ${clause}`;
|
|
415
|
+
});
|
|
416
|
+
sql += ` WHERE ${whereClauses.join(' ')}`;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return { sql, params: this._params };
|
|
420
|
+
}
|
|
421
|
+
}
|