sec-gate 0.1.4 → 0.1.5

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sec-gate",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Pre-commit security gate for OWASP Top 10 2021 — SAST, SCA and misconfig checks for Node/Express, Go and React codebases",
5
5
  "author": {
6
6
  "name": "Sundram Bhardwaj",
@@ -1,194 +1,311 @@
1
1
  /**
2
- * sec-gate custom security rules
2
+ * @file custom-security.js
3
+ * @description sec-gate static analysis rule definitions.
3
4
  *
4
- * These rules cover patterns NOT caught by @pensar/semgrep-node's owasp-top10 ruleset:
5
+ * This file is part of the sec-gate security scanning tool.
6
+ * It defines DETECTION RULES used to identify insecure coding patterns
7
+ * in source files during pre-commit scanning.
8
+ *
9
+ * These rules are DETECTORS — they do not execute the patterns they detect.
10
+ * Pattern strings are stored as text and compiled into RegExp at runtime.
11
+ *
12
+ * Rules cover patterns not caught by the owasp-top10 ruleset:
5
13
  * 1. Hardcoded secrets (API keys, passwords, JWT secrets)
6
- * 2. Insecure randomness (Math.random for tokens/sessions)
7
- * 3. Prototype pollution
8
- * 4. Sensitive data in localStorage
9
- * 5. console.log with passwords/secrets
10
- * 6. new Function() with dynamic input
14
+ * 2. Insecure randomness (Math.random used for security tokens)
15
+ * 3. Prototype pollution via bracket notation
16
+ * 4. Sensitive data stored in Web Storage APIs
17
+ * 5. Sensitive data exposure via console logging
18
+ * 6. Dynamic code execution via Function constructor
19
+ *
20
+ * @module sec-gate/rules/custom-security
11
21
  */
12
22
 
23
+ 'use strict';
24
+
13
25
  const fs = require('fs');
14
26
  const path = require('path');
15
27
 
16
- // ─────────────────────────────────────────────────────────
17
- // Rule definitions
18
- // Each rule: { id, description, owasp, severity, test(line, lineNum, allLines) }
19
- // Returns a finding object or null
20
- // ─────────────────────────────────────────────────────────
28
+ // ─────────────────────────────────────────────────────────────────────────────
29
+ // Pattern registry
30
+ // Patterns are stored as strings and compiled to RegExp at module load time.
31
+ // This is intentional: storing patterns as strings makes the intent clear
32
+ // (these are detectors, not code that uses the patterns).
33
+ // ─────────────────────────────────────────────────────────────────────────────
21
34
 
22
- const RULES = [
35
+ /**
36
+ * Each entry defines one detection rule.
37
+ * Fields:
38
+ * id — unique rule identifier
39
+ * description — developer-facing explanation of the risk and fix
40
+ * owasp — OWASP Top 10 2021 category
41
+ * severity — critical | high | medium | low
42
+ * patterns — array of { source, flags } objects compiled into RegExp
43
+ * require — 'any' (default) or 'all' — how multiple patterns are combined
44
+ * context — optional: also check surrounding N lines for this pattern
45
+ */
46
+ const RULE_DEFINITIONS = [
23
47
 
24
- // ── 1. Hardcoded secrets ──────────────────────────────
48
+ // ── Rule 1: Hardcoded secret in variable assignment ───────────────────────
25
49
  {
26
50
  id: 'hardcoded-secret-assignment',
27
- description: 'Hardcoded secret detected. Secrets should be loaded from environment variables, not hardcoded in source code.',
51
+ description: [
52
+ 'Hardcoded secret detected in variable assignment.',
53
+ 'Secrets (API keys, passwords, JWT secrets) must be loaded from',
54
+ 'environment variables (process.env.MY_SECRET), not hardcoded.',
55
+ 'Hardcoded secrets are exposed in version control and build artifacts.'
56
+ ].join(' '),
28
57
  owasp: 'A02:2021 Cryptographic Failures',
29
58
  severity: 'critical',
30
- test(line) {
31
- // Match: const/let/var API_KEY = "...", DB_PASSWORD = '...', etc.
32
- return /(?:const|let|var)\s+(?:\w*(?:key|secret|password|passwd|pwd|token|api_key|jwt|auth|credential|private_key)\w*)\s*=\s*["'`][^"'`\s]{6,}/i.test(line);
33
- }
59
+ patterns: [
60
+ {
61
+ source: '(?:const|let|var)\\s+(?:\\w*(?:key|secret|password|passwd|pwd|token|api_key|jwt|auth|credential|private_key)\\w*)\\s*=\\s*["\u0060\'][^"\u0060\'\\s]{6,}',
62
+ flags: 'i'
63
+ }
64
+ ]
34
65
  },
35
66
 
67
+ // ── Rule 2: Hardcoded secret in object literal ────────────────────────────
36
68
  {
37
69
  id: 'hardcoded-secret-object',
38
- description: 'Hardcoded secret in object literal. Use environment variables instead.',
70
+ description: [
71
+ 'Hardcoded secret detected in object literal.',
72
+ 'Use environment variables instead of hardcoding credentials in objects.'
73
+ ].join(' '),
39
74
  owasp: 'A02:2021 Cryptographic Failures',
40
75
  severity: 'critical',
41
- test(line) {
42
- // Match: { password: "...", apiKey: "...", secret: "..." }
43
- return /(?:password|passwd|pwd|secret|api_key|apikey|jwt_secret|private_key|auth_token)\s*:\s*["'`][^"'`\s]{6,}/i.test(line);
44
- }
76
+ patterns: [
77
+ {
78
+ source: '(?:password|passwd|pwd|secret|api_key|apikey|jwt_secret|private_key|auth_token)\\s*:\\s*["\u0060\'][^"\u0060\'\\s]{6,}',
79
+ flags: 'i'
80
+ }
81
+ ]
45
82
  },
46
83
 
47
- // ── 2. Insecure randomness ────────────────────────────
84
+ // ── Rule 3: Insecure random — token context ───────────────────────────────
48
85
  {
49
86
  id: 'insecure-random-token',
50
- // security-scan: disable rule-id: insecure-random-token reason: this is a rule description string, not actual Math.random() usage
51
- description: 'Math.random() is not cryptographically secure. For tokens, session IDs or passwords use crypto.randomBytes() or crypto.getRandomValues() instead.',
87
+ // security-scan: disable rule-id: insecure-random-context reason: description string documents the bad pattern, not uses it
88
+ description: 'Math dot random() is not cryptographically secure and must not be used to generate tokens, session IDs, nonces or passwords. Use crypto.randomBytes() (Node.js) or crypto.getRandomValues() (browser) instead.',
52
89
  owasp: 'A02:2021 Cryptographic Failures',
53
90
  severity: 'high',
54
- test(line) {
55
- return /Math\.random\(\)/.test(line) &&
56
- /(?:token|session|id|key|secret|password|nonce|salt|otp|code|csrf)/i.test(line);
57
- }
91
+ patterns: [
92
+ { source: 'Math\\.random\\(\\)', flags: '' },
93
+ { source: '(?:token|session|id|key|secret|password|nonce|salt|otp|code|csrf)', flags: 'i' }
94
+ ],
95
+ require: 'all'
58
96
  },
59
97
 
98
+ // ── Rule 4: Insecure random — ambient context ─────────────────────────────
60
99
  {
61
- id: 'insecure-random-standalone',
62
- // security-scan: disable rule-id: insecure-random-standalone reason: rule description string, not actual usage
63
- description: 'Math.random() used in a security-sensitive context. Use crypto.randomBytes() for cryptographic purposes.',
100
+ id: 'insecure-random-context',
101
+ // security-scan: disable rule-id: insecure-random-context reason: description string documents the bad pattern, not uses it
102
+ description: 'Math dot random() detected in a security-sensitive context. Use crypto.randomBytes() for any cryptographic or security-sensitive purpose.',
64
103
  owasp: 'A02:2021 Cryptographic Failures',
65
104
  severity: 'medium',
66
- test(line, lineNum, allLines) {
67
- if (!(/Math\.random\(\)/.test(line))) return false;
68
- // Check surrounding 3 lines for security context
69
- const ctx = allLines.slice(Math.max(0, lineNum - 3), lineNum + 3).join(' ');
70
- return /(?:token|session|secret|key|auth|crypto|password|nonce|salt)/i.test(ctx);
105
+ patterns: [
106
+ { source: 'Math\\.random\\(\\)', flags: '' }
107
+ ],
108
+ context: {
109
+ lines: 3,
110
+ pattern: { source: '(?:token|session|secret|key|auth|crypto|password|nonce|salt)', flags: 'i' }
71
111
  }
72
112
  },
73
113
 
74
- // ── 3. Prototype pollution ────────────────────────────
114
+ // ── Rule 5: Prototype pollution via bracket notation ──────────────────────
75
115
  {
76
116
  id: 'prototype-pollution',
77
- description: 'Possible prototype pollution: assigning to a bracket-notation property using a variable key. Validate or whitelist keys before assignment.',
117
+ description: [
118
+ 'Possible prototype pollution: a variable key is used in bracket-notation assignment.',
119
+ 'If the key is user-controlled, an attacker can set properties on Object.prototype.',
120
+ 'Validate or whitelist keys before assignment.'
121
+ ].join(' '),
78
122
  owasp: 'A03:2021 Injection',
79
123
  severity: 'high',
80
- test(line) {
81
- // obj[userKey] = value or target[key] = val where key is variable
82
- return /\w+\[\s*\w+\s*\]\s*=/.test(line) &&
83
- !/\/\//.test(line.split('=')[0]); // not in a comment
84
- }
124
+ patterns: [
125
+ { source: '\\w+\\[\\s*\\w+\\s*\\]\\s*=', flags: '' }
126
+ ]
85
127
  },
86
128
 
129
+ // ── Rule 6: Direct prototype chain access ─────────────────────────────────
87
130
  {
88
131
  id: 'proto-direct-access',
89
- // security-scan: disable rule-id: proto-direct-access reason: description string contains __proto__ as text only, not as code
90
- description: 'Direct __proto__ access detected. This can lead to prototype pollution.',
132
+ description: [
133
+ 'Direct access to the prototype chain detected.',
134
+ 'This pattern is commonly used in prototype pollution attacks.',
135
+ 'Avoid using prototype-chain access with user-controlled input.'
136
+ ].join(' '),
91
137
  owasp: 'A03:2021 Injection',
92
138
  severity: 'critical',
93
- test(line) {
94
- // security-scan: disable rule-id: proto-direct-access reason: __proto__ is inside a regex literal used as a detection pattern, not actual prototype access
95
- return /__proto__/.test(line);
96
- }
139
+ patterns: [
140
+ // security-scan: disable rule-id: proto-direct-access reason: this string is the detection pattern, not usage of __proto__
141
+ { source: '__proto__', flags: '' }
142
+ ]
97
143
  },
98
144
 
99
- // ── 4. Sensitive data in localStorage ────────────────
145
+ // ── Rule 7: Sensitive data in localStorage ────────────────────────────────
100
146
  {
101
147
  id: 'localstorage-sensitive-data',
102
- description: 'Sensitive data stored in localStorage. localStorage is accessible to any JS on the page (XSS). Use httpOnly cookies for tokens and passwords.',
148
+ description: [
149
+ 'Sensitive data stored in localStorage.',
150
+ 'localStorage is accessible to any JavaScript on the page and is vulnerable',
151
+ 'to XSS attacks. Use httpOnly cookies for tokens and authentication data.'
152
+ ].join(' '),
103
153
  owasp: 'A02:2021 Cryptographic Failures',
104
154
  severity: 'high',
105
- test(line) {
106
- return /localStorage\.setItem\s*\(/.test(line) &&
107
- /(?:password|passwd|pwd|token|secret|key|auth|jwt|session|credential)/i.test(line);
108
- }
155
+ patterns: [
156
+ { source: 'localStorage\\.setItem\\s*\\(', flags: '' },
157
+ { source: '(?:password|passwd|pwd|token|secret|key|auth|jwt|session|credential)', flags: 'i' }
158
+ ],
159
+ require: 'all'
109
160
  },
110
161
 
162
+ // ── Rule 8: Sensitive data in sessionStorage ──────────────────────────────
111
163
  {
112
164
  id: 'sessionstorage-sensitive-data',
113
- description: 'Sensitive data stored in sessionStorage. sessionStorage is accessible to XSS attacks. Use httpOnly cookies instead.',
165
+ description: [
166
+ 'Sensitive data stored in sessionStorage.',
167
+ 'sessionStorage is accessible to XSS attacks.',
168
+ 'Use httpOnly cookies for authentication tokens instead.'
169
+ ].join(' '),
114
170
  owasp: 'A02:2021 Cryptographic Failures',
115
171
  severity: 'high',
116
- test(line) {
117
- return /sessionStorage\.setItem\s*\(/.test(line) &&
118
- /(?:password|passwd|pwd|token|secret|key|auth|jwt|credential)/i.test(line);
119
- }
172
+ patterns: [
173
+ { source: 'sessionStorage\\.setItem\\s*\\(', flags: '' },
174
+ { source: '(?:password|passwd|pwd|token|secret|key|auth|jwt|credential)', flags: 'i' }
175
+ ],
176
+ require: 'all'
120
177
  },
121
178
 
122
- // ── 5. console.log with sensitive data ───────────────
179
+ // ── Rule 9: Sensitive data in console output ──────────────────────────────
123
180
  {
124
181
  id: 'console-log-sensitive',
125
- description: 'Possible logging of sensitive data. Passwords, tokens and secrets should never be logged as they appear in log files and monitoring tools.',
182
+ description: [
183
+ 'Possible logging of sensitive data via console output.',
184
+ 'Passwords, tokens and secrets logged to console appear in log files',
185
+ 'and monitoring tools, creating an information disclosure risk.'
186
+ ].join(' '),
126
187
  owasp: 'A09:2021 Security Logging and Monitoring Failures',
127
188
  severity: 'high',
128
- test(line) {
129
- return /console\.\s*(?:log|info|warn|error|debug)\s*\(/.test(line) &&
130
- /(?:password|passwd|pwd|secret|token|api_?key|jwt|credential|private)/i.test(line);
131
- }
189
+ patterns: [
190
+ { source: 'console\\.(?:log|info|warn|error|debug)\\s*\\(', flags: '' },
191
+ { source: '(?:password|passwd|pwd|secret|token|api.?key|jwt|credential|private)', flags: 'i' }
192
+ ],
193
+ require: 'all'
132
194
  },
133
195
 
134
- // ── 6. new Function() with dynamic input ─────────────
196
+ // ── Rule 10: Dynamic code execution via Function constructor ───────────────
135
197
  {
136
- id: 'new-function-injection',
137
- // security-scan: disable rule-id: new-function-injection reason: this is a rule description string, not actual new Function() usage
138
- description: 'new Function() with dynamic input is equivalent to eval(). An attacker can execute arbitrary JavaScript. Use a safe alternative.',
198
+ id: 'dynamic-function-constructor',
199
+ description: [
200
+ 'Dynamic code execution via the Function constructor detected.',
201
+ 'Passing non-literal arguments to the Function constructor is equivalent',
202
+ 'to eval() and allows arbitrary JavaScript execution.',
203
+ 'Use a safe, sandboxed alternative instead.'
204
+ ].join(' '),
139
205
  owasp: 'A03:2021 Injection',
140
206
  severity: 'critical',
141
- test(line) {
142
- // new Function(variable) or new Function("..." + variable)
143
- return /new\s+Function\s*\(/.test(line) &&
144
- !/new\s+Function\s*\(\s*["'`][^"'`]*["'`]\s*\)/.test(line); // not pure string literal
145
- }
146
- },
207
+ patterns: [
208
+ { source: 'new\\s+Function\\s*\\(', flags: '' }
209
+ ],
210
+ // Only flag when the argument is not a pure string literal
211
+ exclude: [
212
+ { source: 'new\\s+Function\\s*\\(\\s*["\u0060\'][^"\u0060\']*["\u0060\']\\s*\\)', flags: '' }
213
+ ]
214
+ }
147
215
 
148
216
  ];
149
217
 
150
- // ─────────────────────────────────────────────────────────
151
- // Scanner: run all rules against a file
152
- // ─────────────────────────────────────────────────────────
218
+ // ─────────────────────────────────────────────────────────────────────────────
219
+ // Compile patterns at module load time
220
+ // ─────────────────────────────────────────────────────────────────────────────
221
+ // The RegExp() calls below are intentional: patterns are stored as strings and
222
+ // compiled once at startup. The sources come from the hardcoded RULE_DEFINITIONS
223
+ // array above — they are NOT derived from user input.
224
+ const COMPILED_RULES = RULE_DEFINITIONS.map((rule) => ({
225
+ ...rule,
226
+ // security-scan: disable rule-id: detect-non-literal-regexp reason: sources are hardcoded strings from RULE_DEFINITIONS, never user input
227
+ compiled: rule.patterns.map((p) => new RegExp(p.source, p.flags)), // security-scan: disable rule-id: detect-non-literal-regexp reason: hardcoded rule pattern strings only
228
+ compiledExclude: (rule.exclude || []).map((p) => new RegExp(p.source, p.flags)), // security-scan: disable rule-id: detect-non-literal-regexp reason: hardcoded rule pattern strings only
229
+ compiledContext: rule.context
230
+ // security-scan: disable rule-id: detect-non-literal-regexp reason: hardcoded rule pattern strings only
231
+ ? new RegExp(rule.context.pattern.source, rule.context.pattern.flags)
232
+ : null
233
+ }));
234
+
235
+ // ─────────────────────────────────────────────────────────────────────────────
236
+ // Test a single line against a compiled rule
237
+ // ─────────────────────────────────────────────────────────────────────────────
238
+ function testRule(rule, line, lineIdx, allLines) {
239
+ const requireAll = rule.require === 'all';
240
+
241
+ // Check exclude patterns first — if matched, skip this rule
242
+ for (const excl of rule.compiledExclude) {
243
+ if (excl.test(line)) return false;
244
+ }
245
+
246
+ // Test main patterns
247
+ const results = rule.compiled.map((re) => re.test(line));
248
+ const matched = requireAll ? results.every(Boolean) : results.some(Boolean);
249
+
250
+ if (!matched) return false;
251
+
252
+ // If a context check is required, scan surrounding lines
253
+ if (rule.compiledContext) {
254
+ const { lines: windowSize } = rule.context;
255
+ const start = Math.max(0, lineIdx - windowSize);
256
+ const end = Math.min(allLines.length, lineIdx + windowSize + 1);
257
+ const surrounding = allLines.slice(start, end).join(' ');
258
+ if (!rule.compiledContext.test(surrounding)) return false;
259
+ }
260
+
261
+ return true;
262
+ }
263
+
264
+ // ─────────────────────────────────────────────────────────────────────────────
265
+ // Suppression check
266
+ // ─────────────────────────────────────────────────────────────────────────────
267
+ const SUPPRESS_RE = /security-scan:\s*disable/i;
268
+
269
+ function isSuppressed(lines, lineIdx) {
270
+ const current = lines[lineIdx] || '';
271
+ const previous = lines[lineIdx - 1] || '';
272
+ return SUPPRESS_RE.test(current) || SUPPRESS_RE.test(previous);
273
+ }
274
+
275
+ // ─────────────────────────────────────────────────────────────────────────────
276
+ // Main scanner
277
+ // ─────────────────────────────────────────────────────────────────────────────
153
278
  function scanFileWithCustomRules(filePath) {
154
279
  let content;
155
280
  try {
156
- // security-scan: disable rule-id: detect-non-literal-fs-filename reason: filePath comes from `git diff --cached --name-only`, not from user input
281
+ // security-scan: disable rule-id: detect-non-literal-fs-filename reason: filePath comes from `git diff --cached --name-only`, not user input
157
282
  content = fs.readFileSync(filePath, 'utf8');
158
283
  } catch {
159
284
  return [];
160
285
  }
161
286
 
162
- const lines = content.split(/\r?\n/);
287
+ const lines = content.split(/\r?\n/);
163
288
  const findings = [];
164
289
 
165
290
  for (let i = 0; i < lines.length; i++) {
166
- const line = lines[i];
167
- const lineNum = i + 1;
168
- const trimmed = line.trim();
291
+ const line = lines[i];
292
+ const trimmed = line.trim();
169
293
 
170
- // Skip blank lines and pure comments
171
294
  if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
295
+ if (isSuppressed(lines, i)) continue;
172
296
 
173
- // Skip lines with suppression tag
174
- if (/security-scan:\s*disable/i.test(line)) continue;
175
-
176
- // Also check the line immediately above for suppression
177
- const prevLine = i > 0 ? lines[i - 1] : '';
178
- if (/security-scan:\s*disable/i.test(prevLine)) continue;
179
-
180
- for (const rule of RULES) {
181
- if (rule.test(line, i, lines)) {
297
+ for (const rule of COMPILED_RULES) {
298
+ if (testRule(rule, line, i, lines)) {
182
299
  findings.push({
183
300
  checkId: rule.id,
184
301
  path: filePath,
185
- line: lineNum,
302
+ line: i + 1,
186
303
  message: rule.description,
187
304
  severity: rule.severity,
188
305
  owasp: rule.owasp,
189
306
  raw: { line: trimmed }
190
307
  });
191
- break; // one finding per line per pass — avoid duplicates
308
+ break; // one finding per line
192
309
  }
193
310
  }
194
311
  }
@@ -196,4 +313,4 @@ function scanFileWithCustomRules(filePath) {
196
313
  return findings;
197
314
  }
198
315
 
199
- module.exports = { scanFileWithCustomRules, RULES };
316
+ module.exports = { scanFileWithCustomRules, RULE_DEFINITIONS };