Skip to content

Commit bc0fdca

Browse files
authored
Merge pull request #12 from decksoftware/feat/provisioning-run-fixes
fix: make --provision-tools actually run Gitleaks/Trivy (Trivy no-DB + transient retry)
2 parents a909db9 + efcca00 commit bc0fdca

2 files changed

Lines changed: 73 additions & 12 deletions

File tree

csreview/src/securityTools.js

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,10 @@ export const RUN_SPECS = {
274274
},
275275
trivy: {
276276
normalize: normalizeTrivyFindings,
277-
argv: (rootDir) => ['fs', '--scanners', 'vuln,misconfig,secret', '--format', 'json', '--quiet', rootDir],
277+
// misconfig + secret only: dependency vulnerabilities are already covered by
278+
// OSV-Scanner, and dropping `vuln` avoids Trivy's large vulnerability-DB
279+
// download (which otherwise blows the run timeout on the first scan).
280+
argv: (rootDir) => ['fs', '--scanners', 'misconfig,secret', '--format', 'json', '--quiet', rootDir],
278281
parsesStdout: true,
279282
},
280283
};
@@ -285,25 +288,43 @@ export const RUN_SPECS = {
285288
* checksum-verified tool path). Fail-open: any error yields available:false.
286289
*
287290
* @param {string} toolKey
288-
* @param {{ rootDir: string, toolPath: string, exec: (path: string, argv: string[]) => Promise<{stdout: string, stderr?: string}> }} opts
291+
* @param {{ rootDir: string, toolPath: string, exec: (path: string, argv: string[]) => Promise<{stdout: string, stderr?: string}>, retryDelayMs?: number }} opts
289292
* @returns {Promise<{tool: string, available: boolean, findings: Array<object>, rawCount: number, error?: string}>}
290293
*/
291294
export async function runSecurityTool(toolKey, opts) {
292295
const spec = RUN_SPECS[toolKey];
293296
if (!spec) return { tool: toolKey, available: false, findings: [], rawCount: 0, error: `unknown tool ${toolKey}` };
294-
try {
297+
298+
const runOnce = async () => {
295299
const { stdout } = await opts.exec(opts.toolPath, spec.argv(opts.rootDir));
296300
const parsed = JSON.parse(stdout || (toolKey === 'gitleaks' ? '[]' : '{}'));
297301
const findings = spec.normalize(parsed);
298302
return { tool: toolKey, available: true, findings, rawCount: findings.length };
299-
} catch (err) {
300-
return {
301-
tool: toolKey,
302-
available: false,
303-
findings: [],
304-
rawCount: 0,
305-
error: err && err.message ? err.message : String(err),
306-
};
303+
};
304+
305+
try {
306+
return await runOnce();
307+
} catch (firstErr) {
308+
const message = firstErr && firstErr.message ? firstErr.message : String(firstErr);
309+
// Never retry a timeout (that would double a slow scan). DO retry a transient
310+
// failure once — a freshly provisioned binary can be briefly locked on Windows
311+
// (e.g. by antivirus) the instant after it is downloaded.
312+
const isTimeout = Boolean(firstErr && (firstErr.killed || /timed?\s*out|ETIMEDOUT/i.test(message)));
313+
if (isTimeout) {
314+
return { tool: toolKey, available: false, findings: [], rawCount: 0, error: message };
315+
}
316+
await new Promise((resolve) => setTimeout(resolve, opts.retryDelayMs == null ? 400 : opts.retryDelayMs));
317+
try {
318+
return await runOnce();
319+
} catch (secondErr) {
320+
return {
321+
tool: toolKey,
322+
available: false,
323+
findings: [],
324+
rawCount: 0,
325+
error: secondErr && secondErr.message ? secondErr.message : String(secondErr),
326+
};
327+
}
307328
}
308329
}
309330

csreview/test/security-tools.test.js

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,14 +190,54 @@ test('runSecurityTool parses injected stdout and is fail-open on errors', async
190190
const throwExec = async () => {
191191
throw new Error('ENOENT');
192192
};
193-
const fail = await runSecurityTool('gitleaks', { rootDir: '/p', toolPath: 'gitleaks', exec: throwExec });
193+
const fail = await runSecurityTool('gitleaks', {
194+
rootDir: '/p',
195+
toolPath: 'gitleaks',
196+
exec: throwExec,
197+
retryDelayMs: 0,
198+
});
194199
assert.equal(fail.available, false);
195200
assert.match(fail.error, /ENOENT/);
196201

197202
const unknown = await runSecurityTool('nope', { rootDir: '/p', toolPath: 'x', exec: okExec });
198203
assert.equal(unknown.available, false);
199204
});
200205

206+
test('runSecurityTool retries once on a transient failure but not on a timeout', async () => {
207+
// Transient: first call throws (e.g. just-provisioned binary briefly locked), second succeeds.
208+
let calls = 0;
209+
const flakyExec = async () => {
210+
calls += 1;
211+
if (calls === 1) throw new Error('EBUSY: resource busy or locked');
212+
return { stdout: JSON.stringify([{ RuleID: 'x', File: 'a.js', StartLine: 1, Secret: 'zzzz' }]) };
213+
};
214+
const retried = await runSecurityTool('gitleaks', {
215+
rootDir: '/p',
216+
toolPath: 'gitleaks',
217+
exec: flakyExec,
218+
retryDelayMs: 0,
219+
});
220+
assert.equal(retried.available, true);
221+
assert.equal(calls, 2);
222+
223+
// Timeout: must NOT retry (would double a slow scan).
224+
let timeoutCalls = 0;
225+
const timeoutExec = async () => {
226+
timeoutCalls += 1;
227+
const e = new Error('Command failed: timed out');
228+
/** @type {any} */ (e).killed = true;
229+
throw e;
230+
};
231+
const timedOut = await runSecurityTool('trivy', {
232+
rootDir: '/p',
233+
toolPath: 'trivy',
234+
exec: timeoutExec,
235+
retryDelayMs: 0,
236+
});
237+
assert.equal(timedOut.available, false);
238+
assert.equal(timeoutCalls, 1);
239+
});
240+
201241
test('runSecurityTool passes the rootDir only as argv (no shell interpolation)', async () => {
202242
/** @type {string[]|null} */
203243
let capturedArgv = null;

0 commit comments

Comments
 (0)