@syntesseraai/opencode-feature-factory 0.1.18 → 0.1.20
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/package.json +2 -1
- package/src/index.ts +44 -8
- package/src/stop-quality-gate.test.ts +359 -30
- package/src/stop-quality-gate.ts +98 -36
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@syntesseraai/opencode-feature-factory",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.20",
|
|
5
5
|
"description": "OpenCode plugin for Feature Factory agents - provides planning, implementation, review, testing, and validation agents",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@opencode-ai/plugin": "^1.1.23"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
+
"@types/bun": "^1.2.6",
|
|
31
32
|
"@types/node": "^22.0.0",
|
|
32
33
|
"typescript": "^5.0.0"
|
|
33
34
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,10 +1,38 @@
|
|
|
1
|
-
import type { Plugin, Hooks } from '@opencode-ai/plugin';
|
|
1
|
+
import type { Plugin, Hooks, PluginInput } from '@opencode-ai/plugin';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { mkdir, access, writeFile, readFile } from 'node:fs/promises';
|
|
5
5
|
import { constants as fsConstants } from 'node:fs';
|
|
6
6
|
import { createQualityGateHooks } from './stop-quality-gate';
|
|
7
7
|
|
|
8
|
+
const SERVICE_NAME = 'feature-factory';
|
|
9
|
+
|
|
10
|
+
type Client = PluginInput['client'];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Log a message using the OpenCode client's structured logging.
|
|
14
|
+
* Silently fails if logging is unavailable.
|
|
15
|
+
*/
|
|
16
|
+
async function log(
|
|
17
|
+
client: Client,
|
|
18
|
+
level: 'debug' | 'info' | 'warn' | 'error',
|
|
19
|
+
message: string,
|
|
20
|
+
extra?: Record<string, unknown>
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
try {
|
|
23
|
+
await client.app.log({
|
|
24
|
+
body: {
|
|
25
|
+
service: SERVICE_NAME,
|
|
26
|
+
level,
|
|
27
|
+
message,
|
|
28
|
+
extra,
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
} catch {
|
|
32
|
+
// Logging failure should not affect plugin operation
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
8
36
|
/**
|
|
9
37
|
* List of agent templates to sync to projects.
|
|
10
38
|
* Each entry maps a destination filename to its template path relative to this module.
|
|
@@ -147,7 +175,7 @@ function mergeHooks(...hookSets: Partial<Hooks>[]): Hooks {
|
|
|
147
175
|
* - If management/ci.sh does not exist, quality gate does not run
|
|
148
176
|
*/
|
|
149
177
|
export const FeatureFactoryPlugin: Plugin = async (input) => {
|
|
150
|
-
const { worktree, directory } = input;
|
|
178
|
+
const { worktree, directory, client } = input;
|
|
151
179
|
const rootDir = resolveRootDir({ worktree, directory });
|
|
152
180
|
|
|
153
181
|
// Skip if no valid directory (e.g., global config with no project)
|
|
@@ -158,16 +186,21 @@ export const FeatureFactoryPlugin: Plugin = async (input) => {
|
|
|
158
186
|
// Run initial sync on plugin load (silent - errors are swallowed)
|
|
159
187
|
try {
|
|
160
188
|
await ensureAgentsInstalled(rootDir);
|
|
161
|
-
} catch {
|
|
162
|
-
|
|
189
|
+
} catch (error) {
|
|
190
|
+
await log(client, 'warn', 'agent-sync.init-error', {
|
|
191
|
+
rootDir,
|
|
192
|
+
error: String(error),
|
|
193
|
+
});
|
|
163
194
|
}
|
|
164
195
|
|
|
165
196
|
// Create quality gate hooks
|
|
166
197
|
let qualityGateHooks: Partial<Hooks> = {};
|
|
167
198
|
try {
|
|
168
199
|
qualityGateHooks = await createQualityGateHooks(input);
|
|
169
|
-
} catch {
|
|
170
|
-
|
|
200
|
+
} catch (error) {
|
|
201
|
+
await log(client, 'error', 'quality-gate.init-error', {
|
|
202
|
+
error: String(error),
|
|
203
|
+
});
|
|
171
204
|
}
|
|
172
205
|
|
|
173
206
|
// Agent sync hooks
|
|
@@ -177,8 +210,11 @@ export const FeatureFactoryPlugin: Plugin = async (input) => {
|
|
|
177
210
|
if (event.type === 'installation.updated') {
|
|
178
211
|
try {
|
|
179
212
|
await ensureAgentsInstalled(rootDir);
|
|
180
|
-
} catch {
|
|
181
|
-
|
|
213
|
+
} catch (error) {
|
|
214
|
+
await log(client, 'warn', 'agent-sync.update-error', {
|
|
215
|
+
rootDir,
|
|
216
|
+
error: String(error),
|
|
217
|
+
});
|
|
182
218
|
}
|
|
183
219
|
}
|
|
184
220
|
},
|
|
@@ -6,33 +6,7 @@
|
|
|
6
6
|
* - isSessionReadOnly: determines if session has write permissions
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
// This allows us to test the logic without modifying the source file exports
|
|
11
|
-
|
|
12
|
-
const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
|
13
|
-
// AWS Access Key IDs
|
|
14
|
-
{ pattern: /AKIA[0-9A-Z]{16}/g, replacement: '[REDACTED_AWS_KEY]' },
|
|
15
|
-
// Bearer tokens
|
|
16
|
-
{ pattern: /Bearer\s+[\w\-.]+/gi, replacement: 'Bearer [REDACTED]' },
|
|
17
|
-
// API keys (api_key, api-key, apikey, apikeys, etc.)
|
|
18
|
-
{ pattern: /api[_-]?keys?[=:\s]+['"]?[\w-]+['"]?/gi, replacement: 'api_key=[REDACTED]' },
|
|
19
|
-
// Tokens (token, tokens)
|
|
20
|
-
{ pattern: /tokens?[=:\s]+['"]?[\w-]+['"]?/gi, replacement: 'token=[REDACTED]' },
|
|
21
|
-
// Passwords
|
|
22
|
-
{ pattern: /passwords?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'password=[REDACTED]' },
|
|
23
|
-
// Generic secrets
|
|
24
|
-
{ pattern: /secrets?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'secret=[REDACTED]' },
|
|
25
|
-
// Base64-encoded long strings that look like secrets (40+ chars of base64 alphabet)
|
|
26
|
-
{ pattern: /[A-Za-z0-9+/]{40,}={0,2}/g, replacement: '[REDACTED_BASE64]' },
|
|
27
|
-
];
|
|
28
|
-
|
|
29
|
-
function sanitizeOutput(output: string): string {
|
|
30
|
-
let sanitized = output;
|
|
31
|
-
for (const { pattern, replacement } of SECRET_PATTERNS) {
|
|
32
|
-
sanitized = sanitized.replace(pattern, replacement);
|
|
33
|
-
}
|
|
34
|
-
return sanitized;
|
|
35
|
-
}
|
|
9
|
+
import { SECRET_PATTERNS, sanitizeOutput } from './stop-quality-gate';
|
|
36
10
|
|
|
37
11
|
interface PermissionRule {
|
|
38
12
|
permission: string;
|
|
@@ -68,6 +42,78 @@ describe('sanitizeOutput', () => {
|
|
|
68
42
|
});
|
|
69
43
|
});
|
|
70
44
|
|
|
45
|
+
describe('GitHub tokens', () => {
|
|
46
|
+
it('should redact GitHub Personal Access Tokens (classic)', () => {
|
|
47
|
+
const input = 'Using ghp_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
|
|
48
|
+
const result = sanitizeOutput(input);
|
|
49
|
+
expect(result).toBe('Using [REDACTED_GH_TOKEN]');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should redact GitHub Personal Access Tokens (fine-grained)', () => {
|
|
53
|
+
const input = 'Using github_pat_11ABCDEFG_abcdefghijklmnopqrstuvwxyz';
|
|
54
|
+
const result = sanitizeOutput(input);
|
|
55
|
+
expect(result).toBe('Using [REDACTED_GH_TOKEN]');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should redact GitHub OAuth tokens', () => {
|
|
59
|
+
const input = 'oauth=gho_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
|
|
60
|
+
const result = sanitizeOutput(input);
|
|
61
|
+
expect(result).toBe('oauth=[REDACTED_GH_TOKEN]');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should redact GitHub App user-to-server tokens', () => {
|
|
65
|
+
const input = 'Using ghu_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
|
|
66
|
+
const result = sanitizeOutput(input);
|
|
67
|
+
expect(result).toBe('Using [REDACTED_GH_TOKEN]');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should redact GitHub App server-to-server tokens', () => {
|
|
71
|
+
const input = 'Using ghs_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
|
|
72
|
+
const result = sanitizeOutput(input);
|
|
73
|
+
expect(result).toBe('Using [REDACTED_GH_TOKEN]');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should not redact partial GitHub token patterns', () => {
|
|
77
|
+
const input = 'ghp_abc'; // Too short
|
|
78
|
+
const result = sanitizeOutput(input);
|
|
79
|
+
expect(result).toBe('ghp_abc');
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('GitLab tokens', () => {
|
|
84
|
+
it('should redact GitLab Personal Access Tokens', () => {
|
|
85
|
+
const input = 'Using glpat-abcdefghij1234567890';
|
|
86
|
+
const result = sanitizeOutput(input);
|
|
87
|
+
expect(result).toBe('Using [REDACTED_GITLAB_TOKEN]');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should redact GitLab tokens with hyphens', () => {
|
|
91
|
+
const input = 'Using glpat-abc-def-ghi-jkl-mnop-qrs';
|
|
92
|
+
const result = sanitizeOutput(input);
|
|
93
|
+
expect(result).toBe('Using [REDACTED_GITLAB_TOKEN]');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should not redact partial GitLab token patterns', () => {
|
|
97
|
+
const input = 'glpat-short'; // Too short
|
|
98
|
+
const result = sanitizeOutput(input);
|
|
99
|
+
expect(result).toBe('glpat-short');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('npm tokens', () => {
|
|
104
|
+
it('should redact npm tokens', () => {
|
|
105
|
+
const input = 'Using npm_aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789';
|
|
106
|
+
const result = sanitizeOutput(input);
|
|
107
|
+
expect(result).toBe('Using [REDACTED_NPM_TOKEN]');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should not redact partial npm token patterns', () => {
|
|
111
|
+
const input = 'npm_abc'; // Too short
|
|
112
|
+
const result = sanitizeOutput(input);
|
|
113
|
+
expect(result).toBe('npm_abc');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
71
117
|
describe('Bearer tokens', () => {
|
|
72
118
|
it('should redact Bearer tokens', () => {
|
|
73
119
|
const input = 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
|
|
@@ -109,14 +155,44 @@ describe('sanitizeOutput', () => {
|
|
|
109
155
|
});
|
|
110
156
|
|
|
111
157
|
describe('tokens', () => {
|
|
112
|
-
it('should redact token assignments', () => {
|
|
158
|
+
it('should redact token assignments with 8+ char values', () => {
|
|
113
159
|
const input = 'token=ghp_xxxxxxxxxxxxxxxxxxxx';
|
|
114
160
|
const result = sanitizeOutput(input);
|
|
115
161
|
expect(result).toBe('token=[REDACTED]');
|
|
116
162
|
});
|
|
117
163
|
|
|
118
|
-
it('should redact tokens plural', () => {
|
|
119
|
-
const input = 'tokens:
|
|
164
|
+
it('should redact tokens plural with 8+ char values', () => {
|
|
165
|
+
const input = 'tokens: secret12345';
|
|
166
|
+
const result = sanitizeOutput(input);
|
|
167
|
+
expect(result).toBe('token=[REDACTED]');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should NOT redact token with short values (less than 8 chars)', () => {
|
|
171
|
+
const input = 'token=abc123';
|
|
172
|
+
const result = sanitizeOutput(input);
|
|
173
|
+
expect(result).toBe('token=abc123');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should NOT redact phrases like "token count: 5" (value too short)', () => {
|
|
177
|
+
const input = 'token count: 5';
|
|
178
|
+
const result = sanitizeOutput(input);
|
|
179
|
+
expect(result).toBe('token count: 5');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should NOT redact "token: abc" (value too short)', () => {
|
|
183
|
+
const input = 'token: abc';
|
|
184
|
+
const result = sanitizeOutput(input);
|
|
185
|
+
expect(result).toBe('token: abc');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should redact actual token values that are 8+ chars', () => {
|
|
189
|
+
const input = 'token=abcd1234efgh';
|
|
190
|
+
const result = sanitizeOutput(input);
|
|
191
|
+
expect(result).toBe('token=[REDACTED]');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should redact quoted tokens with 8+ char values', () => {
|
|
195
|
+
const input = 'token="my-secret-token-value"';
|
|
120
196
|
const result = sanitizeOutput(input);
|
|
121
197
|
expect(result).toBe('token=[REDACTED]');
|
|
122
198
|
});
|
|
@@ -175,6 +251,33 @@ describe('sanitizeOutput', () => {
|
|
|
175
251
|
const result = sanitizeOutput(base64);
|
|
176
252
|
expect(result).toBe('[REDACTED_BASE64]');
|
|
177
253
|
});
|
|
254
|
+
|
|
255
|
+
it('should redact base64 strings up to 500 chars (ReDoS prevention)', () => {
|
|
256
|
+
// Generate a 500-char base64 string
|
|
257
|
+
const base64 = 'A'.repeat(500);
|
|
258
|
+
const result = sanitizeOutput(base64);
|
|
259
|
+
expect(result).toBe('[REDACTED_BASE64]');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should handle base64 strings over 500 chars by matching only first 500 (ReDoS prevention)', () => {
|
|
263
|
+
// Generate a 501-char base64 string - only first 500 chars match
|
|
264
|
+
const base64 = 'A'.repeat(501);
|
|
265
|
+
const result = sanitizeOutput(base64);
|
|
266
|
+
// Pattern matches the first 500 chars, leaving 1 char behind
|
|
267
|
+
expect(result).toBe('[REDACTED_BASE64]A');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('should handle base64 at exactly 40 chars (minimum threshold)', () => {
|
|
271
|
+
const base64 = 'A'.repeat(40);
|
|
272
|
+
const result = sanitizeOutput(base64);
|
|
273
|
+
expect(result).toBe('[REDACTED_BASE64]');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should NOT redact base64 strings under 40 chars', () => {
|
|
277
|
+
const base64 = 'A'.repeat(39);
|
|
278
|
+
const result = sanitizeOutput(base64);
|
|
279
|
+
expect(result).toBe(base64);
|
|
280
|
+
});
|
|
178
281
|
});
|
|
179
282
|
|
|
180
283
|
describe('multiple secrets', () => {
|
|
@@ -194,6 +297,232 @@ describe('sanitizeOutput', () => {
|
|
|
194
297
|
});
|
|
195
298
|
});
|
|
196
299
|
|
|
300
|
+
describe('private keys', () => {
|
|
301
|
+
it('should redact RSA private keys', () => {
|
|
302
|
+
const input = `-----BEGIN RSA PRIVATE KEY-----
|
|
303
|
+
MIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy
|
|
304
|
+
-----END RSA PRIVATE KEY-----`;
|
|
305
|
+
const result = sanitizeOutput(input);
|
|
306
|
+
expect(result).toBe('[REDACTED_PRIVATE_KEY]');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should redact OpenSSH private keys', () => {
|
|
310
|
+
const input = `-----BEGIN OPENSSH PRIVATE KEY-----
|
|
311
|
+
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAA
|
|
312
|
+
-----END OPENSSH PRIVATE KEY-----`;
|
|
313
|
+
const result = sanitizeOutput(input);
|
|
314
|
+
expect(result).toBe('[REDACTED_PRIVATE_KEY]');
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('should redact EC private keys', () => {
|
|
318
|
+
const input = `-----BEGIN EC PRIVATE KEY-----
|
|
319
|
+
MHQCAQEEICg7E4NN6YPWoU6/FXa5ON6Pt6LKBfA8WL
|
|
320
|
+
-----END EC PRIVATE KEY-----`;
|
|
321
|
+
const result = sanitizeOutput(input);
|
|
322
|
+
expect(result).toBe('[REDACTED_PRIVATE_KEY]');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('should redact generic private keys', () => {
|
|
326
|
+
const input = `-----BEGIN PRIVATE KEY-----
|
|
327
|
+
MIIEvgIBADANBgkqhkiG9w0BAQEFAASC
|
|
328
|
+
-----END PRIVATE KEY-----`;
|
|
329
|
+
const result = sanitizeOutput(input);
|
|
330
|
+
expect(result).toBe('[REDACTED_PRIVATE_KEY]');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('should redact private keys embedded in output', () => {
|
|
334
|
+
const input = `Loading configuration...
|
|
335
|
+
-----BEGIN RSA PRIVATE KEY-----
|
|
336
|
+
MIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGy
|
|
337
|
+
-----END RSA PRIVATE KEY-----
|
|
338
|
+
Done loading.`;
|
|
339
|
+
const result = sanitizeOutput(input);
|
|
340
|
+
expect(result).toBe(`Loading configuration...
|
|
341
|
+
[REDACTED_PRIVATE_KEY]
|
|
342
|
+
Done loading.`);
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
describe('GCP credentials', () => {
|
|
347
|
+
it('should redact GCP API keys', () => {
|
|
348
|
+
const input = 'Using GCP key: AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe';
|
|
349
|
+
const result = sanitizeOutput(input);
|
|
350
|
+
expect(result).toBe('Using GCP key: [REDACTED_GCP_KEY]');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('should redact multiple GCP API keys', () => {
|
|
354
|
+
const input =
|
|
355
|
+
'Key1: AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe Key2: AIzaSyB-1234567890abcdefghijklmnopqrstu';
|
|
356
|
+
const result = sanitizeOutput(input);
|
|
357
|
+
expect(result).toBe('Key1: [REDACTED_GCP_KEY] Key2: [REDACTED_GCP_KEY]');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should not redact partial GCP API key patterns', () => {
|
|
361
|
+
const input = 'AIza123'; // Too short
|
|
362
|
+
const result = sanitizeOutput(input);
|
|
363
|
+
expect(result).toBe('AIza123');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('should redact GCP OAuth tokens', () => {
|
|
367
|
+
const input = 'Authorization: ya29.a0AfH6SMBx-example-token-value_123';
|
|
368
|
+
const result = sanitizeOutput(input);
|
|
369
|
+
expect(result).toBe('Authorization: [REDACTED_GCP_TOKEN]');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should redact GCP OAuth tokens with various characters', () => {
|
|
373
|
+
const input = 'token=ya29.Gl-abc_XYZ-123';
|
|
374
|
+
const result = sanitizeOutput(input);
|
|
375
|
+
expect(result).toBe('token=[REDACTED_GCP_TOKEN]');
|
|
376
|
+
});
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe('Slack tokens', () => {
|
|
380
|
+
it('should redact Slack bot tokens', () => {
|
|
381
|
+
const input = 'SLACK_BOT_TOKEN=xoxb-123456789012-1234567890123-AbCdEfGhIjKlMnOpQrStUvWx';
|
|
382
|
+
const result = sanitizeOutput(input);
|
|
383
|
+
expect(result).toBe('SLACK_BOT_TOKEN=[REDACTED_SLACK_TOKEN]');
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should redact Slack user tokens', () => {
|
|
387
|
+
const input =
|
|
388
|
+
'Using xoxp-123456789012-123456789012-123456789012-abcdef1234567890abcdef1234567890';
|
|
389
|
+
const result = sanitizeOutput(input);
|
|
390
|
+
expect(result).toBe('Using [REDACTED_SLACK_TOKEN]');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('should redact Slack app tokens', () => {
|
|
394
|
+
const input =
|
|
395
|
+
'APP_TOKEN=xapp-1-A0123BCDEFG-1234567890123-abcdefghijklmnopqrstuvwxyz0123456789';
|
|
396
|
+
const result = sanitizeOutput(input);
|
|
397
|
+
expect(result).toBe('APP_TOKEN=[REDACTED_SLACK_TOKEN]');
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should redact Slack webhook URLs', () => {
|
|
401
|
+
const input =
|
|
402
|
+
'Webhook: https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX';
|
|
403
|
+
const result = sanitizeOutput(input);
|
|
404
|
+
expect(result).toBe('Webhook: https://[REDACTED_SLACK_WEBHOOK]');
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should redact multiple different Slack token types', () => {
|
|
408
|
+
const input = `
|
|
409
|
+
Bot: xoxb-123-456-abc
|
|
410
|
+
User: xoxp-789-012-def
|
|
411
|
+
App: xapp-345-ghi
|
|
412
|
+
`;
|
|
413
|
+
const result = sanitizeOutput(input);
|
|
414
|
+
expect(result).toContain('[REDACTED_SLACK_TOKEN]');
|
|
415
|
+
expect(result).not.toContain('xoxb-');
|
|
416
|
+
expect(result).not.toContain('xoxp-');
|
|
417
|
+
expect(result).not.toContain('xapp-');
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe('Stripe keys', () => {
|
|
422
|
+
it('should redact Stripe live secret keys', () => {
|
|
423
|
+
// 24 chars after sk_live_: 51ABC123DEF456GHI789JKLM
|
|
424
|
+
const input = 'STRIPE_SECRET_KEY=sk_live_51ABC123DEF456GHI789JKLM';
|
|
425
|
+
const result = sanitizeOutput(input);
|
|
426
|
+
expect(result).toBe('STRIPE_SECRET_KEY=[REDACTED_STRIPE_KEY]');
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('should redact Stripe test secret keys', () => {
|
|
430
|
+
// 24 chars after sk_test_: 51ABC123DEF456GHI789JKLM
|
|
431
|
+
const input = 'Using sk_test_51ABC123DEF456GHI789JKLM for testing';
|
|
432
|
+
const result = sanitizeOutput(input);
|
|
433
|
+
expect(result).toBe('Using [REDACTED_STRIPE_KEY] for testing');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('should redact Stripe live restricted keys', () => {
|
|
437
|
+
// 24 chars after rk_live_: 51ABC123DEF456GHI789JKLM
|
|
438
|
+
const input = 'RESTRICTED_KEY=rk_live_51ABC123DEF456GHI789JKLM';
|
|
439
|
+
const result = sanitizeOutput(input);
|
|
440
|
+
expect(result).toBe('RESTRICTED_KEY=[REDACTED_STRIPE_KEY]');
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('should redact Stripe test restricted keys', () => {
|
|
444
|
+
// 24 chars after rk_test_: 51ABC123DEF456GHI789JKLM
|
|
445
|
+
const input = 'Using rk_test_51ABC123DEF456GHI789JKLM for testing';
|
|
446
|
+
const result = sanitizeOutput(input);
|
|
447
|
+
expect(result).toBe('Using [REDACTED_STRIPE_KEY] for testing');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('should not redact Stripe keys that are too short', () => {
|
|
451
|
+
const input = 'sk_live_short'; // Less than 24 characters after prefix
|
|
452
|
+
const result = sanitizeOutput(input);
|
|
453
|
+
expect(result).toBe('sk_live_short');
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('should redact multiple Stripe keys', () => {
|
|
457
|
+
// 24 chars after each prefix
|
|
458
|
+
const input = 'Live: sk_live_51ABC123DEF456GHI789JKLM Test: sk_test_51XYZ789ABC123DEF456GHIJ';
|
|
459
|
+
const result = sanitizeOutput(input);
|
|
460
|
+
expect(result).toBe('Live: [REDACTED_STRIPE_KEY] Test: [REDACTED_STRIPE_KEY]');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('should redact long Stripe keys', () => {
|
|
464
|
+
const input = 'sk_live_51ABC123DEF456GHI789JKLmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
|
|
465
|
+
const result = sanitizeOutput(input);
|
|
466
|
+
expect(result).toBe('[REDACTED_STRIPE_KEY]');
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe('database connection strings', () => {
|
|
471
|
+
it('should redact PostgreSQL connection strings', () => {
|
|
472
|
+
const input = 'DATABASE_URL=postgres://admin:secretpass123@db.example.com:5432/mydb';
|
|
473
|
+
const result = sanitizeOutput(input);
|
|
474
|
+
expect(result).toBe('DATABASE_URL=[REDACTED_CONNECTION_STRING]');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('should redact MongoDB connection strings with +srv', () => {
|
|
478
|
+
const input = 'Connecting to mongodb+srv://user:p@ssw0rd@cluster.mongodb.net/database';
|
|
479
|
+
const result = sanitizeOutput(input);
|
|
480
|
+
expect(result).toBe('Connecting to [REDACTED_CONNECTION_STRING]');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('should redact Redis connection strings', () => {
|
|
484
|
+
const input = 'REDIS_URL=redis://default:myredispassword@redis.example.com:6379';
|
|
485
|
+
const result = sanitizeOutput(input);
|
|
486
|
+
expect(result).toBe('REDIS_URL=[REDACTED_CONNECTION_STRING]');
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it('should redact MySQL connection strings', () => {
|
|
490
|
+
const input = 'mysql://root:rootpassword@localhost:3306/testdb';
|
|
491
|
+
const result = sanitizeOutput(input);
|
|
492
|
+
expect(result).toBe('[REDACTED_CONNECTION_STRING]');
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it('should redact rediss (TLS) connection strings', () => {
|
|
496
|
+
const input = 'rediss://user:password@secure-redis.example.com:6380';
|
|
497
|
+
const result = sanitizeOutput(input);
|
|
498
|
+
expect(result).toBe('[REDACTED_CONNECTION_STRING]');
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should redact connection strings with URL-encoded passwords', () => {
|
|
502
|
+
const input = 'mongodb://user:p%40ss%23word@host/db';
|
|
503
|
+
const result = sanitizeOutput(input);
|
|
504
|
+
expect(result).toBe('[REDACTED_CONNECTION_STRING]');
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('should redact PostgreSQL with URL-encoded @ in password', () => {
|
|
508
|
+
const input = 'postgres://admin:secret%40pass@db.example.com:5432/mydb';
|
|
509
|
+
const result = sanitizeOutput(input);
|
|
510
|
+
expect(result).toBe('[REDACTED_CONNECTION_STRING]');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('should redact connection strings with multiple URL-encoded characters', () => {
|
|
514
|
+
const input = 'mysql://root:p%40ss%3Dw%26rd%21@localhost:3306/testdb';
|
|
515
|
+
const result = sanitizeOutput(input);
|
|
516
|
+
expect(result).toBe('[REDACTED_CONNECTION_STRING]');
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('should redact MongoDB+srv with URL-encoded password', () => {
|
|
520
|
+
const input = 'mongodb+srv://user:my%40complex%23pass@cluster.mongodb.net/database';
|
|
521
|
+
const result = sanitizeOutput(input);
|
|
522
|
+
expect(result).toBe('[REDACTED_CONNECTION_STRING]');
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
197
526
|
describe('non-secret content', () => {
|
|
198
527
|
it('should preserve normal log output', () => {
|
|
199
528
|
const input = 'Build completed successfully in 2.5s';
|
package/src/stop-quality-gate.ts
CHANGED
|
@@ -6,28 +6,73 @@ const CI_TIMEOUT_MS = 300000; // 5 minutes
|
|
|
6
6
|
const SESSION_TTL_MS = 3600000; // 1 hour
|
|
7
7
|
const CLEANUP_INTERVAL_MS = 600000; // 10 minutes
|
|
8
8
|
|
|
9
|
-
const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
|
9
|
+
export const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [
|
|
10
10
|
// AWS Access Key IDs
|
|
11
11
|
{ pattern: /AKIA[0-9A-Z]{16}/g, replacement: '[REDACTED_AWS_KEY]' },
|
|
12
|
+
// GitHub Personal Access Tokens (classic)
|
|
13
|
+
{ pattern: /ghp_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
|
|
14
|
+
// GitHub Personal Access Tokens (fine-grained)
|
|
15
|
+
{ pattern: /github_pat_[A-Za-z0-9_]{22,}/g, replacement: '[REDACTED_GH_TOKEN]' },
|
|
16
|
+
// GitHub OAuth tokens
|
|
17
|
+
{ pattern: /gho_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
|
|
18
|
+
// GitHub App tokens (user-to-server and server-to-server)
|
|
19
|
+
{ pattern: /ghu_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
|
|
20
|
+
{ pattern: /ghs_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_GH_TOKEN]' },
|
|
21
|
+
// GitLab Personal Access Tokens
|
|
22
|
+
{ pattern: /glpat-[A-Za-z0-9-]{20,}/g, replacement: '[REDACTED_GITLAB_TOKEN]' },
|
|
23
|
+
// npm tokens
|
|
24
|
+
{ pattern: /npm_[A-Za-z0-9]{36}/g, replacement: '[REDACTED_NPM_TOKEN]' },
|
|
25
|
+
// Slack bot tokens
|
|
26
|
+
{ pattern: /xoxb-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
|
|
27
|
+
// Slack user tokens
|
|
28
|
+
{ pattern: /xoxp-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
|
|
29
|
+
// Slack app tokens
|
|
30
|
+
{ pattern: /xapp-[0-9A-Za-z-]+/g, replacement: '[REDACTED_SLACK_TOKEN]' },
|
|
31
|
+
// Slack webhook URLs
|
|
32
|
+
{ pattern: /hooks\.slack\.com\/services\/[A-Z0-9/]+/g, replacement: '[REDACTED_SLACK_WEBHOOK]' },
|
|
33
|
+
// Stripe live secret keys
|
|
34
|
+
{ pattern: /sk_live_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
|
|
35
|
+
// Stripe test secret keys
|
|
36
|
+
{ pattern: /sk_test_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
|
|
37
|
+
// Stripe live restricted keys
|
|
38
|
+
{ pattern: /rk_live_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
|
|
39
|
+
// Stripe test restricted keys
|
|
40
|
+
{ pattern: /rk_test_[0-9a-zA-Z]{24,}/g, replacement: '[REDACTED_STRIPE_KEY]' },
|
|
12
41
|
// Bearer tokens
|
|
13
42
|
{ pattern: /Bearer\s+[\w\-.]+/gi, replacement: 'Bearer [REDACTED]' },
|
|
14
43
|
// API keys (api_key, api-key, apikey, apikeys, etc.)
|
|
15
44
|
{ pattern: /api[_-]?keys?[=:\s]+['"]?[\w-]+['"]?/gi, replacement: 'api_key=[REDACTED]' },
|
|
16
|
-
// Tokens (token, tokens)
|
|
17
|
-
{ pattern: /tokens?[=:\s]+['"]?[
|
|
45
|
+
// Tokens (token, tokens) - require minimum 8 char value to reduce false positives
|
|
46
|
+
{ pattern: /tokens?[=:\s]+['"]?([A-Za-z0-9_-]{8,})['"]?/gi, replacement: 'token=[REDACTED]' },
|
|
18
47
|
// Passwords
|
|
19
48
|
{ pattern: /passwords?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'password=[REDACTED]' },
|
|
20
49
|
// Generic secrets
|
|
21
50
|
{ pattern: /secrets?[=:\s]+['"]?[^\s'"]+['"]?/gi, replacement: 'secret=[REDACTED]' },
|
|
22
|
-
// Base64-encoded long strings that look like secrets (40
|
|
23
|
-
{ pattern: /[A-Za-z0-9+/]{40,}={0,2}/g, replacement: '[REDACTED_BASE64]' },
|
|
51
|
+
// Base64-encoded long strings that look like secrets (40-500 chars to prevent ReDoS)
|
|
52
|
+
{ pattern: /[A-Za-z0-9+/]{40,500}={0,2}/g, replacement: '[REDACTED_BASE64]' },
|
|
53
|
+
// Private keys (RSA, DSA, EC, OpenSSH, etc.)
|
|
54
|
+
{
|
|
55
|
+
pattern: /-----BEGIN[\s\w]+PRIVATE KEY-----[\s\S]*?-----END[\s\w]+PRIVATE KEY-----/g,
|
|
56
|
+
replacement: '[REDACTED_PRIVATE_KEY]',
|
|
57
|
+
},
|
|
58
|
+
// Database connection strings with credentials (postgres, postgresql, mysql, mongodb, redis)
|
|
59
|
+
// Password portion handles URL-encoded characters like %40 (for @) and %23 (for #)
|
|
60
|
+
{
|
|
61
|
+
pattern:
|
|
62
|
+
/(postgres|postgresql|mysql|mongodb(\+srv)?|rediss?):\/\/[^\s/:]+:(?:[^@\s]|%[0-9A-Fa-f]{2})+@[^\s]+/gi,
|
|
63
|
+
replacement: '[REDACTED_CONNECTION_STRING]',
|
|
64
|
+
},
|
|
65
|
+
// GCP API keys
|
|
66
|
+
{ pattern: /AIza[0-9A-Za-z_-]{35}/g, replacement: '[REDACTED_GCP_KEY]' },
|
|
67
|
+
// GCP OAuth tokens
|
|
68
|
+
{ pattern: /ya29\.[0-9A-Za-z_-]+/g, replacement: '[REDACTED_GCP_TOKEN]' },
|
|
24
69
|
];
|
|
25
70
|
|
|
26
71
|
/**
|
|
27
72
|
* Sanitizes CI output by redacting common secret patterns before sending to the LLM.
|
|
28
73
|
* This helps prevent accidental exposure of sensitive information in prompts.
|
|
29
74
|
*/
|
|
30
|
-
function sanitizeOutput(output: string): string {
|
|
75
|
+
export function sanitizeOutput(output: string): string {
|
|
31
76
|
let sanitized = output;
|
|
32
77
|
for (const { pattern, replacement } of SECRET_PATTERNS) {
|
|
33
78
|
sanitized = sanitized.replace(pattern, replacement);
|
|
@@ -202,44 +247,61 @@ export async function createQualityGateHooks(input: PluginInput): Promise<Partia
|
|
|
202
247
|
let ciPassed = false;
|
|
203
248
|
let timedOut = false;
|
|
204
249
|
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
setTimeout(() => {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
250
|
+
const ciPath = `${directory}/management/ci.sh`;
|
|
251
|
+
// eslint-disable-next-line no-undef
|
|
252
|
+
const proc = Bun.spawn(['bash', ciPath], {
|
|
253
|
+
cwd: directory,
|
|
254
|
+
stdout: 'pipe',
|
|
255
|
+
stderr: 'pipe',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
259
|
+
let forceKillTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
260
|
+
const timeoutPromise = new Promise<void>((resolve) => {
|
|
261
|
+
timeoutId = setTimeout(() => {
|
|
262
|
+
timedOut = true;
|
|
263
|
+
// Graceful termination: SIGTERM first, then SIGKILL after grace period
|
|
264
|
+
proc.kill('SIGTERM');
|
|
265
|
+
forceKillTimeoutId = setTimeout(() => {
|
|
266
|
+
// Force kill if still running after grace period
|
|
267
|
+
try {
|
|
268
|
+
proc.kill('SIGKILL');
|
|
269
|
+
} catch {
|
|
270
|
+
// Process already terminated
|
|
271
|
+
}
|
|
272
|
+
}, 5000);
|
|
273
|
+
resolve();
|
|
274
|
+
}, CI_TIMEOUT_MS);
|
|
275
|
+
});
|
|
229
276
|
|
|
230
|
-
|
|
277
|
+
// Race the process completion against the timeout
|
|
278
|
+
await Promise.race([proc.exited, timeoutPromise]);
|
|
231
279
|
|
|
232
|
-
|
|
233
|
-
|
|
280
|
+
// Clear timeouts if process completed before timeout
|
|
281
|
+
if (timeoutId) {
|
|
282
|
+
clearTimeout(timeoutId);
|
|
283
|
+
}
|
|
284
|
+
if (forceKillTimeoutId) {
|
|
285
|
+
clearTimeout(forceKillTimeoutId);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const exitCode = proc.exitCode;
|
|
289
|
+
const stdout = await new Response(proc.stdout).text();
|
|
290
|
+
const stderr = await new Response(proc.stderr).text();
|
|
291
|
+
|
|
292
|
+
if (timedOut) {
|
|
234
293
|
await log(client, 'warn', 'quality-gate.timeout', {
|
|
235
294
|
sessionId,
|
|
236
295
|
timeoutMs: CI_TIMEOUT_MS,
|
|
237
296
|
});
|
|
297
|
+
ciOutput =
|
|
298
|
+
`CI execution timed out after ${CI_TIMEOUT_MS / 1000} seconds\n\n${stdout}\n${stderr}`.trim();
|
|
299
|
+
ciPassed = false;
|
|
300
|
+
} else {
|
|
301
|
+
ciOutput = stdout + (stderr ? `\n${stderr}` : '');
|
|
302
|
+
ciPassed = exitCode === 0;
|
|
238
303
|
}
|
|
239
304
|
|
|
240
|
-
ciOutput = result.output;
|
|
241
|
-
ciPassed = result.passed;
|
|
242
|
-
|
|
243
305
|
state.lastRunAt = Date.now();
|
|
244
306
|
state.dirty = false;
|
|
245
307
|
state.qualityGatePassed = ciPassed;
|