tlc-claude-code 2.0.1 → 2.1.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.
Files changed (86) hide show
  1. package/.claude/commands/tlc/deploy.md +194 -2
  2. package/.claude/commands/tlc/e2e-verify.md +214 -0
  3. package/.claude/commands/tlc/guard.md +191 -0
  4. package/.claude/commands/tlc/help.md +32 -0
  5. package/.claude/commands/tlc/init.md +73 -37
  6. package/.claude/commands/tlc/llm.md +19 -4
  7. package/.claude/commands/tlc/preflight.md +134 -0
  8. package/.claude/commands/tlc/review.md +17 -4
  9. package/.claude/commands/tlc/watchci.md +159 -0
  10. package/.claude/hooks/tlc-block-tools.sh +41 -0
  11. package/.claude/hooks/tlc-capture-exchange.sh +50 -0
  12. package/.claude/hooks/tlc-post-build.sh +38 -0
  13. package/.claude/hooks/tlc-post-push.sh +22 -0
  14. package/.claude/hooks/tlc-prompt-guard.sh +69 -0
  15. package/.claude/hooks/tlc-session-init.sh +123 -0
  16. package/CLAUDE.md +12 -0
  17. package/bin/install.js +171 -2
  18. package/bin/postinstall.js +45 -26
  19. package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
  20. package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
  21. package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
  22. package/dashboard-web/dist/index.html +2 -2
  23. package/docker-compose.dev.yml +18 -12
  24. package/package.json +3 -1
  25. package/server/index.js +228 -2
  26. package/server/lib/capture-bridge.js +242 -0
  27. package/server/lib/capture-bridge.test.js +363 -0
  28. package/server/lib/capture-guard.js +140 -0
  29. package/server/lib/capture-guard.test.js +182 -0
  30. package/server/lib/command-runner.js +159 -0
  31. package/server/lib/command-runner.test.js +92 -0
  32. package/server/lib/deploy/runners/dependency-runner.js +106 -0
  33. package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
  34. package/server/lib/deploy/runners/secrets-runner.js +174 -0
  35. package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
  36. package/server/lib/deploy/security-gates.js +11 -24
  37. package/server/lib/deploy/security-gates.test.js +9 -2
  38. package/server/lib/deploy-engine.js +182 -0
  39. package/server/lib/deploy-engine.test.js +147 -0
  40. package/server/lib/docker-api.js +137 -0
  41. package/server/lib/docker-api.test.js +202 -0
  42. package/server/lib/docker-client.js +297 -0
  43. package/server/lib/docker-client.test.js +308 -0
  44. package/server/lib/input-sanitizer.js +86 -0
  45. package/server/lib/input-sanitizer.test.js +117 -0
  46. package/server/lib/launchd-agent.js +225 -0
  47. package/server/lib/launchd-agent.test.js +185 -0
  48. package/server/lib/memory-api.js +3 -1
  49. package/server/lib/memory-api.test.js +3 -5
  50. package/server/lib/memory-bridge-e2e.test.js +160 -0
  51. package/server/lib/memory-committer.js +18 -4
  52. package/server/lib/memory-committer.test.js +21 -0
  53. package/server/lib/memory-hooks-capture.test.js +69 -4
  54. package/server/lib/memory-hooks-integration.test.js +98 -0
  55. package/server/lib/memory-hooks.js +42 -4
  56. package/server/lib/memory-store-adapter.js +105 -0
  57. package/server/lib/memory-store-adapter.test.js +141 -0
  58. package/server/lib/memory-wiring-e2e.test.js +93 -0
  59. package/server/lib/nginx-config.js +114 -0
  60. package/server/lib/nginx-config.test.js +82 -0
  61. package/server/lib/ollama-health.js +91 -0
  62. package/server/lib/ollama-health.test.js +74 -0
  63. package/server/lib/port-guard.js +44 -0
  64. package/server/lib/port-guard.test.js +65 -0
  65. package/server/lib/project-scanner.js +37 -2
  66. package/server/lib/project-scanner.test.js +152 -0
  67. package/server/lib/remember-command.js +2 -0
  68. package/server/lib/remember-command.test.js +23 -0
  69. package/server/lib/security/crypto-utils.test.js +2 -2
  70. package/server/lib/semantic-recall.js +1 -1
  71. package/server/lib/semantic-recall.test.js +17 -0
  72. package/server/lib/ssh-client.js +184 -0
  73. package/server/lib/ssh-client.test.js +127 -0
  74. package/server/lib/vps-api.js +184 -0
  75. package/server/lib/vps-api.test.js +208 -0
  76. package/server/lib/vps-bootstrap.js +124 -0
  77. package/server/lib/vps-bootstrap.test.js +79 -0
  78. package/server/lib/vps-monitor.js +126 -0
  79. package/server/lib/vps-monitor.test.js +98 -0
  80. package/server/lib/workspace-api.js +182 -1
  81. package/server/lib/workspace-api.test.js +474 -0
  82. package/server/package-lock.json +737 -0
  83. package/server/package.json +3 -0
  84. package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
  85. package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
  86. package/dashboard-web/dist/assets/index-W36XHPC5.js.map +0 -1
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Command Runner — execute TLC commands via container, Claude Code, or queue
3
+ * Phase 80 Task 8
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const { execSync, spawn } = require('child_process');
9
+
10
+ const VALID_COMMANDS = ['build', 'deploy', 'test', 'plan', 'verify', 'review', 'status'];
11
+
12
+ /**
13
+ * Create command runner
14
+ * @param {Object} [options]
15
+ * @param {Function} [options._checkDocker] - Check if tlc-standalone image exists
16
+ * @param {Function} [options._checkClaude] - Check if Claude Code is running
17
+ * @returns {Object} Command runner API
18
+ */
19
+ function createCommandRunner(options = {}) {
20
+ const checkDocker = options._checkDocker || defaultCheckDocker;
21
+ const checkClaude = options._checkClaude || defaultCheckClaude;
22
+
23
+ async function defaultCheckDocker() {
24
+ try {
25
+ execSync('docker image inspect tlc-standalone 2>/dev/null', { stdio: 'pipe' });
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ function defaultCheckClaude() {
33
+ try {
34
+ const result = execSync('pgrep -f "claude" 2>/dev/null', { stdio: 'pipe' });
35
+ return result.toString().trim().length > 0;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Detect best execution method
43
+ * @param {string} projectPath
44
+ * @returns {Promise<string>} 'container' | 'claude-code' | 'queue'
45
+ */
46
+ async function detectExecutionMethod(projectPath) {
47
+ if (await checkDocker()) return 'container';
48
+ if (checkClaude()) return 'claude-code';
49
+ return 'queue';
50
+ }
51
+
52
+ /**
53
+ * Execute command via Docker container
54
+ * @param {string} projectPath
55
+ * @param {string} command
56
+ * @param {Function} onOutput
57
+ * @returns {Promise<{ exitCode: number }>}
58
+ */
59
+ async function executeViaContainer(projectPath, command, onOutput) {
60
+ return new Promise((resolve, reject) => {
61
+ const proc = spawn('docker', [
62
+ 'run', '--rm',
63
+ '-v', `${projectPath}:/project`,
64
+ '-w', '/project',
65
+ 'tlc-standalone',
66
+ 'tlc', command,
67
+ ]);
68
+
69
+ proc.stdout.on('data', (data) => onOutput && onOutput(data.toString()));
70
+ proc.stderr.on('data', (data) => onOutput && onOutput(data.toString()));
71
+ proc.on('close', (code) => resolve({ exitCode: code }));
72
+ proc.on('error', (err) => reject(err));
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Queue command as task in PLAN.md
78
+ * @param {string} projectPath
79
+ * @param {string} command
80
+ */
81
+ async function queueCommand(projectPath, command) {
82
+ const timestamp = new Date().toISOString();
83
+ const entry = `\n### Queued: tlc ${command}\n_Queued at ${timestamp}_\n`;
84
+
85
+ // Find most recent plan file
86
+ const planDir = path.join(projectPath, '.planning', 'phases');
87
+ if (fs.existsSync(planDir)) {
88
+ const plans = fs.readdirSync(planDir).filter(f => f.endsWith('-PLAN.md')).sort((a, b) => {
89
+ const numA = parseInt(a.match(/^(\d+)/)?.[1] || '0', 10);
90
+ const numB = parseInt(b.match(/^(\d+)/)?.[1] || '0', 10);
91
+ return numA - numB;
92
+ });
93
+ if (plans.length > 0) {
94
+ const planPath = path.join(planDir, plans[plans.length - 1]);
95
+ fs.appendFileSync(planPath, entry);
96
+ }
97
+ }
98
+
99
+ // Log to history
100
+ logCommand(projectPath, { command, timestamp, method: 'queue' });
101
+
102
+ return { queued: true, method: 'queue', command };
103
+ }
104
+
105
+ /**
106
+ * Log a command to history
107
+ */
108
+ function logCommand(projectPath, entry) {
109
+ const histPath = path.join(projectPath, '.tlc', 'command-history.json');
110
+ const dir = path.dirname(histPath);
111
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
112
+
113
+ let history = [];
114
+ try {
115
+ if (fs.existsSync(histPath)) {
116
+ history = JSON.parse(fs.readFileSync(histPath, 'utf8'));
117
+ }
118
+ } catch {}
119
+ history.push(entry);
120
+ // Keep last 100
121
+ if (history.length > 100) history = history.slice(-100);
122
+ fs.writeFileSync(histPath, JSON.stringify(history, null, 2));
123
+ }
124
+
125
+ /**
126
+ * Get command history for a project
127
+ * @param {string} projectPath
128
+ * @returns {Array}
129
+ */
130
+ function getCommandHistory(projectPath) {
131
+ const histPath = path.join(projectPath, '.tlc', 'command-history.json');
132
+ try {
133
+ if (fs.existsSync(histPath)) {
134
+ return JSON.parse(fs.readFileSync(histPath, 'utf8'));
135
+ }
136
+ } catch {}
137
+ return [];
138
+ }
139
+
140
+ /**
141
+ * Validate command type
142
+ * @param {string} command
143
+ * @returns {boolean}
144
+ */
145
+ function validateCommand(command) {
146
+ if (!command || typeof command !== 'string') return false;
147
+ return VALID_COMMANDS.includes(command);
148
+ }
149
+
150
+ return {
151
+ detectExecutionMethod,
152
+ executeViaContainer,
153
+ queueCommand,
154
+ getCommandHistory,
155
+ validateCommand,
156
+ };
157
+ }
158
+
159
+ module.exports = { createCommandRunner };
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const { createCommandRunner } = await import('./command-runner.js');
7
+
8
+ describe('CommandRunner', () => {
9
+ let runner;
10
+ let tempDir;
11
+
12
+ beforeEach(() => {
13
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cmd-runner-test-'));
14
+ runner = createCommandRunner({
15
+ _checkDocker: vi.fn().mockResolvedValue(false),
16
+ _checkClaude: vi.fn().mockReturnValue(false),
17
+ });
18
+ });
19
+
20
+ afterEach(() => {
21
+ fs.rmSync(tempDir, { recursive: true, force: true });
22
+ });
23
+
24
+ describe('detectExecutionMethod', () => {
25
+ it('returns container when tlc-standalone image exists', async () => {
26
+ const r = createCommandRunner({
27
+ _checkDocker: vi.fn().mockResolvedValue(true),
28
+ _checkClaude: vi.fn().mockReturnValue(false),
29
+ });
30
+ const method = await r.detectExecutionMethod(tempDir);
31
+ expect(method).toBe('container');
32
+ });
33
+
34
+ it('returns claude-code when Claude Code process running', async () => {
35
+ const r = createCommandRunner({
36
+ _checkDocker: vi.fn().mockResolvedValue(false),
37
+ _checkClaude: vi.fn().mockReturnValue(true),
38
+ });
39
+ const method = await r.detectExecutionMethod(tempDir);
40
+ expect(method).toBe('claude-code');
41
+ });
42
+
43
+ it('returns queue as fallback', async () => {
44
+ const method = await runner.detectExecutionMethod(tempDir);
45
+ expect(method).toBe('queue');
46
+ });
47
+ });
48
+
49
+ describe('queueCommand', () => {
50
+ it('appends task to PLAN.md', async () => {
51
+ const planDir = path.join(tempDir, '.planning', 'phases');
52
+ fs.mkdirSync(planDir, { recursive: true });
53
+ fs.writeFileSync(path.join(planDir, '1-PLAN.md'), '# Phase 1\n');
54
+
55
+ const result = await runner.queueCommand(tempDir, 'build');
56
+ expect(result.queued).toBe(true);
57
+ expect(result.method).toBe('queue');
58
+ });
59
+ });
60
+
61
+ describe('getCommandHistory', () => {
62
+ it('returns recent commands', () => {
63
+ const histDir = path.join(tempDir, '.tlc');
64
+ fs.mkdirSync(histDir, { recursive: true });
65
+ fs.writeFileSync(path.join(histDir, 'command-history.json'), JSON.stringify([
66
+ { command: 'build', timestamp: '2026-02-18T00:00:00Z', method: 'queue' },
67
+ ]));
68
+
69
+ const history = runner.getCommandHistory(tempDir);
70
+ expect(history).toHaveLength(1);
71
+ expect(history[0].command).toBe('build');
72
+ });
73
+
74
+ it('returns empty array when no history', () => {
75
+ const history = runner.getCommandHistory(tempDir);
76
+ expect(history).toEqual([]);
77
+ });
78
+ });
79
+
80
+ describe('validateCommand', () => {
81
+ it('accepts valid commands', () => {
82
+ expect(runner.validateCommand('build')).toBe(true);
83
+ expect(runner.validateCommand('deploy')).toBe(true);
84
+ expect(runner.validateCommand('test')).toBe(true);
85
+ });
86
+
87
+ it('rejects invalid commands', () => {
88
+ expect(runner.validateCommand('')).toBe(false);
89
+ expect(runner.validateCommand(null)).toBe(false);
90
+ });
91
+ });
92
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Dependency Runner
3
+ *
4
+ * Scans project dependencies using npm audit.
5
+ * Returns findings with severity, package name, and fix availability.
6
+ */
7
+
8
+ import { execFile as defaultExecFile } from 'node:child_process';
9
+ import { promisify } from 'node:util';
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+
13
+ const execFileAsync = promisify(defaultExecFile);
14
+
15
+ /** Severity levels ordered from lowest to highest */
16
+ const SEVERITY_ORDER = ['info', 'low', 'moderate', 'high', 'critical'];
17
+
18
+ /**
19
+ * Check if a severity meets or exceeds the threshold
20
+ * @param {string} severity - The vulnerability severity
21
+ * @param {string} threshold - The minimum severity to fail on
22
+ * @returns {boolean} True if severity >= threshold
23
+ */
24
+ function meetsThreshold(severity, threshold) {
25
+ return SEVERITY_ORDER.indexOf(severity) >= SEVERITY_ORDER.indexOf(threshold);
26
+ }
27
+
28
+ /**
29
+ * Create a dependency scanning runner
30
+ * @param {Object} [deps] - Injectable dependencies for testing
31
+ * @param {Function} [deps.exec] - Command executor (replaces execFile)
32
+ * @param {Object} [deps.fs] - File system module
33
+ * @param {string} [deps.severityThreshold] - Minimum severity to fail on (default: 'high')
34
+ * @returns {Function} Runner function: (projectPath, options) => { passed, findings, error? }
35
+ */
36
+ export function createDependencyRunner(deps = {}) {
37
+ const {
38
+ exec: execFn,
39
+ fs: fsMod = fs,
40
+ severityThreshold = 'high',
41
+ } = deps;
42
+
43
+ return async (projectPath, options = {}) => {
44
+ // Check if package.json exists
45
+ const pkgPath = path.join(projectPath, 'package.json');
46
+ if (!fsMod.existsSync(pkgPath)) {
47
+ return { passed: true, findings: [] };
48
+ }
49
+
50
+ let stdout;
51
+ try {
52
+ if (execFn) {
53
+ // Injected exec for testing
54
+ const result = await execFn('npm', ['audit', '--json'], { cwd: projectPath });
55
+ stdout = result.stdout;
56
+ } else {
57
+ // Real exec
58
+ try {
59
+ const result = await execFileAsync('npm', ['audit', '--json'], { cwd: projectPath });
60
+ stdout = result.stdout;
61
+ } catch (execErr) {
62
+ // npm audit exits with code 1 when vulnerabilities found — that's expected
63
+ if (execErr.stdout) {
64
+ stdout = execErr.stdout;
65
+ } else {
66
+ throw execErr;
67
+ }
68
+ }
69
+ }
70
+ } catch (err) {
71
+ return { passed: false, findings: [], error: err.message };
72
+ }
73
+
74
+ // Parse the JSON output
75
+ let auditData;
76
+ try {
77
+ auditData = JSON.parse(stdout);
78
+ } catch {
79
+ return { passed: false, findings: [], error: 'Failed to parse npm audit output' };
80
+ }
81
+
82
+ const vulnerabilities = auditData.vulnerabilities || {};
83
+ const findings = [];
84
+ let hasFailingSeverity = false;
85
+
86
+ for (const [name, vuln] of Object.entries(vulnerabilities)) {
87
+ const finding = {
88
+ severity: vuln.severity,
89
+ package: vuln.name || name,
90
+ title: vuln.title || 'Unknown vulnerability',
91
+ url: vuln.url || '',
92
+ fixAvailable: Boolean(vuln.fixAvailable),
93
+ };
94
+ findings.push(finding);
95
+
96
+ if (meetsThreshold(vuln.severity, severityThreshold)) {
97
+ hasFailingSeverity = true;
98
+ }
99
+ }
100
+
101
+ return {
102
+ passed: !hasFailingSeverity,
103
+ findings,
104
+ };
105
+ };
106
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Dependency Runner Tests
3
+ */
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
5
+ import { createDependencyRunner } from './dependency-runner.js';
6
+
7
+ describe('dependency-runner', () => {
8
+ let execMock;
9
+ let fsMock;
10
+ let runner;
11
+
12
+ beforeEach(() => {
13
+ execMock = vi.fn();
14
+ fsMock = { existsSync: vi.fn().mockReturnValue(true) };
15
+ runner = createDependencyRunner({ exec: execMock, fs: fsMock });
16
+ });
17
+
18
+ it('passes when npm audit returns no vulnerabilities', async () => {
19
+ execMock.mockResolvedValue({
20
+ stdout: JSON.stringify({ vulnerabilities: {} }),
21
+ exitCode: 0,
22
+ });
23
+
24
+ const result = await runner('/test/project', {});
25
+ expect(result.passed).toBe(true);
26
+ expect(result.findings).toEqual([]);
27
+ });
28
+
29
+ it('fails when npm audit finds high-severity vulnerability', async () => {
30
+ execMock.mockResolvedValue({
31
+ stdout: JSON.stringify({
32
+ vulnerabilities: {
33
+ 'bad-pkg': {
34
+ name: 'bad-pkg',
35
+ severity: 'high',
36
+ title: 'Prototype Pollution',
37
+ url: 'https://npmjs.com/advisories/123',
38
+ fixAvailable: true,
39
+ },
40
+ },
41
+ }),
42
+ exitCode: 1,
43
+ });
44
+
45
+ const result = await runner('/test/project', {});
46
+ expect(result.passed).toBe(false);
47
+ expect(result.findings).toHaveLength(1);
48
+ expect(result.findings[0].severity).toBe('high');
49
+ expect(result.findings[0].package).toBe('bad-pkg');
50
+ expect(result.findings[0].title).toBe('Prototype Pollution');
51
+ });
52
+
53
+ it('passes when only low/moderate vulnerabilities found', async () => {
54
+ execMock.mockResolvedValue({
55
+ stdout: JSON.stringify({
56
+ vulnerabilities: {
57
+ 'ok-pkg': {
58
+ name: 'ok-pkg',
59
+ severity: 'moderate',
60
+ title: 'ReDoS',
61
+ url: 'https://npmjs.com/advisories/456',
62
+ fixAvailable: true,
63
+ },
64
+ },
65
+ }),
66
+ exitCode: 1,
67
+ });
68
+
69
+ const result = await runner('/test/project', {});
70
+ expect(result.passed).toBe(true);
71
+ expect(result.findings).toHaveLength(1);
72
+ });
73
+
74
+ it('fails when critical vulnerability found', async () => {
75
+ execMock.mockResolvedValue({
76
+ stdout: JSON.stringify({
77
+ vulnerabilities: {
78
+ 'evil-pkg': {
79
+ name: 'evil-pkg',
80
+ severity: 'critical',
81
+ title: 'RCE',
82
+ url: 'https://npmjs.com/advisories/789',
83
+ fixAvailable: false,
84
+ },
85
+ },
86
+ }),
87
+ exitCode: 1,
88
+ });
89
+
90
+ const result = await runner('/test/project', {});
91
+ expect(result.passed).toBe(false);
92
+ expect(result.findings[0].severity).toBe('critical');
93
+ });
94
+
95
+ it('handles missing package.json', async () => {
96
+ fsMock.existsSync.mockReturnValue(false);
97
+
98
+ const result = await runner('/test/project', {});
99
+ expect(result.passed).toBe(true);
100
+ expect(result.findings).toEqual([]);
101
+ });
102
+
103
+ it('handles npm audit JSON parse error', async () => {
104
+ execMock.mockResolvedValue({
105
+ stdout: 'not valid json',
106
+ exitCode: 1,
107
+ });
108
+
109
+ const result = await runner('/test/project', {});
110
+ expect(result.passed).toBe(false);
111
+ expect(result.error).toBeDefined();
112
+ });
113
+
114
+ it('handles npm audit process error', async () => {
115
+ execMock.mockRejectedValue(new Error('npm not found'));
116
+
117
+ const result = await runner('/test/project', {});
118
+ expect(result.passed).toBe(false);
119
+ expect(result.error).toContain('npm not found');
120
+ });
121
+
122
+ it('respects configurable severity threshold', async () => {
123
+ execMock.mockResolvedValue({
124
+ stdout: JSON.stringify({
125
+ vulnerabilities: {
126
+ 'bad-pkg': {
127
+ name: 'bad-pkg',
128
+ severity: 'high',
129
+ title: 'Prototype Pollution',
130
+ url: 'https://npmjs.com/advisories/123',
131
+ fixAvailable: true,
132
+ },
133
+ },
134
+ }),
135
+ exitCode: 1,
136
+ });
137
+
138
+ const lenientRunner = createDependencyRunner({
139
+ exec: execMock,
140
+ fs: fsMock,
141
+ severityThreshold: 'critical',
142
+ });
143
+
144
+ const result = await lenientRunner('/test/project', {});
145
+ expect(result.passed).toBe(true);
146
+ expect(result.findings).toHaveLength(1);
147
+ });
148
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Secrets Runner
3
+ *
4
+ * Scans project files for hardcoded secrets using regex patterns.
5
+ * No external tools required — pure pattern matching.
6
+ */
7
+
8
+ import { readdir, readFile as fsReadFile } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ /** Secret detection patterns */
12
+ const SECRET_PATTERNS = [
13
+ {
14
+ name: 'hardcoded-password',
15
+ pattern: /(?:password|passwd|pwd)\s*[:=]\s*["'][^"']{4,}["']/i,
16
+ severity: 'high',
17
+ },
18
+ {
19
+ name: 'aws-access-key',
20
+ pattern: /AKIA[0-9A-Z]{16}/,
21
+ severity: 'critical',
22
+ },
23
+ {
24
+ name: 'private-key',
25
+ pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/,
26
+ severity: 'critical',
27
+ },
28
+ {
29
+ name: 'generic-api-key',
30
+ pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*["'][^"']{8,}["']/i,
31
+ severity: 'high',
32
+ },
33
+ {
34
+ name: 'generic-secret',
35
+ pattern: /(?:secret|token)\s*[:=]\s*["'](?:sk_live_|sk_test_|ghp_|gho_|ghs_)[^"']+["']/i,
36
+ severity: 'high',
37
+ },
38
+ ];
39
+
40
+ /** File extensions to scan */
41
+ const SCAN_EXTENSIONS = new Set([
42
+ '.js', '.ts', '.json', '.env', '.yml', '.yaml', '.jsx', '.tsx', '.mjs', '.cjs',
43
+ ]);
44
+
45
+ /** Default file glob pattern (used with injected glob) */
46
+ const DEFAULT_GLOB = '**/*.{js,ts,json,env,yml,yaml,jsx,tsx,mjs,cjs}';
47
+
48
+ /** Default exclusion patterns */
49
+ const DEFAULT_IGNORE = [
50
+ '**/node_modules/**',
51
+ '**/.git/**',
52
+ '**/package-lock.json',
53
+ '**/yarn.lock',
54
+ '**/pnpm-lock.yaml',
55
+ '**/*.test.*',
56
+ '**/*.spec.*',
57
+ '**/__tests__/**',
58
+ ];
59
+
60
+ /** Directory names to skip during recursive walk */
61
+ const SKIP_DIRS = new Set(['node_modules', '.git', '__tests__']);
62
+
63
+ /** File patterns to skip */
64
+ const SKIP_FILE_PATTERNS = [
65
+ /\.test\./,
66
+ /\.spec\./,
67
+ /package-lock\.json$/,
68
+ /yarn\.lock$/,
69
+ /pnpm-lock\.yaml$/,
70
+ ];
71
+
72
+ /**
73
+ * Recursively find files matching scan extensions
74
+ * @param {string} dir - Directory to walk
75
+ * @param {string} baseDir - Base directory for relative paths
76
+ * @returns {Promise<string[]>} Relative file paths
77
+ */
78
+ async function walkDir(dir, baseDir) {
79
+ const results = [];
80
+ const entries = await readdir(dir, { withFileTypes: true });
81
+
82
+ for (const entry of entries) {
83
+ if (entry.isDirectory()) {
84
+ if (SKIP_DIRS.has(entry.name)) continue;
85
+ const subResults = await walkDir(path.join(dir, entry.name), baseDir);
86
+ results.push(...subResults);
87
+ } else if (entry.isFile()) {
88
+ const ext = path.extname(entry.name);
89
+ if (!SCAN_EXTENSIONS.has(ext)) continue;
90
+
91
+ const relPath = path.relative(baseDir, path.join(dir, entry.name));
92
+ if (SKIP_FILE_PATTERNS.some((p) => p.test(relPath))) continue;
93
+
94
+ results.push(relPath);
95
+ }
96
+ }
97
+
98
+ return results;
99
+ }
100
+
101
+ /**
102
+ * Create a secrets scanning runner
103
+ * @param {Object} [deps] - Injectable dependencies for testing
104
+ * @param {Function} [deps.glob] - Glob function (pattern, options) => string[]
105
+ * @param {Function} [deps.readFile] - File reader (path) => string
106
+ * @param {string[]} [deps.extraIgnore] - Additional exclusion patterns
107
+ * @returns {Function} Runner function: (projectPath, options) => { passed, findings }
108
+ */
109
+ export function createSecretsRunner(deps = {}) {
110
+ const {
111
+ glob: globFn,
112
+ readFile: readFileFn,
113
+ extraIgnore = [],
114
+ } = deps;
115
+
116
+ const ignorePatterns = [...DEFAULT_IGNORE, ...extraIgnore];
117
+
118
+ return async (projectPath, options = {}) => {
119
+ let files;
120
+
121
+ if (globFn) {
122
+ // Use injected glob (testing)
123
+ files = await globFn(DEFAULT_GLOB, {
124
+ cwd: projectPath,
125
+ ignore: ignorePatterns,
126
+ });
127
+ } else {
128
+ // Use built-in recursive walker (production)
129
+ try {
130
+ files = await walkDir(projectPath, projectPath);
131
+ } catch {
132
+ files = [];
133
+ }
134
+ }
135
+
136
+ if (files.length === 0) {
137
+ return { passed: true, findings: [] };
138
+ }
139
+
140
+ const findings = [];
141
+
142
+ for (const file of files) {
143
+ let content;
144
+ if (readFileFn) {
145
+ content = await readFileFn(file);
146
+ } else {
147
+ content = await fsReadFile(path.join(projectPath, file), 'utf-8');
148
+ }
149
+
150
+ const lines = content.split('\n');
151
+
152
+ for (let i = 0; i < lines.length; i++) {
153
+ const line = lines[i];
154
+
155
+ for (const secretPattern of SECRET_PATTERNS) {
156
+ if (secretPattern.pattern.test(line)) {
157
+ findings.push({
158
+ severity: secretPattern.severity,
159
+ file,
160
+ line: i + 1,
161
+ pattern: secretPattern.name,
162
+ match: line.trim().substring(0, 80),
163
+ });
164
+ }
165
+ }
166
+ }
167
+ }
168
+
169
+ return {
170
+ passed: findings.length === 0,
171
+ findings,
172
+ };
173
+ };
174
+ }