@synergyerp/backend-standards 1.3.1 → 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 CHANGED
@@ -4,5 +4,8 @@
4
4
  # Detect --no-verify bypass patterns
5
5
  sh "$(dirname -- "$0")/../scripts/detect-no-verify.sh"
6
6
 
7
+ echo "šŸ” Running security check..."
8
+ pnpm check-security
9
+
7
10
  echo "šŸ” Running lint-staged..."
8
11
  pnpm exec lint-staged
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
@@ -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 | When | Purpose |
642
- | --------------- | ------------------- | ------------ | ------------------------------ |
643
- | L1: Editor | ESLint + Prettier | Every save | Immediate developer feedback |
644
- | L2: Pre-commit | Husky + lint-staged | Every commit | Block bad code locally |
645
- | L3: CI Pipeline | GitHub Actions | Every PR | Enforce standards before merge |
646
- | L4: CD Pipeline | Smoke Tests | Every deploy | Verify runtime integrity |
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 Anti-Bypass (`--no-verify`) Protection
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.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);