@torus-engineering/tas-kit 1.8.0 → 1.10.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.
@@ -0,0 +1,599 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * TAS Kit — Pre-commit Security Scanner
4
+ *
5
+ * Invoked by the pre-commit hook (husky or native .git/hooks).
6
+ *
7
+ * Flow (3-tier):
8
+ * Tier 1 (always, blocking): Fast regex scan of staged files — hardcoded
9
+ * secrets, AWS/Azure/GCP keys, private keys.
10
+ * Tier 2 (if on PATH, blocking): External scanner — gitleaks / trufflehog.
11
+ * Runs only when found on PATH.
12
+ * Tier 3 (opt-in, LOCAL-ONLY, REPORT-ONLY):
13
+ * AI deep scan via claude / codex / gemini.
14
+ * Triggered only by `deep_scan_on_every_commit:
15
+ * true` in tas.yaml. Writes findings to
16
+ * docs/security-report.md. Never blocks the
17
+ * commit. Intended for local runs (using a
18
+ * dev's personal Claude Code subscription —
19
+ * no API charges). Dev can choose to include
20
+ * the report file in their PR if they want
21
+ * reviewers to see the deep-scan output.
22
+ *
23
+ * Exit 1 only if Tier 1/2 findings match `block_on`. Tier 3 never blocks.
24
+ *
25
+ * Bypass: SKIP_SECURITY_SCAN=1 git commit ... OR git commit --no-verify
26
+ *
27
+ * This script is intentionally dependency-free (vanilla Node only).
28
+ */
29
+
30
+ 'use strict';
31
+
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+ const { execSync, spawnSync } = require('child_process');
35
+
36
+ // ─── Bypass ──────────────────────────────────────────────────────────────────
37
+ if (process.env.SKIP_SECURITY_SCAN === '1' || process.env.TAS_SKIP_SECURITY === '1') {
38
+ console.log('[TAS Security] Scan skipped via SKIP_SECURITY_SCAN env var');
39
+ process.exit(0);
40
+ }
41
+
42
+ // ─── Locate repo root ────────────────────────────────────────────────────────
43
+ function findRepoRoot(start) {
44
+ let dir = path.resolve(start);
45
+ for (let i = 0; i < 20; i++) {
46
+ if (fs.existsSync(path.join(dir, '.git'))) return dir;
47
+ const parent = path.dirname(dir);
48
+ if (parent === dir) return null;
49
+ dir = parent;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ const repoRoot = findRepoRoot(process.cwd());
55
+ if (!repoRoot) {
56
+ console.log('[TAS Security] Not inside a git repo, skipping');
57
+ process.exit(0);
58
+ }
59
+
60
+ // ─── Config loader (minimal YAML reader for `security:` section only) ────────
61
+ function loadConfig(root) {
62
+ const defaults = {
63
+ pre_commit_hook: true,
64
+ tool: 'claude',
65
+ external_scanner: 'auto',
66
+ deep_scan_on_every_commit: false,
67
+ block_on: ['critical', 'high'],
68
+ allow_bypass: true,
69
+ };
70
+ const p = path.join(root, 'tas.yaml');
71
+ if (!fs.existsSync(p)) return defaults;
72
+
73
+ const content = fs.readFileSync(p, 'utf8');
74
+ const secMatch = content.match(/^security:\s*\n((?:[ \t]+.*\n?)+)/m);
75
+ if (!secMatch) return defaults;
76
+
77
+ const body = secMatch[1];
78
+ const cfg = { ...defaults };
79
+
80
+ const getRaw = (key) => {
81
+ const m = body.match(new RegExp(`^[ \\t]+${key}:[ \\t]*(.+?)[ \\t]*$`, 'm'));
82
+ if (!m) return null;
83
+ // Strip YAML trailing comment (# outside of quotes)
84
+ let v = m[1];
85
+ const hashAt = v.indexOf('#');
86
+ if (hashAt >= 0) {
87
+ const before = v.slice(0, hashAt);
88
+ const dquotes = (before.match(/"/g) || []).length;
89
+ const squotes = (before.match(/'/g) || []).length;
90
+ if (dquotes % 2 === 0 && squotes % 2 === 0) {
91
+ v = before;
92
+ }
93
+ }
94
+ return v.trim();
95
+ };
96
+ const asBool = (v) => (v == null ? null : /^(true|yes|on)$/i.test(v));
97
+ const asStr = (v) => (v == null ? null : v.replace(/^['"]|['"]$/g, '').trim());
98
+ const asList = (v) => {
99
+ if (v == null) return null;
100
+ return v.replace(/^\[|\]$/g, '')
101
+ .split(',')
102
+ .map((s) => s.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
103
+ .filter(Boolean);
104
+ };
105
+
106
+ const b1 = asBool(getRaw('pre_commit_hook'));
107
+ if (b1 !== null) cfg.pre_commit_hook = b1;
108
+
109
+ const t = asStr(getRaw('tool'));
110
+ if (t) cfg.tool = t.toLowerCase();
111
+
112
+ const ext = asStr(getRaw('external_scanner'));
113
+ if (ext) cfg.external_scanner = ext.toLowerCase();
114
+
115
+ const b2 = asBool(getRaw('deep_scan_on_every_commit'));
116
+ if (b2 !== null) cfg.deep_scan_on_every_commit = b2;
117
+
118
+ const arr = asList(getRaw('block_on'));
119
+ if (arr) cfg.block_on = arr;
120
+
121
+ const b3 = asBool(getRaw('allow_bypass'));
122
+ if (b3 !== null) cfg.allow_bypass = b3;
123
+
124
+ return cfg;
125
+ }
126
+
127
+ const cfg = loadConfig(repoRoot);
128
+ if (cfg.pre_commit_hook === false) {
129
+ console.log('[TAS Security] Pre-commit hook disabled in tas.yaml');
130
+ process.exit(0);
131
+ }
132
+
133
+ // ─── Read staged files ───────────────────────────────────────────────────────
134
+ function git(args) {
135
+ try {
136
+ return execSync(`git ${args}`, { cwd: repoRoot, stdio: ['ignore', 'pipe', 'pipe'] }).toString();
137
+ } catch {
138
+ return '';
139
+ }
140
+ }
141
+
142
+ const stagedRaw = git('diff --cached --name-only --diff-filter=ACM');
143
+ const staged = stagedRaw.split('\n').filter(Boolean);
144
+
145
+ if (staged.length === 0) {
146
+ console.log('[TAS Security] No staged files, skipping');
147
+ process.exit(0);
148
+ }
149
+
150
+ // ─── Fast regex patterns ─────────────────────────────────────────────────────
151
+ const PATTERNS = [
152
+ // ── Cloud provider credentials ─────────────────────────────────────────────
153
+ { id: 'aws_access_key', re: /\bAKIA[0-9A-Z]{16}\b/, severity: 'critical', label: 'AWS Access Key' },
154
+ { id: 'aws_secret_key', re: /aws(.{0,20})?(secret|access)(.{0,20})?[:=]\s*['"][A-Za-z0-9/+=]{40}['"]/i, severity: 'critical', label: 'AWS Secret Key' },
155
+ { id: 'azure_storage_key', re: /AccountKey\s*=\s*[A-Za-z0-9+/=]{80,}/i, severity: 'critical', label: 'Azure Storage Account Key' },
156
+ { id: 'azure_conn_string', re: /DefaultEndpointsProtocol\s*=\s*https?;AccountName\s*=\s*[A-Za-z0-9]+;AccountKey\s*=/i, severity: 'critical', label: 'Azure Storage Connection String' },
157
+ { id: 'gcp_service_account', re: /"type"\s*:\s*"service_account"\s*,[\s\S]{0,200}"private_key"\s*:/, severity: 'critical', label: 'GCP Service Account JSON' },
158
+ { id: 'google_api', re: /\bAIza[0-9A-Za-z_-]{35}\b/, severity: 'critical', label: 'Google API Key' },
159
+ { id: 'digitalocean_token', re: /\bdop_v1_[a-f0-9]{64}\b/, severity: 'critical', label: 'DigitalOcean Personal Access Token' },
160
+ { id: 'cloudflare_token', re: /\b[A-Za-z0-9_-]{40}\b(?=.{0,30}(cloudflare|cf[_-]?token|cf[_-]?api))/i, severity: 'high', label: 'Cloudflare API Token (heuristic)' },
161
+ { id: 'heroku_key', re: /heroku[a-zA-Z0-9_ .\-,]{0,25}[:=]\s*['"][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}['"]/i, severity: 'critical', label: 'Heroku API Key' },
162
+
163
+ // ── VCS / Package registries ───────────────────────────────────────────────
164
+ { id: 'github_token', re: /\bgh[opsur]_[A-Za-z0-9]{30,}\b/, severity: 'critical', label: 'GitHub Token' },
165
+ { id: 'github_fine_grained', re: /\bgithub_pat_[A-Za-z0-9_]{70,}\b/, severity: 'critical', label: 'GitHub Fine-grained PAT' },
166
+ { id: 'gitlab_token', re: /\bglpat-[A-Za-z0-9_-]{20,}\b/, severity: 'critical', label: 'GitLab Personal Access Token' },
167
+ { id: 'npm_token', re: /\bnpm_[A-Za-z0-9]{36}\b/, severity: 'critical', label: 'npm Token' },
168
+ { id: 'pypi_token', re: /\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{50,}\b/, severity: 'critical', label: 'PyPI Token' },
169
+
170
+ // ── AI provider keys ───────────────────────────────────────────────────────
171
+ { id: 'openai_key', re: /\bsk-(proj-)?[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,}\b/, severity: 'critical', label: 'OpenAI API Key' },
172
+ { id: 'openai_key_legacy', re: /\bsk-[A-Za-z0-9]{48}\b/, severity: 'critical', label: 'OpenAI API Key (legacy)' },
173
+ { id: 'anthropic_key', re: /\bsk-ant-(api|admin)[0-9]{2}-[A-Za-z0-9_-]{80,}\b/, severity: 'critical', label: 'Anthropic API Key' },
174
+
175
+ // ── Payments ───────────────────────────────────────────────────────────────
176
+ { id: 'stripe_live', re: /\b(sk|rk)_live_[0-9a-zA-Z]{24,}\b/, severity: 'critical', label: 'Stripe Live Secret Key' },
177
+ { id: 'stripe_test', re: /\b(sk|rk)_test_[0-9a-zA-Z]{24,}\b/, severity: 'high', label: 'Stripe Test Key' },
178
+ { id: 'stripe_publishable', re: /\bpk_(live|test)_[0-9a-zA-Z]{24,}\b/, severity: 'medium', label: 'Stripe Publishable Key (OK in frontend, not in backend)' },
179
+ { id: 'square_token', re: /\bsq0(atp|csp|idp)-[A-Za-z0-9_-]{22,}\b/, severity: 'critical', label: 'Square Access Token' },
180
+ { id: 'paypal_braintree', re: /\baccess_token\$production\$[0-9a-z]{16}\$[0-9a-f]{32}\b/, severity: 'critical', label: 'Braintree Production Token' },
181
+
182
+ // ── Messaging / Email ──────────────────────────────────────────────────────
183
+ { id: 'slack_token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, severity: 'critical', label: 'Slack Token' },
184
+ { id: 'slack_webhook', re: /\bhttps:\/\/hooks\.slack\.com\/services\/T[A-Z0-9]+\/B[A-Z0-9]+\/[A-Za-z0-9]{20,}\b/, severity: 'high', label: 'Slack Webhook URL' },
185
+ { id: 'discord_bot', re: /\b[MN][A-Za-z\d]{23}\.[\w-]{6}\.[\w-]{27,}\b/, severity: 'critical', label: 'Discord Bot Token' },
186
+ { id: 'discord_webhook', re: /\bhttps:\/\/(canary\.|ptb\.)?discord(app)?\.com\/api\/webhooks\/\d{17,19}\/[\w-]{60,}\b/, severity: 'high', label: 'Discord Webhook URL' },
187
+ { id: 'twilio_sid', re: /\bAC[a-f0-9]{32}\b/, severity: 'high', label: 'Twilio Account SID' },
188
+ { id: 'twilio_auth', re: /\bSK[a-f0-9]{32}\b/, severity: 'critical', label: 'Twilio Auth Token / API Key' },
189
+ { id: 'sendgrid', re: /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/, severity: 'critical', label: 'SendGrid API Key' },
190
+ { id: 'mailgun', re: /\bkey-[0-9a-zA-Z]{32}\b/, severity: 'critical', label: 'Mailgun API Key' },
191
+ { id: 'mailchimp', re: /\b[0-9a-f]{32}-us[0-9]{1,2}\b/, severity: 'critical', label: 'Mailchimp API Key' },
192
+ { id: 'postmark', re: /\bPOSTMARK_[A-Z_]*TOKEN\s*[:=]\s*['"][0-9a-f-]{36}['"]/i, severity: 'critical', label: 'Postmark Token' },
193
+
194
+ // ── Keys / certificates ────────────────────────────────────────────────────
195
+ { id: 'private_key', re: /-----BEGIN (RSA |OPENSSH |EC |DSA |PGP |ENCRYPTED )?PRIVATE KEY-----/, severity: 'critical', label: 'Private Key' },
196
+ { id: 'ssh_key', re: /\bssh-(rsa|ed25519|dss) AAAA[A-Za-z0-9+/=]{100,}\b/, severity: 'high', label: 'SSH Public Key (context check)' },
197
+ { id: 'pgp_private', re: /-----BEGIN PGP PRIVATE KEY BLOCK-----/, severity: 'critical', label: 'PGP Private Key Block' },
198
+
199
+ // ── Connection strings & auth patterns ────────────────────────────────────
200
+ { id: 'connection_string', re: /(mongodb(\+srv)?|mysql|postgres(ql)?|redis|amqp|amqps):\/\/[^:\s'"]+:[^@\s'"]+@/i, severity: 'high', label: 'DB/MQ Connection String with Credentials' },
201
+ { id: 'jdbc_password', re: /jdbc:[a-z]+:\/\/[^?\s]+\?.*password=[^&\s'"]+/i, severity: 'high', label: 'JDBC URL with Password' },
202
+ { id: 'basic_auth_url', re: /\bhttps?:\/\/[A-Za-z0-9._~-]+:[A-Za-z0-9._~%+-]{6,}@[A-Za-z0-9.-]+/, severity: 'high', label: 'HTTP Basic Auth in URL' },
203
+
204
+ // ── Generic catch-all (looser, more false positives possible) ──────────────
205
+ { id: 'jwt', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/, severity: 'high', label: 'JWT Token' },
206
+ { id: 'generic_secret', re: /\b(api[_-]?key|secret[_-]?key|password|passwd|auth[_-]?token|access[_-]?token|bearer[_-]?token|client[_-]?secret)\b\s*[:=]\s*['"][A-Za-z0-9_\-!@#$%^&*+/=]{12,}['"]/i, severity: 'high', label: 'Hardcoded Secret' },
207
+ { id: 'authorization_header', re: /authorization\s*[:=]\s*['"]\s*(bearer|basic)\s+[A-Za-z0-9+/=_.-]{20,}['"]/i, severity: 'high', label: 'Authorization Header with Credentials' },
208
+ ];
209
+
210
+ // ── Placeholders to ignore (reduces false positives from docs/examples) ──────
211
+ const PLACEHOLDER_HINTS = [
212
+ /\bexample\b/i, /\bplaceholder\b/i, /\bxxxxxxxx\b/, /\bchange[_-]?me\b/i,
213
+ /\byour[_-]?(api|key|token|secret)/i, /\bfake\b/i, /\bdummy\b/i, /<your/i,
214
+ ];
215
+
216
+ // Files we shouldn't scan byte-for-byte
217
+ const SKIP_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.pdf', '.zip', '.gz', '.tar', '.dll', '.exe', '.bin', '.woff', '.woff2', '.ttf', '.eot']);
218
+
219
+ const findings = [];
220
+
221
+ for (const file of staged) {
222
+ const lower = file.toLowerCase();
223
+ const basename = path.basename(lower);
224
+
225
+ // .env files staged (allow .env.example / .env.sample)
226
+ if (/^\.env(\..+)?$/.test(basename) && !/\.(example|sample|template)$/.test(basename)) {
227
+ findings.push({ file, line: 0, severity: 'critical', label: '.env file staged for commit', match: file });
228
+ continue;
229
+ }
230
+
231
+ const ext = path.extname(lower);
232
+ if (SKIP_EXT.has(ext)) continue;
233
+
234
+ const full = path.join(repoRoot, file);
235
+ if (!fs.existsSync(full)) continue;
236
+
237
+ let stat;
238
+ try { stat = fs.statSync(full); } catch { continue; }
239
+ if (stat.size > 2 * 1024 * 1024) continue;
240
+
241
+ let content;
242
+ try { content = fs.readFileSync(full, 'utf8'); } catch { continue; }
243
+
244
+ const lines = content.split('\n');
245
+ for (const pat of PATTERNS) {
246
+ for (let i = 0; i < lines.length; i++) {
247
+ const line = lines[i];
248
+ if (line.length > 500) continue;
249
+ const m = pat.re.exec(line);
250
+ if (!m) continue;
251
+
252
+ // Skip obvious placeholders (docs, examples, templates)
253
+ if (PLACEHOLDER_HINTS.some((h) => h.test(line))) continue;
254
+
255
+ findings.push({
256
+ file,
257
+ line: i + 1,
258
+ severity: pat.severity,
259
+ label: pat.label,
260
+ match: m[0].length > 40 ? m[0].slice(0, 37) + '...' : m[0],
261
+ });
262
+ }
263
+ }
264
+ }
265
+
266
+ // ─── Tier 2: External scanner (gitleaks / trufflehog, if on PATH) ────────────
267
+ function commandExists(bin) {
268
+ const probe = process.platform === 'win32' ? 'where' : 'command';
269
+ const args = process.platform === 'win32' ? [bin] : ['-v', bin];
270
+ try {
271
+ const res = spawnSync(probe, args, {
272
+ stdio: 'ignore',
273
+ shell: process.platform !== 'win32',
274
+ timeout: 5000,
275
+ });
276
+ return res.status === 0;
277
+ } catch {
278
+ return false;
279
+ }
280
+ }
281
+
282
+ const EXTERNAL_SCANNERS = {
283
+ gitleaks: {
284
+ bin: 'gitleaks',
285
+ run: (root) => {
286
+ // gitleaks v8.30+: `git --staged` is canonical. (Older `protect --staged`
287
+ // was deprecated and removed.) --pre-commit flag is implicit with --staged.
288
+ const res = spawnSync('gitleaks', [
289
+ 'git', '--staged', '--no-banner', '--redact', '--no-color', '-v',
290
+ ], { cwd: root, encoding: 'utf8', timeout: 60_000 });
291
+ if (res.error) return null;
292
+ if (res.status === 0) return { findings: [], raw: '' };
293
+ const out = (res.stdout || '') + (res.stderr || '');
294
+ const clean = out.replace(/\x1b\[[0-9;]*m/g, ''); // strip any residual ANSI
295
+ const countMatch = clean.match(/leaks?\s+found:\s*(\d+)/i);
296
+ const count = countMatch ? parseInt(countMatch[1], 10) : 1;
297
+ const items = [];
298
+ // Gitleaks finding block:
299
+ // Finding: ...
300
+ // Secret: ...
301
+ // RuleID: <id>
302
+ // ...
303
+ // File: <path>
304
+ // Line: <n>
305
+ const re = /RuleID:\s*(\S+)[\s\S]*?File:\s*(\S+)[\s\S]*?Line:\s*(\d+)/g;
306
+ let m;
307
+ while ((m = re.exec(clean)) !== null) {
308
+ items.push({
309
+ file: m[2].replace(/\\/g, '/'),
310
+ line: parseInt(m[3], 10) || 0,
311
+ severity: 'critical',
312
+ label: `gitleaks: ${m[1]}`,
313
+ match: 'external',
314
+ });
315
+ }
316
+ if (items.length === 0) {
317
+ items.push({
318
+ file: 'gitleaks',
319
+ line: 0,
320
+ severity: 'critical',
321
+ label: `gitleaks reported ${count} leak(s)`,
322
+ match: 'see output',
323
+ });
324
+ }
325
+ return { findings: items, raw: clean };
326
+ },
327
+ },
328
+ trufflehog: {
329
+ bin: 'trufflehog',
330
+ run: (root, stagedFiles) => {
331
+ if (!stagedFiles.length) return { findings: [], raw: '' };
332
+ const existing = stagedFiles
333
+ .filter((f) => fs.existsSync(path.join(root, f)))
334
+ .map((f) => path.join(root, f));
335
+ if (!existing.length) return { findings: [], raw: '' };
336
+ const res = spawnSync('trufflehog', [
337
+ 'filesystem', '--json', '--no-update', '--fail', ...existing,
338
+ ], { cwd: root, encoding: 'utf8', timeout: 60_000 });
339
+ if (res.error) return null;
340
+ if (res.status === 0) return { findings: [], raw: '' };
341
+ const items = [];
342
+ const out = res.stdout || '';
343
+ for (const line of out.split('\n')) {
344
+ if (!line.trim()) continue;
345
+ try {
346
+ const obj = JSON.parse(line);
347
+ const fileMeta = obj.SourceMetadata?.Data?.Filesystem || {};
348
+ const relFile = (fileMeta.file || '').replace(root + path.sep, '').replace(/\\/g, '/');
349
+ items.push({
350
+ file: relFile || 'trufflehog',
351
+ line: fileMeta.line || 0,
352
+ severity: obj.Verified ? 'critical' : 'high',
353
+ label: `trufflehog: ${obj.DetectorName}${obj.Verified ? ' (VERIFIED live)' : ''}`,
354
+ match: (obj.Raw || '').slice(0, 30),
355
+ });
356
+ } catch {
357
+ /* skip non-JSON line */
358
+ }
359
+ }
360
+ if (items.length === 0 && res.status !== 0) {
361
+ items.push({
362
+ file: 'trufflehog',
363
+ line: 0,
364
+ severity: 'critical',
365
+ label: 'trufflehog reported findings',
366
+ match: 'see output',
367
+ });
368
+ }
369
+ return { findings: items, raw: (res.stderr || '') };
370
+ },
371
+ },
372
+ };
373
+
374
+ function runExternalScanner() {
375
+ const pref = (cfg.external_scanner || 'auto').toLowerCase();
376
+ if (pref === 'none') return;
377
+
378
+ const order = pref === 'auto' ? ['gitleaks', 'trufflehog'] : [pref];
379
+ for (const name of order) {
380
+ const scanner = EXTERNAL_SCANNERS[name];
381
+ if (!scanner) {
382
+ if (pref !== 'auto') console.warn(`[TAS Security] Unknown external_scanner "${pref}"`);
383
+ continue;
384
+ }
385
+ if (!commandExists(scanner.bin)) {
386
+ if (pref !== 'auto') {
387
+ console.warn(`[TAS Security] external_scanner="${name}" not on PATH — skipping tier 2`);
388
+ }
389
+ continue;
390
+ }
391
+ console.log(`[TAS Security] Running tier 2 scanner: ${name}...`);
392
+ const result = scanner.run(repoRoot, staged);
393
+ if (!result) {
394
+ console.warn(`[TAS Security] Tier 2 (${name}) invocation failed`);
395
+ return;
396
+ }
397
+ if (result.findings.length > 0) {
398
+ findings.push(...result.findings);
399
+ } else {
400
+ console.log(`[TAS Security] Tier 2 (${name}) — clean`);
401
+ }
402
+ return; // only run first available
403
+ }
404
+ }
405
+
406
+ runExternalScanner();
407
+
408
+ // ─── Tier 3: AI deep scan → report-only, LOCAL ONLY ──────────────────────────
409
+ // Triggers ONLY when deep_scan_on_every_commit=true in tas.yaml.
410
+ // Not intended for CI — use a personal Claude Code subscription locally
411
+ // (5h reset, no per-call API charges). Writes/appends to docs/security-report.md;
412
+ // dev may choose to `git add` it into the PR if they want reviewers to see.
413
+ const runDeep = cfg.deep_scan_on_every_commit && cfg.tool !== 'none';
414
+
415
+ function writeDeepScanReport(aiOutput, commitSha, staged) {
416
+ const docsDir = path.join(repoRoot, 'docs');
417
+ const reportPath = path.join(docsDir, 'security-report.md');
418
+ try { fs.mkdirSync(docsDir, { recursive: true }); } catch { /* ignore */ }
419
+
420
+ const now = new Date();
421
+ const isoDate = now.toISOString().replace('T', ' ').slice(0, 19) + ' UTC';
422
+ const shortSha = (commitSha || 'staged').slice(0, 8);
423
+
424
+ // Initial file template (only on first scan)
425
+ const header = [
426
+ '# Security Report',
427
+ '',
428
+ '> Generated by pre-commit / CI deep scan. Report-only — does NOT block commits.',
429
+ '> Review findings during PR review; open issues or fix in follow-up commits.',
430
+ '',
431
+ '## Scan History',
432
+ '',
433
+ '| Date (UTC) | Commit | Tool | Status | Findings |',
434
+ '|---|---|---|---|---|',
435
+ '',
436
+ '---',
437
+ '',
438
+ ].join('\n');
439
+
440
+ let content = '';
441
+ if (fs.existsSync(reportPath)) {
442
+ content = fs.readFileSync(reportPath, 'utf8');
443
+ } else {
444
+ content = header;
445
+ }
446
+
447
+ // Build per-scan section
448
+ const trimmed = (aiOutput || '').trim();
449
+ const isClean = /^NO FINDINGS\s*$/im.test(trimmed) || trimmed.length === 0;
450
+ const status = isClean ? 'Clean' : 'Findings present';
451
+
452
+ // Count findings from AI output (structured line format if present)
453
+ const lineRe = /^\s*(CRITICAL|HIGH|MEDIUM|LOW)\s*\|/gmi;
454
+ const matches = trimmed.match(lineRe) || [];
455
+ const findingsCount = matches.length;
456
+
457
+ const newHistoryRow = `| ${isoDate} | ${shortSha} | ${cfg.tool} | ${status} | ${findingsCount} |`;
458
+ const sectionAnchor = `scan-${now.getTime()}`;
459
+
460
+ const section = [
461
+ '',
462
+ `## Scan ${isoDate} — ${shortSha} <a id="${sectionAnchor}"></a>`,
463
+ '',
464
+ `- **Tool:** \`${cfg.tool}\``,
465
+ `- **Scope:** staged diff (${staged.length} file${staged.length === 1 ? '' : 's'})`,
466
+ `- **Status:** ${status}`,
467
+ '',
468
+ '### Staged files',
469
+ '',
470
+ ...staged.map((f) => `- \`${f}\``),
471
+ '',
472
+ '### AI Review Output',
473
+ '',
474
+ '```',
475
+ trimmed || 'NO FINDINGS',
476
+ '```',
477
+ '',
478
+ '---',
479
+ '',
480
+ ].join('\n');
481
+
482
+ // Insert new history row after the table header
483
+ const tableMatcher = /(\|\s*Date \(UTC\)[\s\S]*?\|---\|---\|---\|---\|---\|\n)/;
484
+ if (tableMatcher.test(content)) {
485
+ content = content.replace(tableMatcher, `$1${newHistoryRow}\n`);
486
+ } else {
487
+ // Fallback: no table header found, prepend history
488
+ content += `\n${newHistoryRow}\n`;
489
+ }
490
+
491
+ // Append full section at end
492
+ content = content.trimEnd() + '\n\n' + section;
493
+ fs.writeFileSync(reportPath, content);
494
+
495
+ return { reportPath, status, findingsCount };
496
+ }
497
+
498
+ if (runDeep) {
499
+ const diff = git('diff --cached -U0');
500
+ const headSha = git('rev-parse HEAD').trim();
501
+ if (diff && diff.length < 200_000) {
502
+ const prompt = [
503
+ 'You are a senior security reviewer auditing a git staged diff before commit.',
504
+ '',
505
+ 'Produce a structured security review. For each finding, output one line in this exact format:',
506
+ ' <SEVERITY> | <file:line> | <description> | <remediation>',
507
+ '',
508
+ 'SEVERITY must be one of: CRITICAL, HIGH, MEDIUM, LOW.',
509
+ '',
510
+ 'Focus areas: OWASP Top 10, hardcoded secrets, SQL/command injection, XSS, IDOR,',
511
+ 'broken authn/authz, unsafe deserialization, insecure cryptography, SSRF, path traversal.',
512
+ '',
513
+ 'Skip: style issues, missing tests, generic code quality. Only report security-relevant issues.',
514
+ '',
515
+ 'If there are no security issues, output exactly: NO FINDINGS',
516
+ '',
517
+ 'After the finding lines, add a short paragraph summarizing overall risk (1-3 sentences).',
518
+ '',
519
+ '=== DIFF START ===',
520
+ diff,
521
+ '=== DIFF END ===',
522
+ ].join('\n');
523
+
524
+ const tools = {
525
+ claude: { bin: 'claude', args: ['--print', '--permission-mode', 'bypassPermissions'] },
526
+ codex: { bin: 'codex', args: ['exec', '--quiet'] },
527
+ gemini: { bin: 'gemini', args: ['--prompt-stdin'] },
528
+ };
529
+ const tool = tools[cfg.tool];
530
+
531
+ if (!tool) {
532
+ console.warn(`[TAS Security] Tier 3: unknown tool "${cfg.tool}", skipping deep scan`);
533
+ } else {
534
+ console.log(`[TAS Security] Tier 3: running deep scan via ${cfg.tool} (report-only)...`);
535
+ const res = spawnSync(tool.bin, tool.args, {
536
+ input: prompt,
537
+ encoding: 'utf8',
538
+ timeout: 180_000,
539
+ shell: process.platform === 'win32',
540
+ });
541
+
542
+ if (res.error) {
543
+ console.warn(`[TAS Security] Tier 3: ${cfg.tool} CLI not installed or not on PATH — skipping`);
544
+ } else {
545
+ const out = ((res.stdout || '') + '\n' + (res.stderr || '')).trim();
546
+ const { reportPath, status, findingsCount } = writeDeepScanReport(out, headSha, staged);
547
+ const rel = path.relative(repoRoot, reportPath).replace(/\\/g, '/');
548
+ console.log(`[TAS Security] Tier 3: ${status} (${findingsCount} finding${findingsCount === 1 ? '' : 's'}) → ${rel}`);
549
+ if (findingsCount > 0) {
550
+ console.log(`[TAS Security] Commit proceeds (report-only).`);
551
+ console.log(`[TAS Security] To include the report in this commit so reviewers see it:`);
552
+ console.log(`[TAS Security] git add ${rel} && git commit --amend --no-edit --no-verify`);
553
+ }
554
+ }
555
+ }
556
+ }
557
+ }
558
+
559
+ // ─── Report & exit ───────────────────────────────────────────────────────────
560
+ if (findings.length === 0) {
561
+ console.log(`[TAS Security] Pass — ${staged.length} staged file(s) scanned, no issues`);
562
+ process.exit(0);
563
+ }
564
+
565
+ const sevOrder = { critical: 0, high: 1, medium: 2, low: 3 };
566
+ findings.sort((a, b) => (sevOrder[a.severity] ?? 9) - (sevOrder[b.severity] ?? 9));
567
+
568
+ console.error('');
569
+ console.error('[TAS Security] Findings:');
570
+ console.error('─'.repeat(72));
571
+ for (const f of findings) {
572
+ const sev = f.severity.toUpperCase().padEnd(8);
573
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
574
+ console.error(` [${sev}] ${loc}`);
575
+ console.error(` ${f.label}${f.match ? ' — ' + f.match : ''}`);
576
+ }
577
+ console.error('─'.repeat(72));
578
+
579
+ const blockList = (cfg.block_on || []).map((s) => s.toLowerCase());
580
+ const shouldBlock = findings.some((f) => blockList.includes(f.severity));
581
+
582
+ if (shouldBlock) {
583
+ console.error('');
584
+ console.error('[TAS Security] COMMIT BLOCKED — findings at or above blocking threshold.');
585
+ console.error(` block_on: [${blockList.join(', ')}]`);
586
+ if (cfg.allow_bypass) {
587
+ console.error('');
588
+ console.error(' To bypass (document the reason in commit message):');
589
+ console.error(' SKIP_SECURITY_SCAN=1 git commit ...');
590
+ console.error(' git commit --no-verify');
591
+ }
592
+ console.error('');
593
+ process.exit(1);
594
+ }
595
+
596
+ console.warn('');
597
+ console.warn('[TAS Security] Warnings only — below block threshold, commit allowed.');
598
+ console.warn('');
599
+ process.exit(0);