Configure Semgrep with OWASP rulesets, write custom security rules for NestJS/React patterns, and integrate ESLint security plugins for real-time vulnerability detection.
Static Application Security Testing analyzes source code without executing it, finding vulnerabilities at development time. Semgrep is the modern SAST standard for TypeScript: faster than CodeQL, more configurable than Snyk Code, and producing minimal false positives when tuned correctly.
The key insight: a SAST tool producing 200 findings on a new repo will be ignored. A tool tuned to find 5 real issues per PR gets fixed. This lesson focuses on signal quality over quantity.
1# Install Semgrep
2pip install semgrep
3# or: docker run -i returntocorp/semgrep --config=auto .
4
5# Scan with official rulesets
6semgrep --config=p/nodejs # Node.js security patterns
7semgrep --config=p/secrets # API keys, tokens in code
8semgrep --config=p/owasp-top-ten # OWASP A01-A10 patterns
9
10# Combine and exclude test files
11semgrep \
12 --config=p/nodejs \
13 --config=p/secrets \
14 --config=p/owasp-top-ten \
15 --config=.semgrep/ \
16 --json --output=semgrep.json \
17 --exclude='*.test.ts' \
18 --exclude='*.spec.ts' \
19 --exclude='**/dist/**' \
20 apps/ packages/
21
22# Upload SARIF to GitHub Security tab:
23semgrep --config=p/nodejs --sarif --output=semgrep.sarif apps/ packages/
24# Then: uses: github/codeql-action/upload-sarif@v3
25# with: { sarif_file: semgrep.sarif, category: semgrep }1# .semgrep/security-rules.yml
2rules:
3 - id: prisma-queryraw-injection
4 pattern-either:
5 - pattern: prisma.$queryRawUnsafe(`...$EXPR...`)
6 - pattern: prisma.$executeRawUnsafe(`...$EXPR...`)
7 message: |
8 CRITICAL: $queryRawUnsafe with template interpolation is SQL injection.
9 Use prisma.$queryRaw tagged template literal instead.
10 languages: [typescript]
11 severity: ERROR
12 metadata: { cwe: CWE-89, owasp: A03 }
13
14 - id: dangerous-inner-html
15 patterns:
16 - pattern: dangerouslySetInnerHTML={{ __html: $X }}
17 - pattern-not: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize($X) }}
18 message: |
19 HIGH: dangerouslySetInnerHTML without DOMPurify.sanitize() — XSS risk.
20 languages: [tsx, jsx]
21 severity: ERROR
22 metadata: { cwe: CWE-79 }
23
24 - id: missing-ownership-check
25 patterns:
26 - pattern: |
27 @Get(':id')
28 async $HANDLER(@Param('id') $ID: ...) {
29 return this.$SERVICE.findById($ID);
30 }
31 - pattern-not: |
32 @Get(':id')
33 async $HANDLER(@Param('id') $ID: ..., @CurrentUser() $USER: ...) { ... }
34 message: GET by ID endpoint without ownership check — possible IDOR.
35 languages: [typescript]
36 severity: WARNING
37
38 - id: hardcoded-secret
39 pattern-either:
40 - pattern: $KEY = "sk_live_$VALUE"
41 - pattern: $KEY = "ghp_$VALUE"
42 message: CRITICAL: Hardcoded secret — move to environment variable.
43 languages: [typescript, javascript]
44 severity: ERROR
45 metadata: { cwe: CWE-798 }1npm install --save-dev eslint-plugin-security1// eslint.config.js
2import security from 'eslint-plugin-security';
3
4export default [
5 security.configs.recommended,
6 {
7 rules: {
8 'security/detect-eval-with-expression': 'error',
9 'security/detect-child-process': 'error',
10 'security/detect-non-literal-fs-filename': 'warn',
11 'security/detect-possible-timing-attacks': 'warn',
12 'security/detect-pseudoRandomBytes': 'warn',
13 'no-eval': 'error',
14 'no-implied-eval': 'error',
15 'no-new-func': 'error',
16 }
17 }
18];What eslint-plugin-security catches:
1const filename = req.params.filename;
2fs.readFile(filename, callback); // WARN: non-literal-fs-filename
3
4if (secret === userInput) { /* ... */ } // WARN: timing attack
5
6eval(userCode); // ERROR: eval-with-expression
7
8// Safe patterns:
9const resolved = path.resolve('/safe/dir', filename);
10if (!resolved.startsWith('/safe/dir/')) throw new ForbiddenException();A SAST tool with a 30% false positive rate will be ignored by developers. Tune aggressively.
1// Suppress a specific finding with an inline comment
2const data = unsafeBuffer.readUInt32LE(0); // nosemgrep: detect-buffer-noassert
3// Add justification:
4// nosemgrep: detect-buffer-noassert -- legacy binary protocol, offset validated above1# .semgrep-ignore — suppress by file pattern
2# Format: pattern rule-id
3apps/api/src/generated/: # generated code
4 - security/detect-object-injectionMetrics to track:
Rule quality check: if a rule has >30% false positive rate:
1# .github/workflows/semgrep.yml
2name: SAST - Semgrep
3on:
4 pull_request:
5jobs:
6 semgrep:
7 runs-on: ubuntu-latest
8 container: { image: returntocorp/semgrep }
9 steps:
10 - uses: actions/checkout@v4
11 - name: Run Semgrep
12 run: |
13 semgrep \
14 --config=p/nodejs --config=p/secrets \
15 --config=.semgrep/ \
16 --json --output=semgrep.json \
17 --exclude='*.test.ts' apps/ packages/ || true
18
19 - name: Upload SARIF
20 uses: github/codeql-action/upload-sarif@v3
21 if: always()
22 with:
23 sarif_file: semgrep.json
24 category: semgrep
25 continue-on-error: true
26
27 - name: Fail on errors
28 run: |
29 node -e "
30 const r = require('./semgrep.json');
31 const errors = (r.results || []).filter(f => f.extra.severity === 'ERROR');
32 if (errors.length > 0) {
33 errors.forEach(e => console.error('[ERROR]', e.check_id, 'in', e.path + ':' + e.start.line));
34 process.exit(1);
35 }
36 console.log('No SAST errors found');
37 "Production Semgrep rules covering SQL injection, XSS, IDOR, and hardcoded secrets specific to a NestJS/Next.js monorepo.
1# .semgrep/security-rules.yml
2# Custom Semgrep rules for DeveloperBootstrap monorepo
3# Run: semgrep --config=.semgrep/ apps/ packages/
4
5rules:
6 # ─── Critical: Code Injection ──────────────────────────────────────────────
7 - id: prisma-queryraw-injection
8 pattern-either:
9 - pattern: prisma.$queryRawUnsafe(`...$EXPR...`)
10 - pattern: prisma.$executeRawUnsafe(`...$EXPR...`)
11 message: CRITICAL - Use prisma.$queryRaw tagged template literal instead
12 languages: [typescript]
13 severity: ERROR
14 metadata: { cwe: CWE-89, owasp: A03 }
15
16 - id: eval-user-input
17 pattern-either:
18 - pattern: eval($X)
19 - pattern: new Function($X)
20 message: CRITICAL - eval/new Function enables code injection
21 languages: [typescript, javascript]
22 severity: ERROR
23 metadata: { cwe: CWE-95 }
24
25 # ─── High: XSS and Path Traversal ──────────────────────────────────────────
26 - id: dangerous-innerhtml-no-sanitize
27 patterns:
28 - pattern: dangerouslySetInnerHTML={{ __html: $X }}
29 - pattern-not: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize($X) }}
30 - pattern-not: dangerouslySetInnerHTML={{ __html: sanitizeHtml($X) }}
31 message: HIGH - dangerouslySetInnerHTML without sanitization (XSS risk)
32 languages: [tsx, jsx]
33 severity: ERROR
34 metadata: { cwe: CWE-79 }
35
36 - id: path-traversal-risk
37 patterns:
38 - pattern: path.join($BASE, $USER_INPUT)
39 - pattern-not: |
40 const $R = path.resolve($BASE, $USER_INPUT);
41 if (!$R.startsWith($BASE)) { ... }
42 message: HIGH - path.join with user-controlled input enables path traversal
43 languages: [typescript]
44 severity: ERROR
45 metadata: { cwe: CWE-22 }
46
47 # ─── Medium: Missing Security Controls ─────────────────────────────────────
48 - id: nestjs-post-no-throttle
49 patterns:
50 - pattern: |
51 @Post('login')
52 async $H(...) { ... }
53 - pattern-not: |
54 @UseGuards(ThrottlerGuard)
55 @Post('login')
56 async $H(...) { ... }
57 message: MEDIUM - POST /login missing @UseGuards(ThrottlerGuard)
58 languages: [typescript]
59 severity: WARNING
60
61 # ─── Info: Hardcoded Secrets ─────────────────────────────────────────────────
62 - id: hardcoded-stripe-key
63 pattern-either:
64 - pattern: $K = "sk_live_$V"
65 - pattern: $K = "sk_test_$V"
66 message: CRITICAL - Hardcoded Stripe key — use environment variable
67 languages: [typescript, javascript]
68 severity: ERROR
69 metadata: { cwe: CWE-798 }
70
71 - id: hardcoded-github-token
72 pattern: $K = "ghp_$V"
73 message: CRITICAL - Hardcoded GitHub token — use environment variable
74 languages: [typescript, javascript]
75 severity: ERROR
76 metadata: { cwe: CWE-798 }These custom rules target patterns specific to a NestJS/Next.js monorepo. The prisma-queryraw-injection rule catches the most dangerous Prisma misuse. The dangerous-innerhtml-no-sanitize rule allows the sanitized pattern but flags unsanitized usage. The nestjs-post-no-throttle rule verifies ThrottlerGuard is applied to login routes. Each rule includes CWE and OWASP category metadata for traceability. Run with: semgrep --config=.semgrep/security-rules.yml apps/ packages/
The next lesson covers DAST with OWASP ZAP — running baseline, full, and API scan modes against staging, interpreting reports, and integrating ZAP into CI pipelines.
Your progress is saved in this browser, on this device only.