@synergyerp/backend-standards 1.4.0 ā 1.5.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/.husky/pre-commit +3 -0
- package/.husky/pre-push +1 -1
- package/BACKEND_STANDARDS.md +57 -10
- package/package.json +5 -1
- package/scripts/check-security.mjs +244 -0
package/.husky/pre-commit
CHANGED
package/.husky/pre-push
CHANGED
|
@@ -5,4 +5,4 @@
|
|
|
5
5
|
sh "$(dirname -- "$0")/../scripts/detect-no-verify.sh"
|
|
6
6
|
|
|
7
7
|
echo "š Running pre-push quality checks..."
|
|
8
|
-
pnpm lint && pnpm check-modularization && pnpm check-types && pnpm test:ci
|
|
8
|
+
pnpm lint && pnpm check-security && pnpm check-modularization && pnpm check-types && pnpm test:ci
|
package/BACKEND_STANDARDS.md
CHANGED
|
@@ -630,6 +630,33 @@ app.use(
|
|
|
630
630
|
);
|
|
631
631
|
```
|
|
632
632
|
|
|
633
|
+
### 10.7 Automated Security Enforcement
|
|
634
|
+
|
|
635
|
+
Security standards are enforced automatically by the `check-security` hook (`scripts/check-security.mjs`). The hook scans the consuming project's `src/` directory and fails if any of the following are detected:
|
|
636
|
+
|
|
637
|
+
| Rule | What is checked |
|
|
638
|
+
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
639
|
+
| **No raw SQL** | `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER` inside template literals (allowed in `.sql` files and `migrations/` / `seeds/` directories). |
|
|
640
|
+
| **No hardcoded secrets** | `api_key`, `password`, `secret`, `token` (and common variants) assigned to string literals. Environment variable references (`process.env.*`, `import.meta.env.*`) are allowed. |
|
|
641
|
+
| **Response masking** | At least one source file must contain `[REDACTED]` or a `mask()` / `maskSensitive()` function. |
|
|
642
|
+
| **Rate limiting** | At least one source file must use `express-rate-limit` or a `rateLimit()` call. |
|
|
643
|
+
| **Helmet** | At least one source file must call `helmet()`. |
|
|
644
|
+
| **CORS whitelist** | `origin: '*'` or `cors()` without a whitelist is blocked. |
|
|
645
|
+
| **Input validation** | At least one source file must use `zod`, `class-validator`, or `joi`. |
|
|
646
|
+
| **No exposed auth tokens** | Responses must not return `token`, `authorization`, `accessToken`, `refreshToken`, `api_key`, or `secret` fields in the response body. |
|
|
647
|
+
|
|
648
|
+
Add the script to the consuming project's `package.json`:
|
|
649
|
+
|
|
650
|
+
```json
|
|
651
|
+
{
|
|
652
|
+
"scripts": {
|
|
653
|
+
"check-security": "node scripts/check-security.mjs"
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
The `backend-standards` package also exposes a binary (`check-backend-security`) so consuming projects can run it via `npx` / `pnpm exec`.
|
|
659
|
+
|
|
633
660
|
---
|
|
634
661
|
|
|
635
662
|
## 11. Enforcement: Husky, Git Hooks & CI/CD Validation
|
|
@@ -638,18 +665,18 @@ app.use(
|
|
|
638
665
|
|
|
639
666
|
Every standard must be machine-enforced to be effective.
|
|
640
667
|
|
|
641
|
-
| Layer | Tool
|
|
642
|
-
| --------------- |
|
|
643
|
-
| L1: Editor | ESLint + Prettier
|
|
644
|
-
| L2: Pre-commit | Husky + lint-staged | Every commit | Block bad code locally
|
|
645
|
-
| L3: CI Pipeline | GitHub Actions
|
|
646
|
-
| L4: CD Pipeline | Smoke Tests
|
|
668
|
+
| Layer | Tool | When | Purpose |
|
|
669
|
+
| --------------- | -------------------------------------- | ------------ | -------------------------------------------- |
|
|
670
|
+
| L1: Editor | ESLint + Prettier | Every save | Immediate developer feedback |
|
|
671
|
+
| L2: Pre-commit | Husky + `check-security` + lint-staged | Every commit | Block bad code & security violations locally |
|
|
672
|
+
| L3: CI Pipeline | GitHub Actions | Every PR | Enforce standards before merge |
|
|
673
|
+
| L4: CD Pipeline | Smoke Tests | Every deploy | Verify runtime integrity |
|
|
647
674
|
|
|
648
675
|
### 11.2 Required Git Hooks (Husky)
|
|
649
676
|
|
|
650
|
-
- **pre-commit**: Runs `lint-staged` (ESLint + Prettier).
|
|
677
|
+
- **pre-commit**: Runs `check-security` then `lint-staged` (ESLint + Prettier).
|
|
651
678
|
- **commit-msg**: Validates commit messages using `commitlint`.
|
|
652
|
-
- **pre-push**: Runs linting, full test suite, type checking, **and modularization validation**.
|
|
679
|
+
- **pre-push**: Runs linting, `check-security`, full test suite, type checking, **and modularization validation**.
|
|
653
680
|
|
|
654
681
|
### 11.3 Pre-push Hook with Modularization Check
|
|
655
682
|
|
|
@@ -659,7 +686,7 @@ Every standard must be machine-enforced to be effective.
|
|
|
659
686
|
. "$(dirname -- "$0")/_/husky.sh"
|
|
660
687
|
|
|
661
688
|
echo "Running quality checks..."
|
|
662
|
-
pnpm lint && pnpm check-modularization && pnpm check-types && pnpm test:ci
|
|
689
|
+
pnpm lint && pnpm check-security && pnpm check-modularization && pnpm check-types && pnpm test:ci
|
|
663
690
|
```
|
|
664
691
|
|
|
665
692
|
### 11.4 Modularization Validation Script
|
|
@@ -756,7 +783,23 @@ console.log(
|
|
|
756
783
|
if (errors > 0) process.exit(1);
|
|
757
784
|
```
|
|
758
785
|
|
|
759
|
-
### 11.5
|
|
786
|
+
### 11.5 Security Validation Script
|
|
787
|
+
|
|
788
|
+
Add the security check to the consuming project's `package.json`:
|
|
789
|
+
|
|
790
|
+
```json
|
|
791
|
+
{
|
|
792
|
+
"scripts": {
|
|
793
|
+
"check-security": "node scripts/check-security.mjs"
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
Create `scripts/check-security.mjs` (or use the one provided by `@synergyerp/backend-standards` / its `check-backend-security` binary). The script scans the consuming project's `src/` directory and exits with code `1` when any of the violations listed in [Section 10.7](#107-automated-security-enforcement) are found.
|
|
799
|
+
|
|
800
|
+
The script is pure ESM and only uses Node.js built-ins (`fs` and `path`), so it is compatible with any backend project without extra dependencies.
|
|
801
|
+
|
|
802
|
+
### 11.6 Anti-Bypass (`--no-verify`) Protection
|
|
760
803
|
|
|
761
804
|
The `git commit --no-verify` flag skips local hooks. Since CI runs the same checks independently, `--no-verify` only delays validation ā it never bypasses it.
|
|
762
805
|
|
|
@@ -767,6 +810,9 @@ The `git commit --no-verify` flag skips local hooks. Since CI runs the same chec
|
|
|
767
810
|
- name: Lint (catches --no-verify bypass)
|
|
768
811
|
run: pnpm lint
|
|
769
812
|
|
|
813
|
+
- name: Check Security (catches --no-verify bypass)
|
|
814
|
+
run: pnpm check-security
|
|
815
|
+
|
|
770
816
|
- name: Check Modularization (catches --no-verify bypass)
|
|
771
817
|
run: pnpm check-modularization
|
|
772
818
|
|
|
@@ -888,6 +934,7 @@ Standard `package.json` scripts:
|
|
|
888
934
|
"test": "vitest",
|
|
889
935
|
"lint": "eslint .",
|
|
890
936
|
"check-modularization": "node scripts/check-modularization.mjs",
|
|
937
|
+
"check-security": "node scripts/check-security.mjs",
|
|
891
938
|
"db:migrate": "prisma migrate deploy"
|
|
892
939
|
}
|
|
893
940
|
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synergyerp/backend-standards",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "SynergyERP backend standards ā ESLint, Prettier, commitlint, Husky, TypeScript configs, modularization enforcement, and PR templates.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "eslint.config.js",
|
|
8
|
+
"bin": {
|
|
9
|
+
"check-backend-security": "scripts/check-security.mjs"
|
|
10
|
+
},
|
|
8
11
|
"publishConfig": {
|
|
9
12
|
"registry": "https://registry.npmjs.org",
|
|
10
13
|
"access": "public"
|
|
@@ -27,6 +30,7 @@
|
|
|
27
30
|
"lint": "eslint .",
|
|
28
31
|
"format": "prettier --write .",
|
|
29
32
|
"format:check": "prettier --check .",
|
|
33
|
+
"check-security": "node scripts/check-security.mjs",
|
|
30
34
|
"check-modularization": "node scripts/check-modularization.mjs; exit 0",
|
|
31
35
|
"check-types": "tsc --noEmit; exit 0",
|
|
32
36
|
"test:ci": "exit 0",
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Backend Security Enforcement Hook
|
|
5
|
+
*
|
|
6
|
+
* Scans the consuming project's src/ directory for common backend security
|
|
7
|
+
* violations and fails if any are found.
|
|
8
|
+
*
|
|
9
|
+
* Enforces:
|
|
10
|
+
* 1. No raw SQL in template literals (outside .sql files / migration dirs).
|
|
11
|
+
* 2. No hardcoded secrets (api_key, password, secret, token string literals).
|
|
12
|
+
* 3. Response masking must be implemented (e.g., [REDACTED] or mask function).
|
|
13
|
+
* 4. Rate limiting must be used (express-rate-limit or similar).
|
|
14
|
+
* 5. Helmet must be used for security headers.
|
|
15
|
+
* 6. CORS must not use wildcard origins.
|
|
16
|
+
* 7. Input validation must be used (zod or class-validator).
|
|
17
|
+
* 8. Authorization / tokens must not be returned in response bodies.
|
|
18
|
+
*
|
|
19
|
+
* See: BACKEND_STANDARDS.md Sections 10 & 11
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
23
|
+
import { extname, join, relative } from 'path';
|
|
24
|
+
|
|
25
|
+
const PROJECT_ROOT = process.cwd();
|
|
26
|
+
const SRC_DIR = join(PROJECT_ROOT, 'src');
|
|
27
|
+
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']);
|
|
28
|
+
|
|
29
|
+
const errors = [];
|
|
30
|
+
|
|
31
|
+
function logError(msg) {
|
|
32
|
+
console.error(` ā ERROR: ${msg}`);
|
|
33
|
+
errors.push(msg);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isSourceFile(file) {
|
|
37
|
+
return SOURCE_EXTENSIONS.has(extname(file));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getAllSourceFiles(dir) {
|
|
41
|
+
const result = [];
|
|
42
|
+
if (!existsSync(dir)) return result;
|
|
43
|
+
|
|
44
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
const path = join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) {
|
|
47
|
+
result.push(...getAllSourceFiles(path));
|
|
48
|
+
} else if (entry.isFile() && isSourceFile(path)) {
|
|
49
|
+
result.push(path);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function stripComments(text) {
|
|
56
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/\/\/.*$/gm, ' ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getLine(text, index) {
|
|
60
|
+
return text.substring(0, index).split('\n').length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rel(file) {
|
|
64
|
+
return relative(PROJECT_ROOT, file);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
// 1. Raw SQL in template literals
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
const SQL_KEYWORDS = ['SELECT', 'INSERT', 'UPDATE', 'DELETE', 'DROP', 'ALTER'];
|
|
71
|
+
const SQL_TEMPLATE_ALLOWED_DIRS = ['migrations', 'migration', 'seeds', 'seed'];
|
|
72
|
+
|
|
73
|
+
function isSQLAllowedPath(file) {
|
|
74
|
+
const lower = file.toLowerCase().replace(/\\/g, '/');
|
|
75
|
+
return lower.endsWith('.sql') || SQL_TEMPLATE_ALLOWED_DIRS.some((d) => lower.includes(`/${d}/`));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function checkRawSQL(file, content) {
|
|
79
|
+
if (isSQLAllowedPath(file)) return;
|
|
80
|
+
|
|
81
|
+
const templateRegex = /`([\s\S]*?)`/g;
|
|
82
|
+
let match;
|
|
83
|
+
while ((match = templateRegex.exec(content)) !== null) {
|
|
84
|
+
const literal = match[1];
|
|
85
|
+
const strippedLiteral = stripComments(literal);
|
|
86
|
+
for (const keyword of SQL_KEYWORDS) {
|
|
87
|
+
if (new RegExp(`\\b${keyword}\\b`, 'i').test(strippedLiteral)) {
|
|
88
|
+
logError(
|
|
89
|
+
`Raw SQL detected in ${rel(file)} (line ${getLine(content, match.index)}): ${keyword} inside template literal`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// 2. Hardcoded secrets
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
const SECRET_NAMES = ['api_key', 'apikey', 'api-key', 'password', 'secret', 'token'];
|
|
100
|
+
const secretRegex = new RegExp(
|
|
101
|
+
`\\b(?:${SECRET_NAMES.join('|')})(?:[_-]?(?:key|hash|salt|token|secret))?\\s*[:=]\\s*(["'])(?!\\$\\{)(?:(?!\\1).)+\\1`,
|
|
102
|
+
'gi'
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
function isEnvVarLine(line) {
|
|
106
|
+
return /process\.env\./i.test(line) || /import\.meta\.env\./i.test(line);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function checkHardcodedSecrets(file, content) {
|
|
110
|
+
const lines = content.split('\n');
|
|
111
|
+
let match;
|
|
112
|
+
while ((match = secretRegex.exec(content)) !== null) {
|
|
113
|
+
const lineNumber = getLine(content, match.index);
|
|
114
|
+
const line = lines[lineNumber - 1] || '';
|
|
115
|
+
if (isEnvVarLine(line)) continue;
|
|
116
|
+
if (line.trim().startsWith('//')) continue;
|
|
117
|
+
|
|
118
|
+
logError(`Hardcoded secret detected in ${rel(file)} (line ${lineNumber}): ${match[0].trim()}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// 3. Response masking
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
function hasResponseMasking(file, content) {
|
|
126
|
+
return (
|
|
127
|
+
/\[REDACTED\]/i.test(content) ||
|
|
128
|
+
/\bmask\s*\(/i.test(content) ||
|
|
129
|
+
/\bmaskSensitive/i.test(content)
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// 4. Rate limiting
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
function hasRateLimiting(file, content) {
|
|
137
|
+
return (
|
|
138
|
+
/express-rate-limit/i.test(content) ||
|
|
139
|
+
/rateLimit\s*\(/i.test(content) ||
|
|
140
|
+
/rate[-_]?limit/i.test(content)
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// 5. Helmet
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
function hasHelmet(file, content) {
|
|
148
|
+
return /\bhelmet\s*\(/i.test(content) || /helmet\(/i.test(content);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// 6. CORS wildcard
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
function checkCORSWildcard(file, content) {
|
|
155
|
+
const originWildcardRegex = /origin\s*:\s*['"]\*['"]/i;
|
|
156
|
+
if (originWildcardRegex.test(content)) {
|
|
157
|
+
logError(`CORS wildcard origin detected in ${rel(file)}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const corsRegex = /cors\s*\(\s*\)/i;
|
|
161
|
+
if (corsRegex.test(content)) {
|
|
162
|
+
logError(`CORS called without explicit whitelist in ${rel(file)}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// 7. Input validation
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
function hasInputValidation(file, content) {
|
|
170
|
+
return /zod/i.test(content) || /class-validator/i.test(content) || /joi/i.test(content);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
// 8. Exposed Authorization / tokens in response bodies
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
function checkExposedAuth(file, content) {
|
|
177
|
+
const responseRegex = /res\.(?:json|send|status\([^)]*\)\.(?:json|send))\s*\(/i;
|
|
178
|
+
const authFieldRegex =
|
|
179
|
+
/['"]?(?:token|authorization|accessToken|refreshToken|api_key|apikey|secret)['"]?\s*:/i;
|
|
180
|
+
|
|
181
|
+
if (responseRegex.test(content) && authFieldRegex.test(content)) {
|
|
182
|
+
logError(`Sensitive auth/token field exposed in response body in ${rel(file)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Main
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
console.log('\nš Checking backend security standards...\n');
|
|
190
|
+
|
|
191
|
+
if (!existsSync(SRC_DIR)) {
|
|
192
|
+
console.log(' No src/ directory found. Skipping.\n');
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const files = getAllSourceFiles(SRC_DIR);
|
|
197
|
+
if (files.length === 0) {
|
|
198
|
+
console.log(' No source files found in src/. Skipping.\n');
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let maskingFound = false;
|
|
203
|
+
let rateLimitingFound = false;
|
|
204
|
+
let helmetFound = false;
|
|
205
|
+
let inputValidationFound = false;
|
|
206
|
+
|
|
207
|
+
for (const file of files) {
|
|
208
|
+
const content = readFileSync(file, 'utf-8');
|
|
209
|
+
|
|
210
|
+
checkRawSQL(file, content);
|
|
211
|
+
checkHardcodedSecrets(file, content);
|
|
212
|
+
checkCORSWildcard(file, content);
|
|
213
|
+
checkExposedAuth(file, content);
|
|
214
|
+
|
|
215
|
+
if (hasResponseMasking(file, content)) maskingFound = true;
|
|
216
|
+
if (hasRateLimiting(file, content)) rateLimitingFound = true;
|
|
217
|
+
if (hasHelmet(file, content)) helmetFound = true;
|
|
218
|
+
if (hasInputValidation(file, content)) inputValidationFound = true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!maskingFound) {
|
|
222
|
+
logError('Missing response masking (no [REDACTED] or mask() function found in src/)');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!rateLimitingFound) {
|
|
226
|
+
logError('Missing rate limiting (no express-rate-limit or rateLimit() usage found in src/)');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!helmetFound) {
|
|
230
|
+
logError('Missing helmet security headers (no helmet() usage found in src/)');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!inputValidationFound) {
|
|
234
|
+
logError('Missing input validation (no zod/class-validator/joi usage found in src/)');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
console.log(
|
|
238
|
+
`\n${errors.length === 0 ? 'ā
' : 'ā'} Backend security check complete. Errors: ${errors.length}\n`
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
if (errors.length > 0) {
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
process.exit(0);
|