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.
- package/.claude/commands/tlc/deploy.md +194 -2
- package/.claude/commands/tlc/e2e-verify.md +214 -0
- package/.claude/commands/tlc/guard.md +191 -0
- package/.claude/commands/tlc/help.md +32 -0
- package/.claude/commands/tlc/init.md +73 -37
- package/.claude/commands/tlc/llm.md +19 -4
- package/.claude/commands/tlc/preflight.md +134 -0
- package/.claude/commands/tlc/review.md +17 -4
- package/.claude/commands/tlc/watchci.md +159 -0
- package/.claude/hooks/tlc-block-tools.sh +41 -0
- package/.claude/hooks/tlc-capture-exchange.sh +50 -0
- package/.claude/hooks/tlc-post-build.sh +38 -0
- package/.claude/hooks/tlc-post-push.sh +22 -0
- package/.claude/hooks/tlc-prompt-guard.sh +69 -0
- package/.claude/hooks/tlc-session-init.sh +123 -0
- package/CLAUDE.md +12 -0
- package/bin/install.js +171 -2
- package/bin/postinstall.js +45 -26
- package/dashboard-web/dist/assets/index-CdS5CHqu.css +1 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js +483 -0
- package/dashboard-web/dist/assets/index-CwNPPVpg.js.map +1 -0
- package/dashboard-web/dist/index.html +2 -2
- package/docker-compose.dev.yml +18 -12
- package/package.json +3 -1
- package/server/index.js +228 -2
- package/server/lib/capture-bridge.js +242 -0
- package/server/lib/capture-bridge.test.js +363 -0
- package/server/lib/capture-guard.js +140 -0
- package/server/lib/capture-guard.test.js +182 -0
- package/server/lib/command-runner.js +159 -0
- package/server/lib/command-runner.test.js +92 -0
- package/server/lib/deploy/runners/dependency-runner.js +106 -0
- package/server/lib/deploy/runners/dependency-runner.test.js +148 -0
- package/server/lib/deploy/runners/secrets-runner.js +174 -0
- package/server/lib/deploy/runners/secrets-runner.test.js +127 -0
- package/server/lib/deploy/security-gates.js +11 -24
- package/server/lib/deploy/security-gates.test.js +9 -2
- package/server/lib/deploy-engine.js +182 -0
- package/server/lib/deploy-engine.test.js +147 -0
- package/server/lib/docker-api.js +137 -0
- package/server/lib/docker-api.test.js +202 -0
- package/server/lib/docker-client.js +297 -0
- package/server/lib/docker-client.test.js +308 -0
- package/server/lib/input-sanitizer.js +86 -0
- package/server/lib/input-sanitizer.test.js +117 -0
- package/server/lib/launchd-agent.js +225 -0
- package/server/lib/launchd-agent.test.js +185 -0
- package/server/lib/memory-api.js +3 -1
- package/server/lib/memory-api.test.js +3 -5
- package/server/lib/memory-bridge-e2e.test.js +160 -0
- package/server/lib/memory-committer.js +18 -4
- package/server/lib/memory-committer.test.js +21 -0
- package/server/lib/memory-hooks-capture.test.js +69 -4
- package/server/lib/memory-hooks-integration.test.js +98 -0
- package/server/lib/memory-hooks.js +42 -4
- package/server/lib/memory-store-adapter.js +105 -0
- package/server/lib/memory-store-adapter.test.js +141 -0
- package/server/lib/memory-wiring-e2e.test.js +93 -0
- package/server/lib/nginx-config.js +114 -0
- package/server/lib/nginx-config.test.js +82 -0
- package/server/lib/ollama-health.js +91 -0
- package/server/lib/ollama-health.test.js +74 -0
- package/server/lib/port-guard.js +44 -0
- package/server/lib/port-guard.test.js +65 -0
- package/server/lib/project-scanner.js +37 -2
- package/server/lib/project-scanner.test.js +152 -0
- package/server/lib/remember-command.js +2 -0
- package/server/lib/remember-command.test.js +23 -0
- package/server/lib/security/crypto-utils.test.js +2 -2
- package/server/lib/semantic-recall.js +1 -1
- package/server/lib/semantic-recall.test.js +17 -0
- package/server/lib/ssh-client.js +184 -0
- package/server/lib/ssh-client.test.js +127 -0
- package/server/lib/vps-api.js +184 -0
- package/server/lib/vps-api.test.js +208 -0
- package/server/lib/vps-bootstrap.js +124 -0
- package/server/lib/vps-bootstrap.test.js +79 -0
- package/server/lib/vps-monitor.js +126 -0
- package/server/lib/vps-monitor.test.js +98 -0
- package/server/lib/workspace-api.js +182 -1
- package/server/lib/workspace-api.test.js +474 -0
- package/server/package-lock.json +737 -0
- package/server/package.json +3 -0
- package/dashboard-web/dist/assets/index-Uhc49PE-.css +0 -1
- package/dashboard-web/dist/assets/index-W36XHPC5.js +0 -431
- 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
|
+
}
|