start-vibing-stacks 2.6.0 → 2.7.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/README.md +83 -135
- package/dist/index.js +16 -2
- package/dist/migrate.d.ts +27 -0
- package/dist/migrate.js +217 -0
- package/dist/setup.js +10 -0
- package/package.json +1 -1
- package/stacks/_shared/agents/claude-md-compactor.md +1 -0
- package/stacks/_shared/agents/commit-manager.md +1 -0
- package/stacks/_shared/agents/documenter.md +1 -0
- package/stacks/_shared/agents/domain-updater.md +1 -0
- package/stacks/_shared/agents/research-web.md +1 -0
- package/stacks/_shared/agents/security-auditor.md +168 -0
- package/stacks/_shared/agents/tester.md +1 -0
- package/stacks/_shared/hooks/final-check.ts +205 -0
- package/stacks/_shared/hooks/stop-validator.ts +77 -1
- package/stacks/_shared/skills/accessibility-wcag22/SKILL.md +284 -0
- package/stacks/_shared/skills/ci-pipelines/SKILL.md +166 -0
- package/stacks/_shared/skills/codebase-knowledge/SKILL.md +5 -0
- package/stacks/_shared/skills/database-migrations/SKILL.md +256 -0
- package/stacks/_shared/skills/debugging-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/docker-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/docs-tracker/SKILL.md +5 -0
- package/stacks/_shared/skills/error-handling/SKILL.md +335 -0
- package/stacks/_shared/skills/final-check/SKILL.md +74 -37
- package/stacks/_shared/skills/git-workflow/SKILL.md +5 -0
- package/stacks/_shared/skills/hook-development/SKILL.md +5 -0
- package/stacks/_shared/skills/observability/SKILL.md +351 -0
- package/stacks/_shared/skills/performance-patterns/SKILL.md +5 -0
- package/stacks/_shared/skills/playwright-automation/SKILL.md +5 -0
- package/stacks/_shared/skills/quality-gate/SKILL.md +5 -0
- package/stacks/_shared/skills/research-cache/SKILL.md +5 -0
- package/stacks/_shared/skills/secrets-management/SKILL.md +245 -0
- package/stacks/_shared/skills/security-baseline/SKILL.md +202 -0
- package/stacks/_shared/skills/test-coverage/SKILL.md +5 -0
- package/stacks/_shared/skills/ui-ux-audit/SKILL.md +5 -0
- package/stacks/frontend/react/skills/preline-ui/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-standards/SKILL.md +5 -0
- package/stacks/frontend/react/skills/react-ui-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/shadcn-ui/SKILL.md +5 -0
- package/stacks/frontend/react/skills/tailwind-patterns/SKILL.md +5 -0
- package/stacks/frontend/react/skills/zod-validation/SKILL.md +5 -0
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +5 -0
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +5 -0
- package/stacks/nodejs/skills/api-security-node/SKILL.md +275 -0
- package/stacks/nodejs/skills/bun-runtime/SKILL.md +5 -0
- package/stacks/nodejs/skills/mongoose-patterns/SKILL.md +5 -0
- package/stacks/nodejs/skills/nextjs-app-router/SKILL.md +5 -0
- package/stacks/nodejs/skills/trpc-api/SKILL.md +5 -0
- package/stacks/nodejs/skills/typescript-strict/SKILL.md +5 -0
- package/stacks/nodejs/stack.json +2 -1
- package/stacks/nodejs/workflows/ci.yml +90 -0
- package/stacks/nodejs/workflows/security.yml +45 -0
- package/stacks/php/skills/api-design/SKILL.md +5 -0
- package/stacks/php/skills/api-security/SKILL.md +5 -0
- package/stacks/php/skills/composer-workflow/SKILL.md +5 -0
- package/stacks/php/skills/external-api-patterns/SKILL.md +5 -0
- package/stacks/php/skills/inertia-react/SKILL.md +5 -0
- package/stacks/php/skills/laravel-inertia-i18n/SKILL.md +5 -0
- package/stacks/php/skills/laravel-octane/SKILL.md +5 -0
- package/stacks/php/skills/laravel-patterns/SKILL.md +5 -0
- package/stacks/php/skills/mariadb-octane/SKILL.md +5 -0
- package/stacks/php/skills/php-patterns/SKILL.md +5 -0
- package/stacks/php/skills/phpstan-analysis/SKILL.md +5 -0
- package/stacks/php/skills/phpunit-testing/SKILL.md +5 -0
- package/stacks/php/skills/security-scan-php/SKILL.md +5 -0
- package/stacks/php/workflows/ci.yml +106 -0
- package/stacks/php/workflows/security.yml +36 -0
- package/stacks/python/skills/api-security-python/SKILL.md +312 -0
- package/stacks/python/skills/async-patterns/SKILL.md +5 -0
- package/stacks/python/skills/django-patterns/SKILL.md +5 -0
- package/stacks/python/skills/fastapi-patterns/SKILL.md +5 -0
- package/stacks/python/skills/pydantic-validation/SKILL.md +5 -0
- package/stacks/python/skills/pytest-testing/SKILL.md +5 -0
- package/stacks/python/skills/python-patterns/SKILL.md +5 -0
- package/stacks/python/skills/python-performance/SKILL.md +5 -0
- package/stacks/python/skills/scripting-automation/SKILL.md +5 -0
- package/stacks/python/stack.json +2 -1
- package/stacks/python/workflows/ci.yml +76 -0
- package/stacks/python/workflows/security.yml +56 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: security-auditor
|
|
3
|
+
version: 1.0.0
|
|
4
|
+
description: "AUTOMATICALLY invoke when code touches auth, sessions, user data, passwords, tokens, API routes, database queries, cookies, or env vars. VETO POWER — blocks insecure code. Runs AFTER tester, BEFORE quality-gate."
|
|
5
|
+
model: sonnet
|
|
6
|
+
tools: Read, Grep, Glob, Bash
|
|
7
|
+
skills: security-baseline, secrets-management
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Security Auditor Agent
|
|
11
|
+
|
|
12
|
+
You audit code for security flaws. **You have VETO power** — when violations are found you block the workflow and require fixes.
|
|
13
|
+
|
|
14
|
+
## When You Run
|
|
15
|
+
|
|
16
|
+
After implementation and tester, **before** quality-gate and commit-manager. Always run when modified files include:
|
|
17
|
+
|
|
18
|
+
- Auth, session, login, register, password, token, JWT
|
|
19
|
+
- Route Handlers, Server Actions, controllers, API endpoints
|
|
20
|
+
- Database queries, ORM models
|
|
21
|
+
- Cookie / header / CORS / CSP configuration
|
|
22
|
+
- File uploads
|
|
23
|
+
- Anything reading `process.env` / `os.environ` / `$_ENV` / `env()`
|
|
24
|
+
|
|
25
|
+
## Step 1 — Read Stack Context
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
cat .claude/config/active-project.json
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Branch logic on `stack`:
|
|
32
|
+
- `nodejs` → load `api-security-node` skill
|
|
33
|
+
- `python` → load `api-security-python` skill
|
|
34
|
+
- `php` → load `api-security` skill
|
|
35
|
+
- always → `security-baseline`, `secrets-management`
|
|
36
|
+
|
|
37
|
+
## Step 2 — Identify Modified Files
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
git diff --name-only --diff-filter=AM HEAD
|
|
41
|
+
git diff --name-only --cached --diff-filter=AM
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Read each modified source file.
|
|
45
|
+
|
|
46
|
+
## Step 3 — Run the Audit Matrix
|
|
47
|
+
|
|
48
|
+
Apply each check below. **One violation = block.**
|
|
49
|
+
|
|
50
|
+
### A. Authn / Session
|
|
51
|
+
|
|
52
|
+
| Check | Pattern that fails |
|
|
53
|
+
|---|---|
|
|
54
|
+
| User ID from session, not body | `req.body.userId`, `request.json()["user_id"]`, `$request->input('user_id')` used for ownership |
|
|
55
|
+
| Auth gate before logic | Route handler with no `auth()` / `Depends(current_user)` / `auth:sanctum` middleware |
|
|
56
|
+
| Algorithm pinned on JWT | `jwt.verify(token)` without `algorithms` array |
|
|
57
|
+
| Token in HttpOnly cookie | `localStorage.setItem('token', ...)` or token rendered into HTML |
|
|
58
|
+
|
|
59
|
+
### B. Authz
|
|
60
|
+
|
|
61
|
+
| Check | Pattern that fails |
|
|
62
|
+
|---|---|
|
|
63
|
+
| Object-level scope | `Model.findById(id)` with no `where userId = session.user.id` |
|
|
64
|
+
| Role check on server | Role check only in client/UI |
|
|
65
|
+
| Mass assignment guard | `User.create(req.body)` without allowlist / Zod `.strict()` / Pydantic `extra="forbid"` / `$fillable` |
|
|
66
|
+
|
|
67
|
+
### C. Input Validation
|
|
68
|
+
|
|
69
|
+
| Check | Pattern that fails |
|
|
70
|
+
|---|---|
|
|
71
|
+
| Schema at boundary | Route handler reads `req.body` / `request.json()` without Zod / Pydantic / FormRequest |
|
|
72
|
+
| Strict mode | Schema present but allows extra keys |
|
|
73
|
+
| Mongo operator injection | `User.findOne({ email: req.body.email })` without coercing email to string |
|
|
74
|
+
| SQL bindings | f-string / template-literal SQL: `f"... {x} ..."`, `\`SELECT ... ${x}\``, `"... $x ..."` |
|
|
75
|
+
|
|
76
|
+
### D. Secrets / Env
|
|
77
|
+
|
|
78
|
+
Run secrets scan:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# Quick high-signal grep
|
|
82
|
+
git diff --cached -U0 \
|
|
83
|
+
| grep -E '(api[_-]?key|secret|token|password|bearer|aws_|private_key)' \
|
|
84
|
+
| grep -vE '\.env\.example|TEMPLATE|placeholder|example|<your|YOUR_|XXXX'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Also check:
|
|
88
|
+
- No `NEXT_PUBLIC_` containing `SECRET|TOKEN|PRIVATE|PASSWORD|CREDENTIAL`
|
|
89
|
+
- No hardcoded connection string with embedded password
|
|
90
|
+
- `.env` is in `.gitignore`
|
|
91
|
+
- `.env.example` exists if any env var is read
|
|
92
|
+
|
|
93
|
+
### E. Cookies
|
|
94
|
+
|
|
95
|
+
| Check | Required |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `httpOnly: true` | yes |
|
|
98
|
+
| `secure: true` in prod | yes |
|
|
99
|
+
| `sameSite` set | `lax` or `strict` |
|
|
100
|
+
|
|
101
|
+
### F. CORS / Headers
|
|
102
|
+
|
|
103
|
+
- No `origin: '*'` or `allow_origins=["*"]` combined with `credentials: true` / `allow_credentials=True`
|
|
104
|
+
- Security headers configured (Helmet / middleware): HSTS, CSP, X-Content-Type-Options, Referrer-Policy
|
|
105
|
+
|
|
106
|
+
### G. Rate Limiting
|
|
107
|
+
|
|
108
|
+
- Auth endpoints (`/login`, `/register`, `/password/reset`) have a rate limiter
|
|
109
|
+
- Webhook endpoints reject requests without verified signature
|
|
110
|
+
|
|
111
|
+
### H. Logging
|
|
112
|
+
|
|
113
|
+
- No `console.log(req.body)` / `print(request.json())` / `Log::info($request->all())`
|
|
114
|
+
- No `console.log(token)` / logging of cookies, headers, or `Authorization`
|
|
115
|
+
- No PII (email, phone, full name) in logs without redaction
|
|
116
|
+
|
|
117
|
+
### I. SSRF
|
|
118
|
+
|
|
119
|
+
- User-supplied URLs are validated against an allowlist OR private IP ranges are blocked
|
|
120
|
+
|
|
121
|
+
### J. Webhook Verification
|
|
122
|
+
|
|
123
|
+
- Stripe / GitHub / GitHub App webhooks call `constructEvent` / signature verifier **before** parsing body
|
|
124
|
+
|
|
125
|
+
## Step 4 — Report
|
|
126
|
+
|
|
127
|
+
If everything passes, output:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
✅ Security audit passed.
|
|
131
|
+
Files audited: <n>
|
|
132
|
+
Checks: A-J all green.
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
If violations are found, output:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
🛑 SECURITY AUDIT BLOCKED
|
|
139
|
+
|
|
140
|
+
<n> violation(s) found:
|
|
141
|
+
|
|
142
|
+
1. [HIGH] <file>:<line>
|
|
143
|
+
Issue: <one line>
|
|
144
|
+
Fix: <one line>
|
|
145
|
+
Reference: security-baseline §A01 (or whichever)
|
|
146
|
+
|
|
147
|
+
2. [MEDIUM] ...
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Severity:
|
|
151
|
+
- **CRITICAL** — RCE, SQLi, missing auth, secret in commit. Block immediately.
|
|
152
|
+
- **HIGH** — Authz bypass, missing CSRF, unsafe cookie. Block.
|
|
153
|
+
- **MEDIUM** — Missing rate limit, weak headers. Block.
|
|
154
|
+
- **LOW** — Minor logging concern. Warn, allow.
|
|
155
|
+
|
|
156
|
+
## Rules
|
|
157
|
+
|
|
158
|
+
1. **VETO POWER** — `domain-updater` and `commit-manager` MUST NOT run while you have unresolved CRITICAL/HIGH/MEDIUM findings.
|
|
159
|
+
2. **READ THE CODE** — never approve based on file names alone.
|
|
160
|
+
3. **NO FALSE NEGATIVES > FALSE POSITIVES** — when in doubt, flag and explain.
|
|
161
|
+
4. **CITE THE FIX** — every finding has a one-line fix and a skill reference.
|
|
162
|
+
5. **RE-RUN AFTER FIXES** — never trust "I fixed it" without re-reading the file.
|
|
163
|
+
|
|
164
|
+
## See Also
|
|
165
|
+
|
|
166
|
+
- Skill `security-baseline` — universal OWASP rules
|
|
167
|
+
- Skill `secrets-management` — env hygiene
|
|
168
|
+
- Stack-specific skill: `api-security-node` / `api-security-python` / PHP `api-security`
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Final Check — executable validator with VETO.
|
|
4
|
+
*
|
|
5
|
+
* Scans staged + unstaged source files for forbidden patterns:
|
|
6
|
+
* - debug statements (console.log, var_dump, dd, print debug)
|
|
7
|
+
* - TODO/FIXME left in code
|
|
8
|
+
* - any-typed code (TypeScript)
|
|
9
|
+
* - hardcoded secrets
|
|
10
|
+
* - test .skip / .only
|
|
11
|
+
* - dangerous functions (RCE, command injection)
|
|
12
|
+
*
|
|
13
|
+
* Exits 0 if clean, 1 if any blocking finding. Output is human-readable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'child_process';
|
|
17
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
18
|
+
import { extname, join } from 'path';
|
|
19
|
+
|
|
20
|
+
const PROJECT_DIR = process.env['CLAUDE_PROJECT_DIR'] || process.cwd();
|
|
21
|
+
const ACTIVE_PROJECT = join(PROJECT_DIR, '.claude', 'config', 'active-project.json');
|
|
22
|
+
|
|
23
|
+
let stackId = 'unknown';
|
|
24
|
+
try {
|
|
25
|
+
if (existsSync(ACTIVE_PROJECT)) {
|
|
26
|
+
stackId = JSON.parse(readFileSync(ACTIVE_PROJECT, 'utf8')).stack || 'unknown';
|
|
27
|
+
}
|
|
28
|
+
} catch {}
|
|
29
|
+
|
|
30
|
+
const STACK_EXTENSIONS: Record<string, Set<string>> = {
|
|
31
|
+
php: new Set(['.php', '.blade.php']),
|
|
32
|
+
nodejs: new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs']),
|
|
33
|
+
python: new Set(['.py']),
|
|
34
|
+
};
|
|
35
|
+
const sourceExt = STACK_EXTENSIONS[stackId] || new Set(['.ts', '.js', '.php', '.py']);
|
|
36
|
+
|
|
37
|
+
interface Finding {
|
|
38
|
+
severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
|
|
39
|
+
file: string;
|
|
40
|
+
line: number;
|
|
41
|
+
rule: string;
|
|
42
|
+
excerpt: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const findings: Finding[] = [];
|
|
46
|
+
|
|
47
|
+
function git(...args: string[]): string {
|
|
48
|
+
const r = spawnSync('git', args, { cwd: PROJECT_DIR, encoding: 'utf8' });
|
|
49
|
+
return (r.stdout || '').trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Paths that are always skipped (validator itself, generated, vendored, etc.)
|
|
53
|
+
const SKIP_PATH_RE = /(^|\/)(\.claude\/hooks|\.claude\/scripts|node_modules|dist|build|coverage|vendor|\.next|\.nuxt|venv|\.venv|stacks\/_shared\/hooks)\//;
|
|
54
|
+
|
|
55
|
+
function listFiles(): string[] {
|
|
56
|
+
const staged = git('diff', '--name-only', '--cached', '--diff-filter=ACMR').split('\n');
|
|
57
|
+
const unstaged = git('diff', '--name-only', '--diff-filter=ACMR').split('\n');
|
|
58
|
+
const untracked = git('ls-files', '--others', '--exclude-standard').split('\n');
|
|
59
|
+
const all = new Set<string>();
|
|
60
|
+
for (const f of [...staged, ...unstaged, ...untracked]) {
|
|
61
|
+
if (!f) continue;
|
|
62
|
+
if (SKIP_PATH_RE.test('/' + f)) continue;
|
|
63
|
+
if (!sourceExt.has(extname(f).toLowerCase())) continue;
|
|
64
|
+
const full = join(PROJECT_DIR, f);
|
|
65
|
+
if (!existsSync(full)) continue;
|
|
66
|
+
try {
|
|
67
|
+
if (statSync(full).isFile()) all.add(f);
|
|
68
|
+
} catch {}
|
|
69
|
+
}
|
|
70
|
+
return [...all];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Rule {
|
|
74
|
+
re: RegExp;
|
|
75
|
+
rule: string;
|
|
76
|
+
severity: Finding['severity'];
|
|
77
|
+
appliesTo?: (path: string) => boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const isTest = (p: string) => /\.(test|spec)\.[tj]sx?$|tests?\//i.test(p);
|
|
81
|
+
const isPhp = (p: string) => p.endsWith('.php');
|
|
82
|
+
const isPy = (p: string) => p.endsWith('.py');
|
|
83
|
+
const isJs = (p: string) => /\.(t|j)sx?$|\.mjs$/.test(p);
|
|
84
|
+
|
|
85
|
+
// Build dangerous-function regex via parts to avoid trivial lexical scans
|
|
86
|
+
const DANGEROUS_JS = new RegExp('\\b' + 'ev' + 'al' + '\\s*\\(');
|
|
87
|
+
const DANGEROUS_PY = new RegExp('\\b(?:' + 'ev' + 'al' + '|exec)\\s*\\(');
|
|
88
|
+
|
|
89
|
+
const RULES: Rule[] = [
|
|
90
|
+
// Debug statements
|
|
91
|
+
{ re: /\bconsole\.(log|debug|trace)\s*\(/, rule: 'console debug statement', severity: 'MEDIUM',
|
|
92
|
+
appliesTo: p => isJs(p) && !isTest(p) },
|
|
93
|
+
{ re: /\b(var_dump|dd|dump|print_r)\s*\(/, rule: 'PHP debug statement', severity: 'MEDIUM',
|
|
94
|
+
appliesTo: isPhp },
|
|
95
|
+
{ re: /^[^#]*\bprint\s*\(/m, rule: 'Python print() (use logger)', severity: 'LOW',
|
|
96
|
+
appliesTo: p => isPy(p) && !isTest(p) },
|
|
97
|
+
|
|
98
|
+
// Tests
|
|
99
|
+
{ re: /\b(it|test|describe)\.(only|skip)\s*\(/, rule: '.only / .skip in test', severity: 'HIGH',
|
|
100
|
+
appliesTo: p => /\.(test|spec)\./i.test(p) },
|
|
101
|
+
{ re: /@pytest\.mark\.skip\b/, rule: 'pytest skip marker', severity: 'MEDIUM',
|
|
102
|
+
appliesTo: isPy },
|
|
103
|
+
|
|
104
|
+
// TypeScript any
|
|
105
|
+
{ re: /:\s*any\b(?!\s*\/\*\s*ok)/, rule: 'explicit any (use unknown or proper type)', severity: 'MEDIUM',
|
|
106
|
+
appliesTo: p => /\.(ts|tsx)$/.test(p) },
|
|
107
|
+
{ re: /@ts-ignore/, rule: '@ts-ignore (use @ts-expect-error with comment)', severity: 'MEDIUM',
|
|
108
|
+
appliesTo: p => /\.(ts|tsx)$/.test(p) },
|
|
109
|
+
|
|
110
|
+
// TODO / FIXME — informational
|
|
111
|
+
{ re: /\b(TODO|FIXME|XXX|HACK)\b/, rule: 'unresolved TODO/FIXME', severity: 'LOW' },
|
|
112
|
+
|
|
113
|
+
// Secrets — high signal patterns
|
|
114
|
+
{ re: /(?:api[_-]?key|secret|token|bearer|password|aws_(?:access|secret)_key|private_key)\s*[:=]\s*["'][A-Za-z0-9/+=_\-.]{20,}["']/i,
|
|
115
|
+
rule: 'possible hardcoded secret', severity: 'CRITICAL' },
|
|
116
|
+
{ re: /(?:NEXT_PUBLIC|VITE|REACT_APP)_[A-Z_]*(?:SECRET|TOKEN|PRIVATE|PASSWORD|CREDENTIAL)/,
|
|
117
|
+
rule: 'public env var contains secret-like name', severity: 'CRITICAL' },
|
|
118
|
+
|
|
119
|
+
// Dangerous code execution
|
|
120
|
+
{ re: DANGEROUS_JS, rule: 'arbitrary code execution function — RCE risk', severity: 'HIGH',
|
|
121
|
+
appliesTo: p => isJs(p) || isPhp(p) },
|
|
122
|
+
{ re: DANGEROUS_PY, rule: 'arbitrary code execution function — RCE risk', severity: 'HIGH',
|
|
123
|
+
appliesTo: isPy },
|
|
124
|
+
{ re: /shell\s*=\s*True/, rule: 'subprocess shell=True (command injection)', severity: 'HIGH',
|
|
125
|
+
appliesTo: isPy },
|
|
126
|
+
|
|
127
|
+
// SQL string concat (rough)
|
|
128
|
+
{ re: /(SELECT|INSERT|UPDATE|DELETE)\s[^"']*?\+\s*[a-zA-Z_]/i,
|
|
129
|
+
rule: 'possible SQL string concatenation', severity: 'HIGH', appliesTo: p => isJs(p) || isPhp(p) },
|
|
130
|
+
{ re: /f["'][^"']*\b(SELECT|INSERT|UPDATE|DELETE)\b[^"']*\{/i,
|
|
131
|
+
rule: 'f-string SQL (use bind parameters)', severity: 'HIGH', appliesTo: isPy },
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
const SEVERITY_ORDER: Record<Finding['severity'], number> = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
|
|
135
|
+
const PLACEHOLDER_RE = /<\s*your[_\- ]|YOUR_[A-Z_]+|placeholder|example\.com|sk_test_|sk_xxx|xxxxxxxx/i;
|
|
136
|
+
|
|
137
|
+
function scan(file: string) {
|
|
138
|
+
const full = join(PROJECT_DIR, file);
|
|
139
|
+
let content: string;
|
|
140
|
+
try { content = readFileSync(full, 'utf8'); } catch { return; }
|
|
141
|
+
|
|
142
|
+
const lines = content.split('\n');
|
|
143
|
+
for (let i = 0; i < lines.length; i++) {
|
|
144
|
+
const line = lines[i] ?? '';
|
|
145
|
+
if (line.length > 1000) continue; // skip minified
|
|
146
|
+
if (PLACEHOLDER_RE.test(line)) continue; // example/template values
|
|
147
|
+
for (const r of RULES) {
|
|
148
|
+
if (r.appliesTo && !r.appliesTo(file)) continue;
|
|
149
|
+
if (r.re.test(line)) {
|
|
150
|
+
findings.push({
|
|
151
|
+
severity: r.severity,
|
|
152
|
+
file,
|
|
153
|
+
line: i + 1,
|
|
154
|
+
rule: r.rule,
|
|
155
|
+
excerpt: line.trim().slice(0, 160),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function main() {
|
|
163
|
+
const files = listFiles();
|
|
164
|
+
if (files.length === 0) {
|
|
165
|
+
console.log('Final check: no source files in current diff to scan.');
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const f of files) scan(f);
|
|
170
|
+
|
|
171
|
+
findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
172
|
+
|
|
173
|
+
const blocking = findings.filter(f => f.severity === 'CRITICAL' || f.severity === 'HIGH');
|
|
174
|
+
const warnings = findings.filter(f => f.severity === 'MEDIUM' || f.severity === 'LOW');
|
|
175
|
+
|
|
176
|
+
if (findings.length === 0) {
|
|
177
|
+
console.log(`Final check passed (${files.length} file${files.length === 1 ? '' : 's'} scanned, stack=${stackId}).`);
|
|
178
|
+
process.exit(0);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.log(`Final check report — stack=${stackId}, files=${files.length}\n`);
|
|
182
|
+
|
|
183
|
+
if (blocking.length > 0) {
|
|
184
|
+
console.log(`BLOCKING — ${blocking.length} critical/high finding${blocking.length === 1 ? '' : 's'}:\n`);
|
|
185
|
+
for (const f of blocking) {
|
|
186
|
+
console.log(` [${f.severity}] ${f.file}:${f.line}`);
|
|
187
|
+
console.log(` ${f.rule}`);
|
|
188
|
+
console.log(` > ${f.excerpt}`);
|
|
189
|
+
}
|
|
190
|
+
console.log('');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (warnings.length > 0) {
|
|
194
|
+
console.log(`WARNINGS — ${warnings.length} medium/low finding${warnings.length === 1 ? '' : 's'}:\n`);
|
|
195
|
+
for (const f of warnings.slice(0, 20)) {
|
|
196
|
+
console.log(` [${f.severity}] ${f.file}:${f.line} ${f.rule}`);
|
|
197
|
+
}
|
|
198
|
+
if (warnings.length > 20) console.log(` ... and ${warnings.length - 20} more`);
|
|
199
|
+
console.log('');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
process.exit(blocking.length > 0 ? 1 : 0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
main();
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* 3. CLAUDE.md not updated
|
|
10
10
|
* 4. CLAUDE.md missing required sections
|
|
11
11
|
* 5. CLAUDE.md exceeds 40k chars
|
|
12
|
+
* 6. Secret pattern detected in committed/staged files (uses gitleaks if present, fallback to regex)
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { execSync } from 'child_process';
|
|
@@ -65,6 +66,71 @@ function getModifiedFiles(): string[] {
|
|
|
65
66
|
return [...new Set([...staged, ...unstaged, ...untracked])];
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Run a command capturing stdout, stderr and exit code without throwing.
|
|
71
|
+
*/
|
|
72
|
+
function runCapture(command: string): { code: number; stdout: string; stderr: string } {
|
|
73
|
+
try {
|
|
74
|
+
const stdout = execSync(command, {
|
|
75
|
+
cwd: PROJECT_DIR,
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
78
|
+
});
|
|
79
|
+
return { code: 0, stdout: stdout.toString(), stderr: '' };
|
|
80
|
+
} catch (err: any) {
|
|
81
|
+
return {
|
|
82
|
+
code: typeof err?.status === 'number' ? err.status : 1,
|
|
83
|
+
stdout: err?.stdout?.toString?.() ?? '',
|
|
84
|
+
stderr: err?.stderr?.toString?.() ?? '',
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Scan staged + unstaged diff for secret patterns.
|
|
91
|
+
* Uses gitleaks when installed; otherwise applies a high-signal regex sweep.
|
|
92
|
+
* Returns a list of findings (empty = clean).
|
|
93
|
+
*/
|
|
94
|
+
function scanSecrets(): string[] {
|
|
95
|
+
const findings: string[] = [];
|
|
96
|
+
|
|
97
|
+
const hasGitleaks = cmd('command -v gitleaks').length > 0;
|
|
98
|
+
if (hasGitleaks) {
|
|
99
|
+
const { code, stdout, stderr } = runCapture(
|
|
100
|
+
'gitleaks detect --no-banner --redact --exit-code 1 --log-level=error'
|
|
101
|
+
);
|
|
102
|
+
if (code !== 0) {
|
|
103
|
+
const out = stdout + '\n' + stderr;
|
|
104
|
+
const lines = out
|
|
105
|
+
.split('\n')
|
|
106
|
+
.filter(l => l.includes('Finding:') || l.includes('File:') || l.includes('Secret:'));
|
|
107
|
+
if (lines.length > 0) findings.push(...lines.slice(0, 20));
|
|
108
|
+
else findings.push('gitleaks reported leaks (run `gitleaks detect --redact -v` for details)');
|
|
109
|
+
}
|
|
110
|
+
return findings;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Fallback: high-signal regex sweep over staged + unstaged diff
|
|
114
|
+
const SECRET_RE = /(?:api[_-]?key|secret|token|bearer|password|aws_(?:access|secret)_key|private_key)\s*[:=]\s*["'][A-Za-z0-9/+=_\-.]{16,}["']/i;
|
|
115
|
+
const PUBLIC_LEAK_RE = /(?:NEXT_PUBLIC|VITE|REACT_APP)_[A-Z_]*(?:SECRET|TOKEN|PRIVATE|PASSWORD|CREDENTIAL)/;
|
|
116
|
+
const PLACEHOLDER_RE = /<\s*your[_\- ]|YOUR_[A-Z_]+|placeholder|example\.com|sk_test_|sk_xxx|xxxxxxxx/i;
|
|
117
|
+
|
|
118
|
+
const diff = cmd('git diff --cached -U0') + '\n' + cmd('git diff -U0');
|
|
119
|
+
if (!diff.trim()) return findings;
|
|
120
|
+
|
|
121
|
+
const lines = diff.split('\n');
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
if (!line.startsWith('+') || line.startsWith('+++')) continue;
|
|
124
|
+
if (PLACEHOLDER_RE.test(line)) continue;
|
|
125
|
+
if (SECRET_RE.test(line) || PUBLIC_LEAK_RE.test(line)) {
|
|
126
|
+
const masked = line.replace(/(["']).{8,}\1/, '$1[REDACTED]$1').slice(0, 160);
|
|
127
|
+
findings.push(masked);
|
|
128
|
+
if (findings.length >= 5) break;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return findings;
|
|
132
|
+
}
|
|
133
|
+
|
|
68
134
|
function validate(): HookResult {
|
|
69
135
|
const branch = getBranch();
|
|
70
136
|
const isMain = branch === 'main' || branch === 'master';
|
|
@@ -132,11 +198,21 @@ function validate(): HookResult {
|
|
|
132
198
|
};
|
|
133
199
|
}
|
|
134
200
|
|
|
201
|
+
// 5. Secret scan (gitleaks or regex fallback)
|
|
202
|
+
const secrets = scanSecrets();
|
|
203
|
+
if (secrets.length > 0) {
|
|
204
|
+
return {
|
|
205
|
+
continue: true,
|
|
206
|
+
decision: 'block',
|
|
207
|
+
reason: `BLOCKED: Potential secrets detected in diff:\n${secrets.map(s => ` - ${s}`).join('\n')}\n\nRotate the credential, remove from diff, and re-run.`,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
135
211
|
// All good
|
|
136
212
|
return {
|
|
137
213
|
continue: false,
|
|
138
214
|
decision: 'approve',
|
|
139
|
-
reason: `ALL CHECKS PASSED ✅\nStack: ${stackId}\nBranch: ${branch}\nTree: Clean`,
|
|
215
|
+
reason: `ALL CHECKS PASSED ✅\nStack: ${stackId}\nBranch: ${branch}\nTree: Clean\nSecrets: clean`,
|
|
140
216
|
};
|
|
141
217
|
}
|
|
142
218
|
|