sec-gate 0.1.1 → 0.1.3

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.1",
3
+ "version": "0.1.3",
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",
@@ -48,6 +48,7 @@
48
48
  "bin/",
49
49
  "src/",
50
50
  "scripts/",
51
+ "rules/",
51
52
  "vendor-bin/.gitkeep",
52
53
  "README.md"
53
54
  ]
@@ -0,0 +1,199 @@
1
+ /**
2
+ * sec-gate custom security rules
3
+ *
4
+ * These rules cover patterns NOT caught by @pensar/semgrep-node's owasp-top10 ruleset:
5
+ * 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
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ // ─────────────────────────────────────────────────────────
17
+ // Rule definitions
18
+ // Each rule: { id, description, owasp, severity, test(line, lineNum, allLines) }
19
+ // Returns a finding object or null
20
+ // ─────────────────────────────────────────────────────────
21
+
22
+ const RULES = [
23
+
24
+ // ── 1. Hardcoded secrets ──────────────────────────────
25
+ {
26
+ id: 'hardcoded-secret-assignment',
27
+ description: 'Hardcoded secret detected. Secrets should be loaded from environment variables, not hardcoded in source code.',
28
+ owasp: 'A02:2021 Cryptographic Failures',
29
+ 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
+ }
34
+ },
35
+
36
+ {
37
+ id: 'hardcoded-secret-object',
38
+ description: 'Hardcoded secret in object literal. Use environment variables instead.',
39
+ owasp: 'A02:2021 Cryptographic Failures',
40
+ 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
+ }
45
+ },
46
+
47
+ // ── 2. Insecure randomness ────────────────────────────
48
+ {
49
+ 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.',
52
+ owasp: 'A02:2021 Cryptographic Failures',
53
+ 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
+ }
58
+ },
59
+
60
+ {
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.',
64
+ owasp: 'A02:2021 Cryptographic Failures',
65
+ 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);
71
+ }
72
+ },
73
+
74
+ // ── 3. Prototype pollution ────────────────────────────
75
+ {
76
+ id: 'prototype-pollution',
77
+ description: 'Possible prototype pollution: assigning to a bracket-notation property using a variable key. Validate or whitelist keys before assignment.',
78
+ owasp: 'A03:2021 Injection',
79
+ 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
+ }
85
+ },
86
+
87
+ {
88
+ 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.',
91
+ owasp: 'A03:2021 Injection',
92
+ 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
+ }
97
+ },
98
+
99
+ // ── 4. Sensitive data in localStorage ────────────────
100
+ {
101
+ 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.',
103
+ owasp: 'A02:2021 Cryptographic Failures',
104
+ 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
+ }
109
+ },
110
+
111
+ {
112
+ id: 'sessionstorage-sensitive-data',
113
+ description: 'Sensitive data stored in sessionStorage. sessionStorage is accessible to XSS attacks. Use httpOnly cookies instead.',
114
+ owasp: 'A02:2021 Cryptographic Failures',
115
+ 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
+ }
120
+ },
121
+
122
+ // ── 5. console.log with sensitive data ───────────────
123
+ {
124
+ 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.',
126
+ owasp: 'A09:2021 Security Logging and Monitoring Failures',
127
+ 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
+ }
132
+ },
133
+
134
+ // ── 6. new Function() with dynamic input ─────────────
135
+ {
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.',
139
+ owasp: 'A03:2021 Injection',
140
+ 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
+ },
147
+
148
+ ];
149
+
150
+ // ─────────────────────────────────────────────────────────
151
+ // Scanner: run all rules against a file
152
+ // ─────────────────────────────────────────────────────────
153
+ function scanFileWithCustomRules(filePath) {
154
+ let content;
155
+ 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
157
+ content = fs.readFileSync(filePath, 'utf8');
158
+ } catch {
159
+ return [];
160
+ }
161
+
162
+ const lines = content.split(/\r?\n/);
163
+ const findings = [];
164
+
165
+ for (let i = 0; i < lines.length; i++) {
166
+ const line = lines[i];
167
+ const lineNum = i + 1;
168
+ const trimmed = line.trim();
169
+
170
+ // Skip blank lines and pure comments
171
+ if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('*')) continue;
172
+
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)) {
182
+ findings.push({
183
+ checkId: rule.id,
184
+ path: filePath,
185
+ line: lineNum,
186
+ message: rule.description,
187
+ severity: rule.severity,
188
+ owasp: rule.owasp,
189
+ raw: { line: trimmed }
190
+ });
191
+ break; // one finding per line per pass — avoid duplicates
192
+ }
193
+ }
194
+ }
195
+
196
+ return findings;
197
+ }
198
+
199
+ module.exports = { scanFileWithCustomRules, RULES };
package/src/cli.js CHANGED
@@ -2,7 +2,16 @@ const path = require('path');
2
2
 
3
3
  function usage() {
4
4
  // eslint-disable-next-line no-console
5
- console.log(`sec-gate - OWASP Top 10 security gate\n\nUsage:\n sec-gate install\n sec-gate scan --staged\n\nCommands:\n install Installs the local git pre-commit hook for this repo\n scan Runs SAST/SCA checks (supports --staged)\n`);
5
+ console.log([
6
+ 'sec-gate - OWASP Top 10 security gate',
7
+ '',
8
+ 'Usage:',
9
+ ' sec-gate install Install the pre-commit hook in this repo',
10
+ ' sec-gate scan Scan all tracked files',
11
+ ' sec-gate scan --staged Scan only staged files (used by pre-commit hook)',
12
+ ' sec-gate doctor Check all components are installed and working',
13
+ ''
14
+ ].join('\n'));
6
15
  }
7
16
 
8
17
  function parseArgs(argv) {
@@ -38,6 +47,12 @@ async function run() {
38
47
  return;
39
48
  }
40
49
 
50
+ if (cmd === 'doctor') {
51
+ const { doctor } = require('./commands/doctor');
52
+ await doctor();
53
+ return;
54
+ }
55
+
41
56
  usage();
42
57
  process.exit(1);
43
58
  }
@@ -0,0 +1,103 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execFileSync } = require('child_process');
4
+
5
+ function check(label, fn) {
6
+ try {
7
+ const result = fn();
8
+ console.log(` [OK] ${label}${result ? ': ' + result : ''}`);
9
+ return true;
10
+ } catch (err) {
11
+ console.log(` [FAIL] ${label}: ${err.message}`);
12
+ return false;
13
+ }
14
+ }
15
+
16
+ async function doctor() {
17
+ console.log('\nsec-gate doctor — checking all components\n');
18
+
19
+ // 1. sec-gate itself
20
+ check('sec-gate CLI', () => {
21
+ const pkg = require('../../package.json');
22
+ return `v${pkg.version}`;
23
+ });
24
+
25
+ // 2. @pensar/semgrep-node
26
+ console.log('\n--- SAST (Semgrep) ---');
27
+ const semgrepOk = check('@pensar/semgrep-node installed', () => {
28
+ const pkg = require('@pensar/semgrep-node');
29
+ return 'found';
30
+ });
31
+
32
+ if (semgrepOk) {
33
+ // Try actually loading the binary by doing a tiny scan on a temp file
34
+ await (async () => {
35
+ try {
36
+ const os = require('os');
37
+ const tmpFile = path.join(os.tmpdir(), 'sec-gate-test.js');
38
+ fs.writeFileSync(tmpFile, '// test\nconst x = 1;\n');
39
+ const scan = require('@pensar/semgrep-node').default;
40
+ await scan(tmpFile, { language: 'js', ruleSets: ['owasp-top10'] });
41
+ fs.unlinkSync(tmpFile);
42
+ console.log(' [OK] semgrep binary: working');
43
+ } catch (err) {
44
+ console.log(` [FAIL] semgrep binary: ${err.message}`);
45
+ console.log(' Fix: the semgrep binary may not be downloaded yet.');
46
+ console.log(' Try running `sec-gate scan` on a JS file once to trigger download.');
47
+ }
48
+ })();
49
+ }
50
+
51
+ // 3. osv-scanner
52
+ console.log('\n--- SCA: Node/pnpm (OSV-Scanner) ---');
53
+ const ext = process.platform === 'win32' ? '.exe' : '';
54
+ const vendorOsv = path.join(__dirname, '..', '..', 'vendor-bin', `osv-scanner${ext}`);
55
+
56
+ check('osv-scanner binary (vendor-bin)', () => {
57
+ if (fs.existsSync(vendorOsv)) return vendorOsv;
58
+ throw new Error('not found in vendor-bin');
59
+ });
60
+
61
+ check('osv-scanner executable', () => {
62
+ if (!fs.existsSync(vendorOsv)) throw new Error('binary missing');
63
+ const out = execFileSync(vendorOsv, ['--version'], { encoding: 'utf8' }).trim();
64
+ return out;
65
+ });
66
+
67
+ // 4. govulncheck
68
+ console.log('\n--- SCA: Go (govulncheck) ---');
69
+ const vendorGo = path.join(__dirname, '..', '..', 'vendor-bin', `govulncheck${ext}`);
70
+
71
+ check('govulncheck binary (vendor-bin)', () => {
72
+ if (fs.existsSync(vendorGo)) return vendorGo;
73
+ throw new Error('not found — Go SCA will be skipped (Go may not be installed)');
74
+ });
75
+
76
+ // 5. git hook
77
+ console.log('\n--- Pre-commit hook ---');
78
+ check('git available', () => {
79
+ execFileSync('git', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] });
80
+ return 'found';
81
+ });
82
+
83
+ try {
84
+ const repoRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
85
+ encoding: 'utf8',
86
+ stdio: ['ignore', 'pipe', 'ignore']
87
+ }).trim();
88
+
89
+ const hookPath = path.join(repoRoot, '.git', 'hooks', 'pre-commit');
90
+ check('pre-commit hook installed', () => {
91
+ if (!fs.existsSync(hookPath)) throw new Error('not found — run `sec-gate install`');
92
+ const content = fs.readFileSync(hookPath, 'utf8');
93
+ if (!content.includes('installed-by: sec-gate')) throw new Error('hook exists but was not installed by sec-gate');
94
+ return hookPath;
95
+ });
96
+ } catch {
97
+ console.log(' [SKIP] pre-commit hook: not inside a git repo');
98
+ }
99
+
100
+ console.log('\nsec-gate doctor done.\n');
101
+ }
102
+
103
+ module.exports = { doctor };
@@ -4,10 +4,12 @@ const { runSemgrep } = require('../scanners/semgrep');
4
4
  const { runOsvScanner } = require('../scanners/osv');
5
5
  const { runGovulncheck } = require('../scanners/govulncheck');
6
6
  const { applyInlineSuppressions } = require('../suppressions/inlineTag');
7
+ const { scanFileWithCustomRules } = require('../../rules/custom-security');
7
8
 
8
9
  function formatFinding(f) {
9
10
  const loc = f.line ? `${f.path}:${f.line}` : f.path;
10
- return `- ${loc} [${f.checkId}] ${f.message}`;
11
+ const owasp = f.owasp ? ` (${f.owasp})` : '';
12
+ return `- ${loc} [${f.checkId}]${owasp}\n ${f.message}`;
11
13
  }
12
14
 
13
15
  function isSemgrepTargetPath(p) {
@@ -34,16 +36,21 @@ async function scan({ staged }) {
34
36
  console.log(`sec-gate: scan started (${staged ? 'staged files' : 'tracked files'})`);
35
37
 
36
38
  const allFindings = [];
37
-
38
39
  const semgrepTargets = (files || []).filter(isSemgrepTargetPath);
39
40
 
40
- // SAST / misconfig
41
41
  if (semgrepTargets.length > 0) {
42
+ // SAST — owasp-top10 via @pensar/semgrep-node
42
43
  const sast = await runSemgrep({ files: semgrepTargets });
43
44
  allFindings.push(...sast);
45
+
46
+ // Custom rules — patterns not covered by owasp-top10 ruleset
47
+ for (const filePath of semgrepTargets) {
48
+ const custom = scanFileWithCustomRules(filePath);
49
+ allFindings.push(...custom);
50
+ }
44
51
  } else {
45
52
  // eslint-disable-next-line no-console
46
- console.log('sec-gate: no relevant staged/tracked source files for Semgrep; skipping SAST');
53
+ console.log('sec-gate: no relevant staged/tracked source files; skipping SAST');
47
54
  }
48
55
 
49
56
  // SCA (only when dependency lockfiles or go module files are staged)
@@ -34,24 +34,37 @@ async function runSemgrep({ files }) {
34
34
 
35
35
  for (const filePath of files) {
36
36
  const lang = getLanguage(filePath);
37
+ // security-scan: disable rule-id: path-join-resolve-traversal reason: filePath comes from `git diff --cached --name-only` output, not from user input
37
38
  const absPath = path.resolve(filePath);
38
39
 
39
40
  try {
40
- // Scan with OWASP Top 10 rules bundled inside @pensar/semgrep-node
41
+ // eslint-disable-next-line no-console
42
+ console.log(`sec-gate: scanning ${filePath} (${lang}) with owasp-top10 rules...`);
43
+
41
44
  const issues = await semgrepScan(absPath, {
42
45
  language: lang,
43
46
  ruleSets: ['owasp-top10']
44
47
  });
45
48
 
49
+ if (issues.length > 0) {
50
+ // eslint-disable-next-line no-console
51
+ console.log(`sec-gate: found ${issues.length} finding(s) in ${filePath}`);
52
+ }
53
+
46
54
  for (const issue of issues) {
47
55
  allFindings.push(normalizeSemgrepNodeFinding(issue, filePath));
48
56
  }
49
57
  } catch (err) {
50
- // If semgrep binary not yet downloaded for this platform, warn but don't crash.
51
58
  if (err && err.message && err.message.includes('ENOENT')) {
52
- console.warn(`sec-gate: semgrep binary not ready for ${lang}; skipping ${filePath}`);
59
+ // eslint-disable-next-line no-console
60
+ console.warn(`sec-gate: WARNING — semgrep binary not found for language "${lang}"`);
61
+ // eslint-disable-next-line no-console
62
+ console.warn(' The semgrep binary needs to be downloaded by @pensar/semgrep-node.');
63
+ // eslint-disable-next-line no-console
64
+ console.warn(' Run `sec-gate doctor` to diagnose, or re-install: npm i -g sec-gate');
53
65
  } else {
54
- throw err;
66
+ // eslint-disable-next-line no-console
67
+ console.warn(`sec-gate: WARNING — semgrep scan failed for ${filePath}: ${err.message}`);
55
68
  }
56
69
  }
57
70
  }