payload-guard-filter 1.0.1 → 1.2.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/dist/cli.js CHANGED
@@ -149,16 +149,57 @@ function init() {
149
149
  console.log(' 2. Import shapes into your Express routes');
150
150
  console.log(' 3. Use res.guardJson(shape, data) to filter responses\n');
151
151
  }
152
+ function sync() {
153
+ const targetDir = process.cwd();
154
+ const shapesDir = path.join(targetDir, 'src', 'shapes');
155
+ const outputPath = path.join(targetDir, 'src', 'types', 'generated.ts');
156
+ console.log('\nšŸ”„ payload-guard sync\n');
157
+ if (!fs.existsSync(shapesDir)) {
158
+ console.log(`āŒ Shapes directory not found: ${shapesDir}`);
159
+ console.log('Use "npx payload-guard init" to create basic shapes first.\n');
160
+ return;
161
+ }
162
+ const files = fs.readdirSync(shapesDir).filter(f => f.endsWith('.ts'));
163
+ if (files.length === 0) {
164
+ console.log('āš ļø No shape files found to sync.\n');
165
+ return;
166
+ }
167
+ let content = `/**\n * Generated by payload-guard sync\n * DO NOT EDIT MANUALLY\n */\n\nimport { InferShape } from 'payload-guard';\n`;
168
+ files.forEach(file => {
169
+ const filePath = path.join(shapesDir, file);
170
+ const fileName = path.parse(file).name;
171
+ const fileContent = fs.readFileSync(filePath, 'utf8');
172
+ // Simple regex to find exported shapes
173
+ const exportRegex = /export const (\w+) = (guard\.shape|buildShape)\(/g;
174
+ let match;
175
+ content += `\n// From ${file}\nimport * as ${fileName}Shapes from '../shapes/${fileName}';\n`;
176
+ while ((match = exportRegex.exec(fileContent)) !== null) {
177
+ const shapeName = match[1];
178
+ const typeName = shapeName.endsWith('Shape')
179
+ ? shapeName.replace('Shape', '')
180
+ : shapeName.charAt(0).toUpperCase() + shapeName.slice(1);
181
+ content += `export type ${typeName} = InferShape<typeof ${fileName}Shapes.${shapeName}>;\n`;
182
+ }
183
+ });
184
+ const outputDir = path.dirname(outputPath);
185
+ if (!fs.existsSync(outputDir)) {
186
+ fs.mkdirSync(outputDir, { recursive: true });
187
+ }
188
+ fs.writeFileSync(outputPath, content);
189
+ console.log(`āœ… Generated types in: ${outputPath}\n`);
190
+ }
152
191
  function showHelp() {
153
192
  console.log(`
154
193
  šŸ›”ļø payload-guard CLI
155
194
 
156
195
  Usage:
157
196
  npx payload-guard init Create example files in current directory
197
+ npx payload-guard sync Sync shapes to TypeScript types
158
198
  npx payload-guard help Show this help message
159
199
 
160
200
  Examples:
161
201
  npx payload-guard init
202
+ npx payload-guard sync
162
203
  `);
163
204
  }
164
205
  // Parse arguments
@@ -168,6 +209,9 @@ switch (command) {
168
209
  case 'init':
169
210
  init();
170
211
  break;
212
+ case 'sync':
213
+ sync();
214
+ break;
171
215
  case 'help':
172
216
  case '--help':
173
217
  case '-h':
@@ -5,6 +5,10 @@ interface CompileOptions {
5
5
  strict?: boolean;
6
6
  redact?: (key: string, value: unknown) => unknown;
7
7
  logger?: (msg: string, level?: 'warn' | 'error' | 'info') => void;
8
+ /** Maximum array length to process (memory safety) */
9
+ maxArrayLength?: number;
10
+ /** Parent path for nested field warnings (internal) */
11
+ _parentPath?: string;
8
12
  }
9
13
  /**
10
14
  * Compile a shape descriptor into an optimized filter function
@@ -57,11 +57,20 @@ function compile(shape, opts = {}) {
57
57
  }
58
58
  // Array shape
59
59
  if (isArrayShape(shape)) {
60
- const itemFn = compile(shape.item, opts);
60
+ const itemFn = compile(shape.item, { ...opts, _parentPath: (opts._parentPath || '') + '[]' });
61
61
  return (value) => {
62
62
  if (!Array.isArray(value))
63
63
  return [];
64
- return value.map(item => {
64
+ // Memory safety: slice array if maxArrayLength is set
65
+ let arr = value;
66
+ if (opts.maxArrayLength && arr.length > opts.maxArrayLength) {
67
+ if (opts.dev) {
68
+ const path = opts._parentPath || 'array';
69
+ (opts.logger ?? console.warn)(`[payload-guard] ${path}: array truncated from ${arr.length} to ${opts.maxArrayLength} items (maxArrayLength)`, 'warn');
70
+ }
71
+ arr = arr.slice(0, opts.maxArrayLength);
72
+ }
73
+ return arr.map(item => {
65
74
  try {
66
75
  return itemFn(item);
67
76
  }
@@ -92,7 +101,7 @@ function compile(shape, opts = {}) {
92
101
  }
93
102
  continue;
94
103
  }
95
- fieldFilters[key] = compile(shapeObj[key], opts);
104
+ fieldFilters[key] = compile(shapeObj[key], { ...opts, _parentPath: (opts._parentPath ? opts._parentPath + '.' : '') + key });
96
105
  }
97
106
  return (value) => {
98
107
  if (value == null || typeof value !== 'object')
@@ -1,7 +1,10 @@
1
1
  export declare function byteSize(v: unknown): number;
2
+ export declare function formatBytes(bytes: number): string;
2
3
  export declare function metric(before: unknown, after: unknown): {
3
4
  before: number;
4
5
  after: number;
5
6
  saved: number;
6
7
  percent: number;
8
+ formatBefore: string;
9
+ formatAfter: string;
7
10
  };
package/dist/core/perf.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.byteSize = byteSize;
4
+ exports.formatBytes = formatBytes;
4
5
  exports.metric = metric;
5
6
  function byteSize(v) {
6
7
  try {
@@ -10,10 +11,25 @@ function byteSize(v) {
10
11
  return 0;
11
12
  }
12
13
  }
14
+ function formatBytes(bytes) {
15
+ if (bytes === 0)
16
+ return '0B';
17
+ const k = 1024;
18
+ const sizes = ['B', 'KB', 'MB', 'GB'];
19
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
20
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + sizes[i];
21
+ }
13
22
  function metric(before, after) {
14
23
  const b = byteSize(before);
15
24
  const a = byteSize(after);
16
25
  const saved = Math.max(0, b - a);
17
26
  const pct = b === 0 ? 0 : Math.round((saved / b) * 10000) / 100;
18
- return { before: b, after: a, saved, percent: pct };
27
+ return {
28
+ before: b,
29
+ after: a,
30
+ saved,
31
+ percent: pct,
32
+ formatBefore: formatBytes(b),
33
+ formatAfter: formatBytes(a)
34
+ };
19
35
  }
@@ -31,14 +31,26 @@ export interface GuardConfig {
31
31
  strict?: boolean;
32
32
  /** Redaction hook: return replacement value for a key */
33
33
  redact?: (key: string, value: unknown) => unknown;
34
- /** Logger integration hook */
34
+ /**
35
+ * Logger integration hook for custom logging (pino, winston, etc.)
36
+ * @example
37
+ * logger: (msg, level) => pino[level || 'info'](msg)
38
+ */
35
39
  logger?: (msg: string, level?: 'warn' | 'error' | 'info') => void;
36
40
  /** Maximum payload size (bytes) for middleware processing; if exceeded, processing is skipped */
37
41
  maxPayloadSize?: number;
42
+ /** Skip processing if payload exceeds maxPayloadSize (default: true) */
43
+ skipLargePayload?: boolean;
44
+ /** Maximum array length to process (memory safety) */
45
+ maxArrayLength?: number;
38
46
  /** Routes to ignore (prefix match) */
39
47
  ignoreRoutes?: string[];
40
48
  /** Enable payload metrics (measure before/after sizes) */
41
49
  payloadMetrics?: boolean;
50
+ /** Sanitize request headers (remove sensitive headers) */
51
+ sanitizeHeaders?: boolean;
52
+ /** Sensitive header names to filter (default: authorization, cookie, x-api-key) */
53
+ sensitiveHeaders?: string[];
42
54
  }
43
55
  export interface GuardMiddlewareOptions extends GuardConfig {
44
56
  /** Sanitize request body */
@@ -19,10 +19,12 @@ const security_1 = require("../core/security");
19
19
  * ```
20
20
  */
21
21
  function guardMiddleware(opts = {}) {
22
- const { sanitizeBody = false, requestShape, sensitiveFields = [], devMode = false, filterResponse = false, maxPayloadSize, ignoreRoutes = [], payloadMetrics = false, } = opts;
22
+ const { sanitizeBody = false, requestShape, sensitiveFields = [], devMode = false, filterResponse = false, maxPayloadSize, skipLargePayload = true, maxArrayLength, ignoreRoutes = [], payloadMetrics = false, sanitizeHeaders = false, sensitiveHeaders = ['authorization', 'cookie', 'x-api-key', 'x-auth-token'], } = opts;
23
+ // Default sensitive headers
24
+ const DEFAULT_SENSITIVE_HEADERS = ['authorization', 'cookie', 'x-api-key', 'x-auth-token', 'x-session-id'];
23
25
  // Pre-compile request shape if provided
24
26
  const compiledRequestShape = requestShape
25
- ? (0, compiler_1.compile)(requestShape, { sensitive: sensitiveFields, dev: devMode })
27
+ ? (0, compiler_1.compile)(requestShape, { sensitive: sensitiveFields, dev: devMode, maxArrayLength, logger: opts.logger })
26
28
  : null;
27
29
  // Response filter cache for shapes to avoid recompiling per request
28
30
  const responseFilterCache = new WeakMap();
@@ -32,6 +34,20 @@ function guardMiddleware(opts = {}) {
32
34
  if (ignoreRoutes && ignoreRoutes.some(p => req.path.startsWith(p))) {
33
35
  return next();
34
36
  }
37
+ const routeName = `[${req.method}] ${req.path}`;
38
+ // Sanitize headers if enabled
39
+ if (sanitizeHeaders && req.headers) {
40
+ const allSensitiveHeaders = [...DEFAULT_SENSITIVE_HEADERS, ...(sensitiveHeaders || [])];
41
+ for (const header of allSensitiveHeaders) {
42
+ const headerLower = header.toLowerCase();
43
+ if (req.headers[headerLower]) {
44
+ if (devMode) {
45
+ (opts.logger ?? console.warn)(`[payload-guard] ${routeName}: Sensitive header "${header}" removed`, 'warn');
46
+ }
47
+ delete req.headers[headerLower];
48
+ }
49
+ }
50
+ }
35
51
  // Sanitize request body
36
52
  if (sanitizeBody && req.body && typeof req.body === 'object') {
37
53
  // max payload size check
@@ -40,9 +56,17 @@ function guardMiddleware(opts = {}) {
40
56
  const size = Buffer.byteLength(JSON.stringify(req.body || ''), 'utf8');
41
57
  if (size > maxPayloadSize) {
42
58
  if (devMode) {
43
- opts.logger?.('[payload-guard] payload exceeds maxPayloadSize; skipping sanitization', 'warn');
59
+ (opts.logger ?? console.warn)(`[payload-guard] ${routeName}: payload exceeds maxPayloadSize (${size} > ${maxPayloadSize} bytes)`, 'warn');
60
+ }
61
+ // Skip processing if skipLargePayload is true (default), else still process
62
+ if (!skipLargePayload) {
63
+ if (compiledRequestShape) {
64
+ req.body = compiledRequestShape(req.body);
65
+ }
66
+ else {
67
+ req.body = (0, security_1.removeSensitive)(req.body, sensitiveFields);
68
+ }
44
69
  }
45
- // do not block; skip processing
46
70
  }
47
71
  else {
48
72
  if (compiledRequestShape) {
@@ -56,7 +80,7 @@ function guardMiddleware(opts = {}) {
56
80
  catch (e) {
57
81
  // if serialization fails, skip and continue
58
82
  if (devMode)
59
- opts.logger?.('[payload-guard] error checking payload size: ' + String(e), 'warn');
83
+ (opts.logger ?? console.warn)('[payload-guard] error checking payload size: ' + String(e), 'warn');
60
84
  }
61
85
  }
62
86
  else {
@@ -68,13 +92,20 @@ function guardMiddleware(opts = {}) {
68
92
  }
69
93
  }
70
94
  }
71
- // Dev mode: warn about sensitive fields in request
95
+ // Dev mode: warn about sensitive fields in request with full path
72
96
  if (devMode && req.body && typeof req.body === 'object') {
73
- for (const key of Object.keys(req.body)) {
74
- if ((0, security_1.isSensitive)(key, sensitiveFields)) {
75
- console.warn(`[payload-guard] āš ļø Sensitive field "${key}" present in request body`);
97
+ const warnSensitive = (obj, path) => {
98
+ for (const key of Object.keys(obj)) {
99
+ const fullPath = path ? `${path}.${key}` : key;
100
+ if ((0, security_1.isSensitive)(key, sensitiveFields)) {
101
+ (opts.logger ?? console.warn)(`[payload-guard] ${routeName}: Unexpected field: ${fullPath}`, 'warn');
102
+ }
103
+ if (obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
104
+ warnSensitive(obj[key], fullPath);
105
+ }
76
106
  }
77
- }
107
+ };
108
+ warnSensitive(req.body, '');
78
109
  }
79
110
  // Attach guardJson helper to response
80
111
  res.guardJson = function (shape, data) {
@@ -109,13 +140,18 @@ function guardMiddleware(opts = {}) {
109
140
  const isStreamLike = data && typeof data.pipe === 'function';
110
141
  if (isStreamLike)
111
142
  return res.json(data);
143
+ // timing start
144
+ const start = performance.now();
112
145
  const filtered = filterFn(data);
146
+ const end = performance.now();
147
+ const duration = (end - start).toFixed(2);
113
148
  // metrics
114
149
  if (payloadMetrics) {
115
150
  try {
116
151
  const { metric } = require('../core/perf');
117
152
  const m = metric(data, filtered);
118
- opts.logger?.(`[payload-guard] payload metrics: reduced ${m.before}→${m.after} bytes (${m.percent}%)`, 'info');
153
+ const reduction = `reduced ${m.formatBefore} -> ${m.formatAfter} (${m.percent}%)`;
154
+ (opts.logger ?? console.info)(`[payload-guard] ${routeName}: ${reduction} in ${duration}ms`, 'info');
119
155
  }
120
156
  catch { }
121
157
  }
package/package.json CHANGED
@@ -1,23 +1,23 @@
1
1
  {
2
2
  "name": "payload-guard-filter",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Lightweight, zero-dependency shape-based payload filtering and sanitization for Node.js and browser",
5
5
  "main": "dist/index.js",
6
- "module": "dist/index.mjs",
6
+ "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "import": "./dist/index.mjs",
10
+ "import": "./dist/index.js",
11
11
  "require": "./dist/index.js",
12
12
  "types": "./dist/index.d.ts"
13
13
  },
14
14
  "./express": {
15
- "import": "./dist/express.mjs",
15
+ "import": "./dist/express.js",
16
16
  "require": "./dist/express.js",
17
17
  "types": "./dist/express.d.ts"
18
18
  },
19
19
  "./client": {
20
- "import": "./dist/client.mjs",
20
+ "import": "./dist/client.js",
21
21
  "require": "./dist/client.js",
22
22
  "types": "./dist/client.d.ts"
23
23
  }
@@ -41,7 +41,8 @@
41
41
  "test:watch": "vitest",
42
42
  "test:coverage": "vitest run --coverage",
43
43
  "lint": "eslint src --ext .ts",
44
- "format": "prettier --write \"src/**/*.ts\""
44
+ "format": "prettier --write \"src/**/*.ts\"",
45
+ "benchmark": "npx ts-node benchmarks/run.ts"
45
46
  },
46
47
  "keywords": [
47
48
  "payload",
@@ -59,6 +60,7 @@
59
60
  ],
60
61
  "author": "",
61
62
  "license": "MIT",
63
+ "sideEffects": false,
62
64
  "engines": {
63
65
  "node": ">=18.0.0"
64
66
  },