11import { execSync } from 'child_process' ;
2- import { existsSync , unlinkSync } from 'fs' ;
3-
2+ import { existsSync , unlinkSync , mkdirSync , rmSync , copyFileSync , readdirSync , statSync } from 'fs' ;
43import { matchers } from 'jest-json-schema' ;
54import { runSnykCLI } from '../../util/runSnykCLI' ;
65import { EXIT_CODES } from '../../../../src/cli/exit-codes' ;
7- import { resolve } from 'path' ;
6+ import { join , resolve } from 'path' ;
7+ import { randomUUID } from 'crypto' ;
88
99expect . extend ( matchers ) ;
1010jest . 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(() => {
4747afterAll ( ( ) => {
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+
57124describe . 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 = / F i n d i n g I D : [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 1 - 5 ] [ 0 - 9 a - f ] { 3 } - [ 8 9 a b ] [ 0 - 9 a - f ] { 3 } - [ 0 - 9 a - 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 ( / I g n o r e d : \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 ( / E x p i r a t i o n : \s + [ A - Z ] [ a - z ] + \s + \d { 2 } , \s + \d { 4 } / ) ;
307+
308+ // Validates the Reason field and spacing
309+ expect ( stdout ) . toMatch ( / R e a s o n : \s + T e s t i g n o r e r e a s o n m e t a d a t a 0 / ) ;
310+ expect ( stdout ) . toMatch ( / R e a s o n : \s + T e s t i g n o r e r e a s o n m e t a d a t a 1 / ) ;
311+ expect ( stdout ) . toMatch ( / I g n o r e d o n : \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 - f 0 - 9 ] { 64 } $ / i;
364+ const slugRegex = / ^ [ a - z 0 - 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