sec-gate 0.1.7 → 0.1.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/README.md +34 -0
- package/package.json +3 -2
- package/sec-gate.example.yml +39 -0
- package/src/commands/install.js +14 -0
- package/src/commands/scan.js +115 -26
- package/src/config/loader.js +214 -0
package/README.md
CHANGED
|
@@ -126,6 +126,40 @@ doSomethingDangerous();
|
|
|
126
126
|
|
|
127
127
|
---
|
|
128
128
|
|
|
129
|
+
## Configuration (optional)
|
|
130
|
+
|
|
131
|
+
Create a `.sec-gate.yml` file in your project root to tune the scanner:
|
|
132
|
+
|
|
133
|
+
```yaml
|
|
134
|
+
# .sec-gate.yml
|
|
135
|
+
|
|
136
|
+
# Only block commits on high/critical findings (medium/low are reported but don't block)
|
|
137
|
+
severity_threshold: high
|
|
138
|
+
|
|
139
|
+
# Exclude specific high-noise rules
|
|
140
|
+
exclude_rules:
|
|
141
|
+
- path-join-resolve-traversal
|
|
142
|
+
- detect-non-literal-regexp
|
|
143
|
+
- detect-non-literal-fs-filename
|
|
144
|
+
|
|
145
|
+
# Skip test/mock files
|
|
146
|
+
exclude_paths:
|
|
147
|
+
- "**/__tests__/**"
|
|
148
|
+
- "**/*.test.js"
|
|
149
|
+
- "**/*.spec.ts"
|
|
150
|
+
- "**/mocks/**"
|
|
151
|
+
|
|
152
|
+
# Disable SCA if you use Snyk/Dependabot separately
|
|
153
|
+
sca: true
|
|
154
|
+
|
|
155
|
+
# Disable custom rules
|
|
156
|
+
custom_rules: true
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
A full example with all options is at `sec-gate.example.yml` inside the package.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
129
163
|
## Bypass (emergency only)
|
|
130
164
|
|
|
131
165
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sec-gate",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"scripts/",
|
|
51
51
|
"rules/",
|
|
52
52
|
"vendor-bin/.gitkeep",
|
|
53
|
-
"README.md"
|
|
53
|
+
"README.md",
|
|
54
|
+
"sec-gate.example.yml"
|
|
54
55
|
]
|
|
55
56
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# .sec-gate.yml — sec-gate configuration
|
|
2
|
+
# Copy this file to your project root as .sec-gate.yml and customize.
|
|
3
|
+
# All fields are optional. Defaults shown below.
|
|
4
|
+
|
|
5
|
+
# Severity threshold — only block commits on findings at or above this level.
|
|
6
|
+
# Options: all (default), critical, high, medium, low
|
|
7
|
+
# Examples:
|
|
8
|
+
# severity_threshold: all → block on any finding (most strict)
|
|
9
|
+
# severity_threshold: high → block on high + critical only
|
|
10
|
+
# severity_threshold: critical → block only on critical findings
|
|
11
|
+
severity_threshold: all
|
|
12
|
+
|
|
13
|
+
# Rule IDs to exclude globally (never report these, even if found).
|
|
14
|
+
# The defaults below exclude the highest false-positive rules.
|
|
15
|
+
# Set to [] to re-enable everything.
|
|
16
|
+
exclude_rules:
|
|
17
|
+
- path-join-resolve-traversal
|
|
18
|
+
- detect-non-literal-regexp
|
|
19
|
+
- detect-non-literal-fs-filename
|
|
20
|
+
|
|
21
|
+
# File paths/patterns to skip entirely (supports glob patterns).
|
|
22
|
+
# Useful for test files, mocks, fixtures where dangerous patterns are intentional.
|
|
23
|
+
exclude_paths:
|
|
24
|
+
- "**/__tests__/**"
|
|
25
|
+
- "**/*.test.js"
|
|
26
|
+
- "**/*.test.ts"
|
|
27
|
+
- "**/*.spec.js"
|
|
28
|
+
- "**/*.spec.ts"
|
|
29
|
+
- "**/test/**"
|
|
30
|
+
- "**/tests/**"
|
|
31
|
+
- "**/mocks/**"
|
|
32
|
+
- "**/fixtures/**"
|
|
33
|
+
|
|
34
|
+
# Enable/disable dependency vulnerability scanning (SCA).
|
|
35
|
+
# Disable if you manage dependencies with a separate tool (Snyk, Dependabot etc.)
|
|
36
|
+
sca: true
|
|
37
|
+
|
|
38
|
+
# Enable/disable custom security rules (hardcoded secrets, insecure random, etc.)
|
|
39
|
+
custom_rules: true
|
package/src/commands/install.js
CHANGED
|
@@ -102,6 +102,11 @@ function standaloneHook() {
|
|
|
102
102
|
* Returns the absolute path to the pre-commit hook file that git WILL execute.
|
|
103
103
|
* This is the single source of truth — works regardless of which hook manager
|
|
104
104
|
* set core.hooksPath.
|
|
105
|
+
*
|
|
106
|
+
* Special cases handled:
|
|
107
|
+
* - .husky/_ → husky's internal bootstrap shim dir, read-only, never write here.
|
|
108
|
+
* Fall back to .husky/pre-commit (the real hook file).
|
|
109
|
+
* - .husky → husky v6+ standard hooks dir, use .husky/pre-commit directly.
|
|
105
110
|
*/
|
|
106
111
|
function resolveGitHookPath(repoRoot) {
|
|
107
112
|
let hooksDir;
|
|
@@ -118,6 +123,15 @@ function resolveGitHookPath(repoRoot) {
|
|
|
118
123
|
hooksDir = path.isAbsolute(configured)
|
|
119
124
|
? configured
|
|
120
125
|
: path.join(repoRoot, configured);
|
|
126
|
+
|
|
127
|
+
// .husky/_ is husky's internal bootstrap shim directory — it is read-only
|
|
128
|
+
// and should never be written to. The actual user-editable hooks live in
|
|
129
|
+
// .husky/ (one level up). Redirect there.
|
|
130
|
+
const huskyShimDir = path.join(repoRoot, '.husky', '_');
|
|
131
|
+
if (hooksDir === huskyShimDir || hooksDir.startsWith(huskyShimDir + path.sep)) {
|
|
132
|
+
console.log('sec-gate: core.hooksPath points to .husky/_ (husky bootstrap shim) — redirecting to .husky/');
|
|
133
|
+
hooksDir = path.join(repoRoot, '.husky');
|
|
134
|
+
}
|
|
121
135
|
}
|
|
122
136
|
} catch {
|
|
123
137
|
// core.hooksPath not set — use default
|
package/src/commands/scan.js
CHANGED
|
@@ -6,11 +6,18 @@ const { runOsvScanner } = require('../scanners/osv');
|
|
|
6
6
|
const { runGovulncheck } = require('../scanners/govulncheck');
|
|
7
7
|
const { applyInlineSuppressions } = require('../suppressions/inlineTag');
|
|
8
8
|
const { scanFileWithCustomRules } = require('../../rules/custom-security');
|
|
9
|
+
const { loadConfig, meetsThreshold, isExcludedPath } = require('../config/loader');
|
|
10
|
+
const { getRepoRoot } = require('../git/repo');
|
|
11
|
+
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// Helpers
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
9
15
|
|
|
10
16
|
function formatFinding(f) {
|
|
11
|
-
const loc
|
|
17
|
+
const loc = f.line ? `${f.path}:${f.line}` : f.path;
|
|
12
18
|
const owasp = f.owasp ? ` (${f.owasp})` : '';
|
|
13
|
-
|
|
19
|
+
const sev = f.severity ? ` [${f.severity.toUpperCase()}]` : '';
|
|
20
|
+
return `- ${loc}${sev} [${f.checkId}]${owasp}\n ${f.message}`;
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
const LOCKFILES = new Set([
|
|
@@ -22,61 +29,121 @@ const LOCKFILES = new Set([
|
|
|
22
29
|
'go.sum'
|
|
23
30
|
]);
|
|
24
31
|
|
|
25
|
-
function isSemgrepTargetPath(p) {
|
|
32
|
+
function isSemgrepTargetPath(p, config) {
|
|
26
33
|
if (!p) return false;
|
|
27
34
|
|
|
28
35
|
const base = require('path').basename(p);
|
|
29
36
|
if (LOCKFILES.has(base)) return false;
|
|
30
37
|
|
|
38
|
+
// Skip paths excluded in config
|
|
39
|
+
if (isExcludedPath(p, config.exclude_paths)) return false;
|
|
40
|
+
|
|
31
41
|
return (
|
|
32
|
-
p.endsWith('.js')
|
|
42
|
+
p.endsWith('.js') ||
|
|
33
43
|
p.endsWith('.jsx') ||
|
|
34
44
|
p.endsWith('.mjs') ||
|
|
35
45
|
p.endsWith('.cjs') ||
|
|
36
|
-
p.endsWith('.ts')
|
|
46
|
+
p.endsWith('.ts') ||
|
|
37
47
|
p.endsWith('.tsx') ||
|
|
38
48
|
p.endsWith('.go')
|
|
39
49
|
);
|
|
40
50
|
}
|
|
41
51
|
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// Apply config filters to findings list
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
function applyConfigFilters(findings, config) {
|
|
56
|
+
const excludedRules = new Set(config.exclude_rules || []);
|
|
57
|
+
const threshold = config.severity_threshold || 'all';
|
|
58
|
+
|
|
59
|
+
const excluded = [];
|
|
60
|
+
const belowThreshold = [];
|
|
61
|
+
const remaining = [];
|
|
62
|
+
|
|
63
|
+
for (const f of findings) {
|
|
64
|
+
// 1. Exclude by rule ID
|
|
65
|
+
if (excludedRules.has(f.checkId)) {
|
|
66
|
+
excluded.push(f);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// 2. Exclude by path
|
|
71
|
+
if (isExcludedPath(f.path || '', config.exclude_paths)) {
|
|
72
|
+
excluded.push(f);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 3. Filter by severity threshold
|
|
77
|
+
if (!meetsThreshold(f.severity, threshold)) {
|
|
78
|
+
belowThreshold.push(f);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
remaining.push(f);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { remaining, excluded, belowThreshold };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
// Main scan
|
|
90
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
42
91
|
async function scan({ staged }) {
|
|
43
|
-
|
|
92
|
+
// Load per-repo config (.sec-gate.yml)
|
|
93
|
+
let repoRoot;
|
|
94
|
+
try { repoRoot = getRepoRoot(); } catch { repoRoot = process.cwd(); }
|
|
95
|
+
const config = loadConfig(repoRoot);
|
|
96
|
+
|
|
97
|
+
const files = staged ? getStagedFiles() : listTrackedFiles();
|
|
44
98
|
const depChanged = staged ? hasStagedDependencyFiles(files) : true;
|
|
45
99
|
|
|
46
100
|
// eslint-disable-next-line no-console
|
|
47
101
|
console.log(`sec-gate: scan started (${staged ? 'staged files' : 'tracked files'})`);
|
|
48
102
|
|
|
49
|
-
|
|
50
|
-
|
|
103
|
+
// Print active config summary
|
|
104
|
+
if (config.severity_threshold !== 'all') {
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.log(`sec-gate: severity threshold: ${config.severity_threshold} and above`);
|
|
107
|
+
}
|
|
108
|
+
if (config.exclude_rules.length > 0) {
|
|
109
|
+
// eslint-disable-next-line no-console
|
|
110
|
+
console.log(`sec-gate: excluding ${config.exclude_rules.length} high-noise rule(s)`);
|
|
111
|
+
}
|
|
51
112
|
|
|
113
|
+
const allFindings = [];
|
|
114
|
+
const semgrepTargets = (files || []).filter((f) => isSemgrepTargetPath(f, config));
|
|
115
|
+
|
|
116
|
+
// ── SAST ──────────────────────────────────────────────────────────────────
|
|
52
117
|
if (semgrepTargets.length > 0) {
|
|
53
|
-
// SAST — owasp-top10 via @pensar/semgrep-node
|
|
54
118
|
const sast = await runSemgrep({ files: semgrepTargets });
|
|
55
119
|
allFindings.push(...sast);
|
|
56
120
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
121
|
+
if (config.custom_rules !== false) {
|
|
122
|
+
for (const filePath of semgrepTargets) {
|
|
123
|
+
const custom = scanFileWithCustomRules(filePath);
|
|
124
|
+
allFindings.push(...custom);
|
|
125
|
+
}
|
|
61
126
|
}
|
|
62
127
|
} else {
|
|
63
128
|
// eslint-disable-next-line no-console
|
|
64
129
|
console.log('sec-gate: no relevant staged/tracked source files; skipping SAST');
|
|
65
130
|
}
|
|
66
131
|
|
|
67
|
-
// SCA
|
|
68
|
-
if (
|
|
132
|
+
// ── SCA ───────────────────────────────────────────────────────────────────
|
|
133
|
+
if (config.sca === false) {
|
|
134
|
+
// eslint-disable-next-line no-console
|
|
135
|
+
console.log('sec-gate: SCA disabled in config; skipping');
|
|
136
|
+
} else if (staged && !depChanged) {
|
|
69
137
|
// eslint-disable-next-line no-console
|
|
70
138
|
console.log('sec-gate: dependency files not staged; skipping SCA');
|
|
71
139
|
} else {
|
|
72
140
|
const fs = require('fs');
|
|
73
141
|
|
|
74
|
-
// Detect which Node lockfile exists — support npm, pnpm and yarn
|
|
75
142
|
const nodeLockfiles = [
|
|
76
|
-
'pnpm-lock.yaml',
|
|
77
|
-
'package-lock.json',
|
|
78
|
-
'npm-shrinkwrap.json',
|
|
79
|
-
'yarn.lock'
|
|
143
|
+
'pnpm-lock.yaml',
|
|
144
|
+
'package-lock.json',
|
|
145
|
+
'npm-shrinkwrap.json',
|
|
146
|
+
'yarn.lock'
|
|
80
147
|
];
|
|
81
148
|
const foundLockfile = nodeLockfiles.find((lf) => fs.existsSync(lf));
|
|
82
149
|
|
|
@@ -87,7 +154,7 @@ async function scan({ staged }) {
|
|
|
87
154
|
allFindings.push(...scaOsv);
|
|
88
155
|
} else {
|
|
89
156
|
// eslint-disable-next-line no-console
|
|
90
|
-
console.log('sec-gate: no Node lockfile found
|
|
157
|
+
console.log('sec-gate: no Node lockfile found; skipping OSV-Scanner');
|
|
91
158
|
}
|
|
92
159
|
|
|
93
160
|
if (fs.existsSync('go.mod')) {
|
|
@@ -99,21 +166,43 @@ async function scan({ staged }) {
|
|
|
99
166
|
}
|
|
100
167
|
}
|
|
101
168
|
|
|
102
|
-
|
|
169
|
+
// ── Apply inline suppressions ─────────────────────────────────────────────
|
|
170
|
+
const afterSuppressions = applyInlineSuppressions({ findings: allFindings });
|
|
171
|
+
|
|
172
|
+
// ── Apply config filters (excluded rules, paths, severity threshold) ──────
|
|
173
|
+
const { remaining, excluded, belowThreshold } = applyConfigFilters(afterSuppressions, config);
|
|
174
|
+
|
|
175
|
+
// Report what was filtered (only in verbose — summarised in one line)
|
|
176
|
+
if (excluded.length > 0) {
|
|
177
|
+
// eslint-disable-next-line no-console
|
|
178
|
+
console.log(`sec-gate: filtered ${excluded.length} finding(s) by excluded rules/paths`);
|
|
179
|
+
}
|
|
180
|
+
if (belowThreshold.length > 0) {
|
|
181
|
+
// eslint-disable-next-line no-console
|
|
182
|
+
console.log(`sec-gate: filtered ${belowThreshold.length} finding(s) below severity threshold (${config.severity_threshold})`);
|
|
183
|
+
}
|
|
103
184
|
|
|
104
|
-
|
|
185
|
+
// ── Block or pass ─────────────────────────────────────────────────────────
|
|
186
|
+
if (remaining.length > 0) {
|
|
105
187
|
// eslint-disable-next-line no-console
|
|
106
188
|
console.log('\nsec-gate: SECURITY FINDINGS (commit blocked):');
|
|
107
|
-
for (const f of
|
|
189
|
+
for (const f of remaining) console.log(formatFinding(f));
|
|
190
|
+
|
|
191
|
+
// Show hint about severity threshold if there are lower-severity findings
|
|
192
|
+
if (belowThreshold.length > 0 && config.severity_threshold === 'all') {
|
|
193
|
+
// eslint-disable-next-line no-console
|
|
194
|
+
console.log('\n TIP: Set severity_threshold in .sec-gate.yml to only block on high/critical.');
|
|
195
|
+
}
|
|
196
|
+
|
|
108
197
|
process.exit(1);
|
|
109
198
|
}
|
|
110
199
|
|
|
111
|
-
// ── Success summary
|
|
200
|
+
// ── Success summary ───────────────────────────────────────────────────────
|
|
112
201
|
const checks = [];
|
|
113
202
|
if (semgrepTargets.length > 0) {
|
|
114
203
|
checks.push(`SAST (${semgrepTargets.length} file${semgrepTargets.length > 1 ? 's' : ''})`);
|
|
115
204
|
}
|
|
116
|
-
if (depChanged || !staged) {
|
|
205
|
+
if (config.sca !== false && (depChanged || !staged)) {
|
|
117
206
|
const fs = require('fs');
|
|
118
207
|
const nodeLockfilesCheck = ['pnpm-lock.yaml', 'package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock'];
|
|
119
208
|
const foundLock = nodeLockfilesCheck.find((lf) => fs.existsSync(lf));
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// security-scan: disable rule-id: path-join-resolve-traversal reason: repoRoot comes from git rev-parse, not user input
|
|
4
|
+
// security-scan: disable rule-id: detect-non-literal-fs-filename reason: repoRoot comes from git rev-parse, not user input
|
|
5
|
+
// security-scan: disable rule-id: detect-non-literal-regexp reason: patterns come from the config file written by the developer, not from end users
|
|
6
|
+
// security-scan: disable rule-id: prototype-pollution reason: result[key] assignment is parsing a config file where keys are validated against a known whitelist of fields
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* sec-gate config loader
|
|
10
|
+
*
|
|
11
|
+
* Reads .sec-gate.yml (or .sec-gate.yaml / sec-gate.config.js) from the
|
|
12
|
+
* repo root and merges it with built-in defaults.
|
|
13
|
+
*
|
|
14
|
+
* Config file example (.sec-gate.yml):
|
|
15
|
+
* ─────────────────────────────────────
|
|
16
|
+
* severity_threshold: high # block only on: critical, high, medium, low, all (default: all)
|
|
17
|
+
* exclude_rules: # rule IDs to never report
|
|
18
|
+
* - path-join-resolve-traversal
|
|
19
|
+
* - detect-non-literal-regexp
|
|
20
|
+
* exclude_paths: # glob patterns to skip
|
|
21
|
+
* - "**\/__tests__\/**"
|
|
22
|
+
* - "**\/mocks\/**"
|
|
23
|
+
* - "**\/fixtures\/**"
|
|
24
|
+
* sca: true # enable/disable SCA (default: true)
|
|
25
|
+
* custom_rules: true # enable/disable custom rules (default: true)
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Severity ordering — higher index = more severe
|
|
33
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
+
const SEVERITY_ORDER = ['low', 'medium', 'high', 'critical'];
|
|
35
|
+
|
|
36
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
// Built-in defaults
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
const DEFAULTS = {
|
|
40
|
+
// Block commit on any finding regardless of severity
|
|
41
|
+
severity_threshold: 'all',
|
|
42
|
+
|
|
43
|
+
// Rules excluded by default — these have very high false positive rates
|
|
44
|
+
// and rarely indicate real vulnerabilities in typical codebases.
|
|
45
|
+
// Developers can re-enable them by setting exclude_rules: [] in their config.
|
|
46
|
+
exclude_rules: [
|
|
47
|
+
'path-join-resolve-traversal', // flags ANY variable in path.join — ~75% FP rate
|
|
48
|
+
'detect-non-literal-regexp', // flags RegExp(var) even with hardcoded sources
|
|
49
|
+
'detect-non-literal-fs-filename' // flags ANY variable in fs calls — ~70% FP rate
|
|
50
|
+
],
|
|
51
|
+
|
|
52
|
+
// Paths excluded from scanning by default
|
|
53
|
+
exclude_paths: [
|
|
54
|
+
'**/__tests__/**',
|
|
55
|
+
'**/*.test.js',
|
|
56
|
+
'**/*.test.ts',
|
|
57
|
+
'**/*.spec.js',
|
|
58
|
+
'**/*.spec.ts',
|
|
59
|
+
'**/test/**',
|
|
60
|
+
'**/tests/**',
|
|
61
|
+
'**/mocks/**',
|
|
62
|
+
'**/fixtures/**',
|
|
63
|
+
'**/vendor/**',
|
|
64
|
+
'**/node_modules/**'
|
|
65
|
+
],
|
|
66
|
+
|
|
67
|
+
sca: true,
|
|
68
|
+
custom_rules: true
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
72
|
+
// Simple YAML parser (only handles the subset we need — no external dep)
|
|
73
|
+
// Supports: string values, boolean values, string arrays
|
|
74
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
function parseYaml(text) {
|
|
76
|
+
const result = {};
|
|
77
|
+
let currentKey = null;
|
|
78
|
+
let currentArray = null;
|
|
79
|
+
|
|
80
|
+
for (const raw of text.split('\n')) {
|
|
81
|
+
const line = raw.replace(/#.*$/, '').trimEnd(); // strip comments
|
|
82
|
+
if (!line.trim()) continue;
|
|
83
|
+
|
|
84
|
+
// Array item: " - value"
|
|
85
|
+
if (/^\s+-\s+/.test(line) && currentKey && currentArray !== null) {
|
|
86
|
+
const val = line.replace(/^\s+-\s+/, '').replace(/^['"]|['"]$/g, '').trim();
|
|
87
|
+
currentArray.push(val);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Key-value: "key: value" or "key:" (start of array)
|
|
92
|
+
const kvMatch = line.match(/^(\w+):\s*(.*)?$/);
|
|
93
|
+
if (kvMatch) {
|
|
94
|
+
if (currentKey && currentArray !== null) {
|
|
95
|
+
result[currentKey] = currentArray;
|
|
96
|
+
}
|
|
97
|
+
currentKey = kvMatch[1].trim();
|
|
98
|
+
const rawVal = (kvMatch[2] || '').trim().replace(/^['"]|['"]$/g, '');
|
|
99
|
+
|
|
100
|
+
if (rawVal === '') {
|
|
101
|
+
// Start of array block
|
|
102
|
+
currentArray = [];
|
|
103
|
+
} else if (rawVal === 'true') {
|
|
104
|
+
result[currentKey] = true;
|
|
105
|
+
currentKey = null;
|
|
106
|
+
currentArray = null;
|
|
107
|
+
} else if (rawVal === 'false') {
|
|
108
|
+
result[currentKey] = false;
|
|
109
|
+
currentKey = null;
|
|
110
|
+
currentArray = null;
|
|
111
|
+
} else {
|
|
112
|
+
result[currentKey] = rawVal;
|
|
113
|
+
currentKey = null;
|
|
114
|
+
currentArray = null;
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Flush last array
|
|
121
|
+
if (currentKey && currentArray !== null) {
|
|
122
|
+
result[currentKey] = currentArray;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
// Load and merge config
|
|
130
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
131
|
+
function loadConfig(repoRoot) {
|
|
132
|
+
const candidates = [
|
|
133
|
+
path.join(repoRoot, '.sec-gate.yml'),
|
|
134
|
+
path.join(repoRoot, '.sec-gate.yaml'),
|
|
135
|
+
path.join(repoRoot, 'sec-gate.config.yml')
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
let userConfig = {};
|
|
139
|
+
|
|
140
|
+
for (const candidate of candidates) {
|
|
141
|
+
if (fs.existsSync(candidate)) {
|
|
142
|
+
try {
|
|
143
|
+
const text = fs.readFileSync(candidate, 'utf8');
|
|
144
|
+
userConfig = parseYaml(text);
|
|
145
|
+
// eslint-disable-next-line no-console
|
|
146
|
+
console.log(`sec-gate: loaded config from ${path.basename(candidate)}`);
|
|
147
|
+
break;
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// eslint-disable-next-line no-console
|
|
150
|
+
console.warn(`sec-gate: warning — could not parse ${candidate}: ${err.message}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Merge: user config overrides defaults
|
|
156
|
+
// For arrays, user config REPLACES defaults (not merges), so teams have full control
|
|
157
|
+
const merged = {
|
|
158
|
+
severity_threshold: userConfig.severity_threshold || DEFAULTS.severity_threshold,
|
|
159
|
+
exclude_rules: Array.isArray(userConfig.exclude_rules)
|
|
160
|
+
? userConfig.exclude_rules
|
|
161
|
+
: DEFAULTS.exclude_rules,
|
|
162
|
+
exclude_paths: Array.isArray(userConfig.exclude_paths)
|
|
163
|
+
? userConfig.exclude_paths
|
|
164
|
+
: DEFAULTS.exclude_paths,
|
|
165
|
+
sca: userConfig.sca !== undefined ? userConfig.sca : DEFAULTS.sca,
|
|
166
|
+
custom_rules: userConfig.custom_rules !== undefined
|
|
167
|
+
? userConfig.custom_rules
|
|
168
|
+
: DEFAULTS.custom_rules
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
return merged;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
175
|
+
// Severity check — should this finding be blocked given the threshold?
|
|
176
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
177
|
+
function meetsThreshold(findingSeverity, threshold) {
|
|
178
|
+
if (threshold === 'all') return true;
|
|
179
|
+
|
|
180
|
+
const findingLevel = SEVERITY_ORDER.indexOf((findingSeverity || 'low').toLowerCase());
|
|
181
|
+
const thresholdLevel = SEVERITY_ORDER.indexOf((threshold || 'all').toLowerCase());
|
|
182
|
+
|
|
183
|
+
if (thresholdLevel === -1) return true; // unknown threshold → block everything
|
|
184
|
+
if (findingLevel === -1) return true; // unknown severity → be safe, block it
|
|
185
|
+
|
|
186
|
+
return findingLevel >= thresholdLevel;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
190
|
+
// Path exclusion check
|
|
191
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
192
|
+
function isExcludedPath(filePath, excludePatterns) {
|
|
193
|
+
if (!excludePatterns || excludePatterns.length === 0) return false;
|
|
194
|
+
|
|
195
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
196
|
+
|
|
197
|
+
for (const pattern of excludePatterns) {
|
|
198
|
+
// Convert glob to simple regex:
|
|
199
|
+
// **/ matches any directory depth
|
|
200
|
+
// * matches anything except /
|
|
201
|
+
const regexStr = pattern
|
|
202
|
+
.replace(/\\/g, '/')
|
|
203
|
+
.replace(/\./g, '\\.')
|
|
204
|
+
.replace(/\*\*\//g, '(?:.+/)?')
|
|
205
|
+
.replace(/\*/g, '[^/]*');
|
|
206
|
+
|
|
207
|
+
const re = new RegExp(`(^|/)${regexStr}(/|$)`);
|
|
208
|
+
if (re.test(normalized)) return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = { loadConfig, meetsThreshold, isExcludedPath, SEVERITY_ORDER };
|