Skip to content

Commit 7f2d38b

Browse files
fix: acceptance tests for secrets test output [PS-357]
1 parent c6af07f commit 7f2d38b

1 file changed

Lines changed: 303 additions & 6 deletions

File tree

test/jest/acceptance/snyk-secrets/snyk-secrets-test-user-journey.spec.ts

Lines changed: 303 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { execSync } from 'child_process';
2-
import { existsSync, unlinkSync } from 'fs';
3-
2+
import { existsSync, unlinkSync, mkdirSync, rmSync, copyFileSync, readdirSync, statSync } from 'fs';
43
import { matchers } from 'jest-json-schema';
54
import { runSnykCLI } from '../../util/runSnykCLI';
65
import { EXIT_CODES } from '../../../../src/cli/exit-codes';
7-
import { resolve } from 'path';
6+
import { join, resolve } from 'path';
7+
import { randomUUID } from 'crypto';
88

99
expect.extend(matchers);
1010
jest.setTimeout(1000 * 180);
@@ -36,7 +36,7 @@ beforeAll(() => {
3636
timeout: 30000,
3737
},
3838
);
39-
} catch (error) {
39+
} catch (error : any) {
4040
throw new Error(
4141
`Failed to clone test repository: ${error.message}. This test requires network access.`,
4242
);
@@ -47,13 +47,80 @@ beforeAll(() => {
4747
afterAll(() => {
4848
if (existsSync(TEMP_LOCAL_PATH)) {
4949
try {
50-
execSync(`rm -rf ${TEMP_LOCAL_PATH}`, { stdio: 'pipe' });
51-
} catch (err) {
50+
rmSync(TEMP_LOCAL_PATH, { recursive: true, force: true });
51+
} catch (err : any) {
5252
console.warn('Failed to cleanup test repository:', err.message);
5353
}
5454
}
5555
});
5656

57+
const copyFolderSync = (from: string, to: string) => {
58+
mkdirSync(to, { recursive: true });
59+
readdirSync(from).forEach(element => {
60+
const fromPath = join(from, element);
61+
const toPath = join(to, element);
62+
if (statSync(fromPath).isFile()) copyFileSync(fromPath, toPath);
63+
else copyFolderSync(fromPath, toPath);
64+
});
65+
};
66+
67+
/**
68+
* Sets up an isolated environment for testing the 'ignore' functionality.
69+
* * Why this is necessary:
70+
* - Generates unique secret identities within a dedicated temporary folder.
71+
* - Isolates local state mutations (like .snyk file creation).
72+
* - Prevents race conditions during concurrent test execution, guaranteeing
73+
* zero side-effects on other acceptance tests.
74+
*/
75+
const setupIsolatedIgnoreEnv = async (basePath: string) => {
76+
const uuid = randomUUID();
77+
const testDir = `${basePath}/ignores_test_${uuid}`;
78+
79+
// Calculate an expiry date 15 minutes from now in YYYY-MM-DDThh:mm:ss.fffZ format
80+
const expiryDate = new Date(Date.now() + 15 * 60000).toISOString();
81+
82+
const cleanup = () => {
83+
if (existsSync(testDir)) {
84+
try {
85+
rmSync(testDir, { recursive: true, force: true });
86+
} catch (err: any) {
87+
console.warn(`Failed to cleanup isolated ignore directory:`, err.message);
88+
}
89+
}
90+
};
91+
92+
try {
93+
mkdirSync(testDir, { recursive: true });
94+
95+
// Copy the same file twice to trigger the same rule ID for multiple locations in SARIF validation
96+
const sourceFile = join(basePath, 'semgrep-rules-examples', 'detected-sendgrid-api-key.txt');
97+
copyFileSync(sourceFile, join(testDir, `sendgrid-keys_1_${uuid}.txt`));
98+
copyFileSync(sourceFile, join(testDir, `sendgrid-keys_2_${uuid}.txt`));
99+
100+
// Run a base JSON scan to extract the exact Issue/Rule IDs for these files
101+
const { stdout: jsonStdout } = await runSnykCLI(`secrets test "${testDir}" --json`, { env });
102+
const jsonOutput = JSON.parse(jsonStdout);
103+
const results = jsonOutput.runs[0].results || [];
104+
105+
const ruleIds = [...new Set(results.map((r: any) => r.ruleId))];
106+
const issuesToIgnore = ruleIds.slice(0, 2);
107+
108+
// Ignore the target issues
109+
for (const [index, issueId] of issuesToIgnore.entries()) {
110+
const reason = `Test ignore reason metadata ${index}`;
111+
await runSnykCLI(
112+
`ignore --id=${issueId} --expiry=${expiryDate} --reason="${reason}"`,
113+
{ env, cwd: testDir }
114+
);
115+
}
116+
117+
return { testDir, cleanup };
118+
} catch (error) {
119+
cleanup();
120+
throw error;
121+
}
122+
};
123+
57124
describe.skip('snyk secrets test', () => {
58125
describe('output formats', () => {
59126
it('should display human-readable output by default', async () => {
@@ -170,6 +237,236 @@ describe.skip('snyk secrets test', () => {
170237
});
171238
});
172239

240+
describe('Human-readable output validation', () => {
241+
it('should generate properly formatted human-readable output and map Finding IDs correctly', async () => {
242+
const { code, stdout, stderr } = await runSnykCLI(
243+
`secrets test ${TEMP_LOCAL_PATH}/${TEST_DIR}`,
244+
{ env },
245+
);
246+
247+
expect(stderr).toBe('');
248+
expect(code).toBe(EXIT_CODES.VULNS_FOUND);
249+
250+
expect(stdout).toContain('Open Secrets issues:');
251+
expect(stdout).toContain('Test Summary');
252+
expect(stdout).toContain('Total secrets issues:');
253+
254+
// Validates: Finding ID is mapped correctly to a UUID string
255+
const uuidRegex = /Finding ID: [0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i;
256+
expect(stdout).toMatch(uuidRegex);
257+
});
258+
259+
it('should omit Finding IDs that start with UNDEFINED', async () => {
260+
// Create a path outside of the repo to remove source used to generate secret identities
261+
const NO_GIT_DIR = `${TEMP_LOCAL_PATH}_nogit`;
262+
263+
try {
264+
// Copy just the test files,
265+
mkdirSync(NO_GIT_DIR, { recursive: true });
266+
copyFolderSync(join(TEMP_LOCAL_PATH, TEST_DIR), NO_GIT_DIR);
267+
268+
const { code, stdout, stderr } = await runSnykCLI(
269+
`secrets test ${NO_GIT_DIR}`,
270+
{ env },
271+
);
272+
273+
expect(stderr).toBe('');
274+
expect(code).toBe(EXIT_CODES.VULNS_FOUND);
275+
276+
// Finding ID should not be included when it starts with UNDEFINED
277+
expect(stdout).not.toContain('Finding ID');
278+
} finally {
279+
try {
280+
rmSync(NO_GIT_DIR, { recursive: true, force: true });
281+
} catch (err: any) {
282+
console.warn(`Failed to cleanup non-git test directory:`, err.message);
283+
}
284+
}
285+
});
286+
287+
it('should correctly render multiple ignores and their metadata in the output', async () => {
288+
const { testDir, cleanup } = await setupIsolatedIgnoreEnv(TEMP_LOCAL_PATH);
289+
290+
try {
291+
// Get human-readable with the ignores included
292+
const { stdout, stderr, code } = await runSnykCLI(
293+
`secrets test ${testDir} --include-ignores`,
294+
{ env, cwd: testDir }
295+
);
296+
297+
expect(stderr).toBe('');
298+
expect(code).toBe(EXIT_CODES.VULNS_FOUND);
299+
300+
// Multiple ignores are rendered properly
301+
expect(stdout).toMatch(/Ignored:\s*[2-9]/);
302+
expect(stdout).toContain('! [IGNORED]');
303+
304+
// Validate ignores metadata is mapped and rendered correctly
305+
// Validates Expiration format
306+
expect(stdout).toMatch(/Expiration:\s+[A-Z][a-z]+\s+\d{2},\s+\d{4}/);
307+
308+
// Validates the Reason field and spacing
309+
expect(stdout).toMatch(/Reason:\s+Test ignore reason metadata 0/);
310+
expect(stdout).toMatch(/Reason:\s+Test ignore reason metadata 1/);
311+
expect(stdout).toMatch(/Ignored on:\s+[A-Z][a-z]+\s+\d{2},\s+\d{4}/);
312+
} finally {
313+
cleanup();
314+
}
315+
});
316+
});
317+
318+
describe('JSON output payload validation', () => {
319+
it('should return a valid SARIF when json flag is used', async () => {
320+
const { code, stdout, stderr } = await runSnykCLI(
321+
`secrets test ${TEMP_LOCAL_PATH}/${TEST_DIR} --json`,
322+
{ env },
323+
);
324+
325+
expect(stderr).toBe('');
326+
expect(code).toBe(EXIT_CODES.VULNS_FOUND);
327+
328+
const output = JSON.parse(stdout);
329+
330+
// Basic SARIF schema requirements
331+
expect(output).toHaveProperty('version');
332+
expect(typeof output.version).toBe('string');
333+
expect(Array.isArray(output.runs)).toBe(true);
334+
expect(output.runs.length).toBeGreaterThan(0);
335+
336+
const run = output.runs[0];
337+
338+
expect(run.tool.driver.name).toBe('Snyk Secrets');
339+
340+
// Results array exists and is populated
341+
expect(Array.isArray(run.results)).toBe(true);
342+
expect(run.results.length).toBeGreaterThan(0);
343+
344+
// Ensure the first result has the expected ruleId mapping
345+
expect(run.results[0]).toHaveProperty('ruleId');
346+
});
347+
});
348+
349+
describe('SARIF output payload validation', () => {
350+
it('should generate an enriched SARIF payload with ignores', async () => {
351+
const { testDir, cleanup } = await setupIsolatedIgnoreEnv(TEMP_LOCAL_PATH);
352+
353+
try {
354+
const { code, stdout, stderr } = await runSnykCLI(
355+
`secrets test "${testDir}" --include-ignores --sarif`,
356+
{ env, cwd: testDir },
357+
);
358+
359+
expect(stderr).toBe('');
360+
expect(code).toBe(EXIT_CODES.VULNS_FOUND);
361+
362+
const sarifOutput = JSON.parse(stdout);
363+
const fingerprintRegex = /^[a-f0-9]{64}$/i;
364+
const slugRegex = /^[a-z0-9-]+$/;
365+
366+
// Only one run is performed
367+
const run = sarifOutput.runs[0];
368+
369+
expect(run.tool.driver.name).toBe('Snyk Secrets');
370+
371+
const rules = run.tool.driver.rules || [];
372+
const ruleIds = rules.map((rule: any) => rule.id);
373+
const uniqueRuleIds = new Set(ruleIds);
374+
375+
// Rules should only be included once in the SARIF, and not multiple times
376+
expect(ruleIds.length).toBe(uniqueRuleIds.size);
377+
378+
rules.forEach((rule: any) => {
379+
expect(rule.id).toMatch(slugRegex);
380+
381+
// Rules should have name
382+
expect(rule).toHaveProperty('name');
383+
384+
// Validates: the properties from the rules include the severity
385+
expect(rule.properties).toBeDefined();
386+
expect(rule.properties).toHaveProperty('severity');
387+
388+
// General structural checks
389+
expect(rule).toHaveProperty('shortDescription.text');
390+
});
391+
392+
let foundMultipleLocations = false;
393+
const results = run.results || [];
394+
395+
results.forEach((result: any) => {
396+
expect(result.ruleId).toMatch(slugRegex);
397+
398+
// Validates: fingerprint is included in the result
399+
expect(result).toHaveProperty('fingerprints');
400+
expect(result.fingerprints).toHaveProperty('fingerprint');
401+
expect(result.fingerprints.fingerprint).toMatch(fingerprintRegex);
402+
403+
expect(Array.isArray(result.locations)).toBe(true);
404+
expect(result.locations.length).toBeGreaterThan(0);
405+
406+
// Tracks if we successfully grouped multiple locations into a single result
407+
if (result.locations.length > 1) {
408+
foundMultipleLocations = true;
409+
}
410+
411+
// Validate ignores metadata includes only these fields: status, justification, kind
412+
if (result.suppressions && result.suppressions.length > 0) {
413+
result.suppressions.forEach((suppression: any) => {
414+
const suppressionKeys = Object.keys(suppression).sort();
415+
const expectedKeys = ['justification', 'kind', 'status'].sort();
416+
expect(suppressionKeys).toEqual(expectedKeys);
417+
});
418+
}
419+
});
420+
421+
expect(foundMultipleLocations).toBe(true);
422+
} finally {
423+
cleanup();
424+
}
425+
});
426+
it('should ensure consistent secret identities regardless of the working directory', async () => {
427+
// Use existing directories from the repo tree to test different path depths
428+
// DIR_A is 1 level deep, DIR_C is 2 levels deep
429+
const DIR_A = `${TEMP_LOCAL_PATH}/auth0`;
430+
const DIR_C = `${TEMP_LOCAL_PATH}/aws/valid`;
431+
432+
const targetDir = 'semgrep-rules-examples';
433+
434+
// Run scan from DIR_A
435+
const { stdout: stdoutA, stderr: stderrA } = await runSnykCLI(
436+
`secrets test "../${targetDir}" --sarif`,
437+
{ env, cwd: DIR_A }
438+
);
439+
expect(stderrA).toBe('');
440+
441+
// Run scan from DIR_C
442+
const { stdout: stdoutC, stderr: stderrC } = await runSnykCLI(
443+
`secrets test "../../${targetDir}" --sarif`,
444+
{ env, cwd: DIR_C }
445+
);
446+
expect(stderrC).toBe('');
447+
448+
const sarifA = JSON.parse(stdoutA);
449+
const sarifC = JSON.parse(stdoutC);
450+
451+
const resultsA = sarifA.runs[0].results || [];
452+
const resultsC = sarifC.runs[0].results || [];
453+
454+
// Ensure we actually scanned and found the secrets
455+
expect(resultsA.length).toBeGreaterThan(0);
456+
expect(resultsA.length).toBe(resultsC.length);
457+
458+
// Helper to extract and sort fingerprints so order doesn't cause false failures
459+
const getFingerprints = (results: any[]) =>
460+
results.map((r: any) => r.fingerprints?.fingerprint).sort();
461+
462+
const fingerprintsA = getFingerprints(resultsA);
463+
const fingerprintsC = getFingerprints(resultsC);
464+
465+
// Identities must be exactly the same, as they are computed relative to the git root
466+
expect(fingerprintsA).toEqual(fingerprintsC);
467+
});
468+
});
469+
173470
describe('validation', () => {
174471
it('should return an error for --report', async () => {
175472
const { code, stdout } = await runSnykCLI(

0 commit comments

Comments
 (0)