diff --git a/src/lib/config/index.ts b/src/lib/config/index.ts index 76bb499007..54de2e26f9 100644 --- a/src/lib/config/index.ts +++ b/src/lib/config/index.ts @@ -90,4 +90,6 @@ if (!config.ROOT) { config.PUBLIC_VULN_DB_URL = 'https://security.snyk.io'; +config.CODE_CLIENT_PROXY_URL = process.env.SNYK_CODE_CLIENT_PROXY_URL || ''; + export default config; diff --git a/test/acceptance/deepcode-fake-server.ts b/test/acceptance/deepcode-fake-server.ts new file mode 100644 index 0000000000..86250c5a26 --- /dev/null +++ b/test/acceptance/deepcode-fake-server.ts @@ -0,0 +1,192 @@ +import * as express from 'express'; +import * as http from 'http'; +import * as net from 'net'; + +export type FakeDeepCodeServer = { + getRequests: () => express.Request[]; + popRequest: () => express.Request; + popRequests: (num: number) => express.Request[]; + setCustomResponse: (next: Record) => void; + setFiltersResponse: (next: Record) => void; + setNextResponse: (r: any) => void; + setNextStatusCode: (code: number) => void; + setSarifResponse: (r: any) => void; + listen: (callback: () => void) => void; + restore: () => void; + close: (callback: () => void) => void; + getPort: () => number; +}; + +export const fakeDeepCodeServer = (): FakeDeepCodeServer => { + let filtersResponse: Record | null = { + configFiles: [], + extensions: ['.java'], + }; + let sarifResponse: Record | null = null; + let requests: express.Request[] = []; + // the status code to return for the next request, overriding statusCode + let nextResponse: Record | undefined = undefined; + let nextStatusCode: number | undefined = undefined; + let customResponse: Record | undefined = undefined; + let server: http.Server | undefined = undefined; + const sockets = new Set(); + + const restore = () => { + requests = []; + customResponse = undefined; + nextResponse = undefined; + nextStatusCode = undefined; + sarifResponse = null; + filtersResponse = { configFiles: [], extensions: ['.java', '.js'] }; + }; + + const getRequests = () => { + return requests; + }; + + const popRequest = () => { + return requests.pop()!; + }; + + const popRequests = (num: number) => { + return requests.splice(requests.length - num, num); + }; + + const setCustomResponse = (next: typeof customResponse) => { + customResponse = next; + }; + + const setFiltersResponse = (response: string | Record) => { + if (typeof response === 'string') { + filtersResponse = JSON.parse(response); + return; + } + filtersResponse = response; + }; + + const setNextResponse = (response: string | Record) => { + if (typeof response === 'string') { + nextResponse = JSON.parse(response); + return; + } + nextResponse = response; + }; + + const setNextStatusCode = (code: number) => { + nextStatusCode = code; + }; + + const setSarifResponse = (response: string | Record) => { + if (typeof response === 'string') { + sarifResponse = JSON.parse(response); + return; + } + sarifResponse = response; + }; + + const app = express(); + app.use((req, res, next) => { + requests.push(req); + next(); + }); + + app.use((req, res, next) => { + if (nextStatusCode) { + const code = nextStatusCode; + res.status(code); + } + + if (nextResponse) { + const response = nextResponse; + res.send(response); + return; + } + next(); + }); + + app.get('/filters', (req, res) => { + res.status(200); + res.send(filtersResponse); + }); + + app.post('/bundle', (req, res) => { + res.status(200); + + res.send({ + bundleHash: 'bundle-hash', + missingFiles: [], + }); + }); + + app.post('/analysis', (req, res) => { + res.status(200); + res.send({ + timing: { + fetchingCode: 1, + analysis: 1, + queue: 1, + }, + coverage: [], + status: 'COMPLETE', + type: 'sarif', + sarif: sarifResponse, + }); + }); + + const listenPromise = () => { + return new Promise((resolve) => { + server = http.createServer(app).listen(resolve); + + server?.on('connection', (socket) => { + sockets.add(socket); + }); + }); + }; + + const listen = (callback: () => void) => { + listenPromise().then(callback); + }; + + const closePromise = () => { + return new Promise((resolve) => { + if (!server) { + resolve(); + return; + } + server.close(() => resolve()); + server = undefined; + }); + }; + + const close = (callback: () => void) => { + for (const socket of sockets) { + (socket as net.Socket)?.destroy(); + sockets.delete(socket); + } + + closePromise().then(callback); + }; + + const getPort = () => { + const address = server?.address(); + if (address && typeof address === 'object') { + return address.port; + } + throw new Error('port not found'); + }; + + return { + getRequests, + popRequest, + popRequests, + setCustomResponse: setCustomResponse, + setFiltersResponse, + setSarifResponse, + setNextResponse, + setNextStatusCode, + listen, + restore, + close, + getPort, + }; +}; diff --git a/test/acceptance/fake-server.ts b/test/acceptance/fake-server.ts index fa11825cb8..1f14bc3205 100644 --- a/test/acceptance/fake-server.ts +++ b/test/acceptance/fake-server.ts @@ -42,7 +42,9 @@ export type FakeServer = { setNextStatusCode: (c: number) => void; setStatusCode: (c: number) => void; setStatusCodes: (c: number[]) => void; + setLocalCodeEngineConfiguration: (next: Record) => void; setFeatureFlag: (featureFlag: string, enabled: boolean) => void; + setOrgSetting: (setting: string, enabled: boolean) => void; unauthorizeAction: (action: string, reason?: string) => void; listen: (port: string | number, callback: () => void) => void; listenPromise: (port: string | number) => Promise; @@ -59,6 +61,10 @@ export type FakeServer = { export const fakeServer = (basePath: string, snykToken: string): FakeServer => { let requests: express.Request[] = []; let featureFlags: Map = featureFlagDefaults(); + let availableSettings: Map = new Map(); + let localCodeEngineConfiguration: Record = { + enabled: false, + }; let unauthorizedActions = new Map(); // the status code to return for the next request, overriding statusCode let nextStatusCode: number | undefined = undefined; @@ -75,6 +81,7 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { requests = []; customResponse = undefined; featureFlags = featureFlagDefaults(); + availableSettings = new Map(); unauthorizedActions = new Map(); }; @@ -94,6 +101,16 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { customResponse = next; }; + const setLocalCodeEngineConfiguration = ( + response: string | Record, + ) => { + if (typeof response === 'string') { + localCodeEngineConfiguration = JSON.parse(response); + return; + } + localCodeEngineConfiguration = response; + }; + const setNextResponse = (response: string | Record) => { if (typeof response === 'string') { nextResponse = JSON.parse(response); @@ -118,6 +135,10 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { featureFlags.set(featureFlag, enabled); }; + const setOrgSetting = (setting: string, enabled: boolean) => { + availableSettings.set(setting, enabled); + }; + const unauthorizeAction = ( action: string, reason = 'unauthorized by test', @@ -388,6 +409,41 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { }); }); + app.get(basePath + '/cli-config/settings/:setting', (req, res) => { + const org = req.query.org; + const setting = req.params.setting; + if (org === 'no-flag') { + res.send({ + ok: false, + userMessage: `Org ${org} doesn't have '${setting}' feature enabled'`, + }); + return; + } + + if (availableSettings.has(setting)) { + const settingEnabled = availableSettings.get(setting); + // TODO: Refactor to support passing in an org setting with additional + // properties, e.g. localCodeEngine. + if (settingEnabled && setting === 'sast') { + return res.send({ + ok: true, + sastEnabled: true, + localCodeEngine: localCodeEngineConfiguration, + }); + } + + return res.send({ + ok: false, + userMessage: `Org ${org} doesn't have '${setting}' feature enabled'`, + }); + } + + // default: return false for all feature flags + res.send({ + ok: false, + }); + }); + app.get(basePath + '/cli-config/feature-flags/:featureFlag', (req, res) => { const org = req.query.org; const flag = req.params.featureFlag; @@ -709,11 +765,13 @@ export const fakeServer = (basePath: string, snykToken: string): FakeServer => { popRequest, popRequests, setCustomResponse: setCustomResponse, + setLocalCodeEngineConfiguration, setNextResponse, setNextStatusCode, setStatusCode, setStatusCodes, setFeatureFlag, + setOrgSetting, unauthorizeAction, listen, listenPromise, diff --git a/test/fixtures/sast-empty/empty-sarif.json b/test/fixtures/sast-empty/empty-sarif.json new file mode 100644 index 0000000000..0e0b340ce9 --- /dev/null +++ b/test/fixtures/sast-empty/empty-sarif.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "SnykCode", + "semanticVersion": "1.0.0", + "version": "1.0.0", + "rules": [] + } + }, + "results": [], + "properties": { + "coverage": [ + { + "files": 8, + "isSupported": true, + "lang": "JavaScript" + }, + { + "files": 1, + "isSupported": true, + "lang": "HTML" + } + ] + } + } + ] +} diff --git a/test/fixtures/sast-empty/shallow_empty/index.java b/test/fixtures/sast-empty/shallow_empty/index.java new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/fixtures/sast-empty/shallow_empty/index.js b/test/fixtures/sast-empty/shallow_empty/index.js new file mode 100644 index 0000000000..a0cf0e4330 --- /dev/null +++ b/test/fixtures/sast-empty/shallow_empty/index.js @@ -0,0 +1 @@ +console.log('shallow_empty'); \ No newline at end of file diff --git a/test/fixtures/sast/empty-sarif.json b/test/fixtures/sast/empty-sarif.json new file mode 100644 index 0000000000..0e0b340ce9 --- /dev/null +++ b/test/fixtures/sast/empty-sarif.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "SnykCode", + "semanticVersion": "1.0.0", + "version": "1.0.0", + "rules": [] + } + }, + "results": [], + "properties": { + "coverage": [ + { + "files": 8, + "isSupported": true, + "lang": "JavaScript" + }, + { + "files": 1, + "isSupported": true, + "lang": "HTML" + } + ] + } + } + ] +} diff --git a/test/jest/acceptance/snyk-code/snyk-code.spec.ts b/test/jest/acceptance/snyk-code/snyk-code.spec.ts index ae4e757f7f..0a8d3243c6 100644 --- a/test/jest/acceptance/snyk-code/snyk-code.spec.ts +++ b/test/jest/acceptance/snyk-code/snyk-code.spec.ts @@ -1,10 +1,57 @@ +import { createProjectFromFixture } from '../../util/createProject'; import { runSnykCLI } from '../../util/runSnykCLI'; +import { fakeServer } from '../../../acceptance/fake-server'; +import { fakeDeepCodeServer } from '../../../acceptance/deepcode-fake-server'; +import { getServerPort } from '../../util/getServerPort'; +const stripAnsi = require('strip-ansi'); + +const EXIT_CODE_SUCCESS = 0; +const EXIT_CODE_ACTION_NEEDED = 1; +const EXIT_CODE_FAIL_WITH_ERROR = 2; +const EXIT_CODE_NO_SUPPORTED_FILES = 3; describe('code', () => { + let server: ReturnType; + let deepCodeServer: ReturnType; + let env: Record; + const port = getServerPort(process); + const baseApi = '/api/v1'; + const initialEnvVars = { + ...process.env, + SNYK_API: 'http://localhost:' + port + baseApi, + SNYK_HOST: 'http://localhost:' + port, + SNYK_TOKEN: '123456789', + }; + + beforeAll((done) => { + deepCodeServer = fakeDeepCodeServer(); + deepCodeServer.listen(() => {}); + env = { + ...initialEnvVars, + SNYK_CODE_CLIENT_PROXY_URL: `http://localhost:${deepCodeServer.getPort()}`, + }; + server = fakeServer(baseApi, 'snykToken'); + server.listen(port, () => { + done(); + }); + }); + + afterEach(() => { + server.restore(); + deepCodeServer.restore(); + }); + + afterAll((done) => { + deepCodeServer.close(() => {}); + server.close(() => { + done(); + }); + }); + it('prints help info', async () => { - const { stdout, code, stderr } = await runSnykCLI('code'); + const { stdout, code, stderr } = await runSnykCLI('code', { env }); - expect(stdout).toContain( + expect(stripAnsi(stdout)).toContain( 'The snyk code test command finds security issues using Static Code Analysis.', ); expect(code).toBe(0); @@ -12,15 +59,190 @@ describe('code', () => { }); describe('test', () => { - jest.setTimeout(60000); - it('supports unknown flags', async () => { - const { stdout: baselineStdOut } = await runSnykCLI('code test --help'); - const { stdout, stderr } = await runSnykCLI('code test --unknown-flag'); + it('should fail - when we do not support files', async () => { + // Setup + const { path } = await createProjectFromFixture('empty'); + server.setOrgSetting('sast', true); + + const { stdout, code, stderr } = await runSnykCLI(`code test ${path()}`, { + env, + }); + + expect(stderr).toBe(''); + expect(stdout).toContain(`We found 0 supported files`); + expect(code).toBe(EXIT_CODE_NO_SUPPORTED_FILES); // failure, no supported projects detected + }); + + it('should succeed - when no errors found', async () => { + // Setup + const { path } = await createProjectFromFixture( + 'sast-empty/shallow_empty', + ); + server.setOrgSetting('sast', true); + deepCodeServer.setSarifResponse( + require('../../../fixtures/sast-empty/empty-sarif.json'), + ); + + const { stdout, code, stderr } = await runSnykCLI(`code test ${path()}`, { + env, + }); + + expect(stderr).toBe(''); + expect(stdout).toContain(`Awesome! No issues were found.`); + expect(code).toBe(EXIT_CODE_SUCCESS); + + expect( + server + .getRequests() + .filter((req) => req.originalUrl.endsWith('/analytics/cli')), + ).toHaveLength(2); + }); + + it('should succeed - with correct exit code', async () => { + const { path } = await createProjectFromFixture( + 'sast/shallow_sast_webgoat', + ); + server.setOrgSetting('sast', true); + deepCodeServer.setSarifResponse( + require('../../../fixtures/sast/sample-sarif.json'), + ); + + const { stdout, stderr, code } = await runSnykCLI(`code test ${path()}`, { + env, + }); // We do not render the help message for unknown flags - expect(stdout).not.toContain(baselineStdOut); - expect(stdout).toContain('Testing '); expect(stderr).toBe(''); + expect(stripAnsi(stdout)).toContain('✗ [Medium] Information Exposure'); + expect(code).toBe(EXIT_CODE_ACTION_NEEDED); + }); + + it('should show error if sast is not enabled', async () => { + // Setup + const { path } = await createProjectFromFixture( + 'sast/shallow_sast_webgoat', + ); + server.setOrgSetting('sast', false); + + const { stdout, code, stderr } = await runSnykCLI(`code test ${path()}`, { + env, + }); + + expect(stderr).toBe(''); + expect(stdout).toContain('Snyk Code is not supported for org'); + expect(code).toBe(EXIT_CODE_FAIL_WITH_ERROR); + }); + + it.each([['sarif'], ['json']])( + 'succeed testing with correct exit code - with %p output', + async (optionsName) => { + const sarifPayload = require('../../../fixtures/sast/sample-sarif.json'); + const { path } = await createProjectFromFixture( + 'sast/shallow_sast_webgoat', + ); + server.setOrgSetting('sast', true); + deepCodeServer.setSarifResponse(sarifPayload); + + const { stdout, stderr, code } = await runSnykCLI( + `code test ${path()} --${optionsName}`, + { + env, + }, + ); + + expect(stderr).toBe(''); + expect(JSON.parse(stdout)).toEqual(sarifPayload); + expect(code).toBe(EXIT_CODE_ACTION_NEEDED); + }, + ); + + it('succeed testing with correct exit code - with sarif oputput and no markdown', async () => { + const sarifPayload = require('../../../fixtures/sast/sample-sarif.json'); + const { path } = await createProjectFromFixture( + 'sast/shallow_sast_webgoat', + ); + server.setOrgSetting('sast', true); + deepCodeServer.setSarifResponse(sarifPayload); + + const { stdout, stderr, code } = await runSnykCLI( + `code test ${path()} --sarif --no-markdown`, + { + env, + }, + ); + + expect(stderr).toBe(''); + const output = JSON.parse(stdout); + expect(Object.keys(output.runs[0].results[0].message)).not.toContain( + 'markdown', + ); + expect(code).toBe(EXIT_CODE_ACTION_NEEDED); + }); + + const failedCodeTestMessage = "Failed to run 'code test'"; + + // This is caused by the retry logic in the code-client + // which defaults to 10 retries with a 5 second delay + jest.setTimeout(60000); + it.each([ + [{ code: 401 }, `Unauthorized: ${failedCodeTestMessage}`], + [{ code: 429 }, failedCodeTestMessage], + [{ code: 500 }, failedCodeTestMessage], // TODO this causes the test to hang. Think it is due to retry logic + ])( + 'should fail - when server returns %p', + async (errorCodeObj, expectedResult) => { + const { path } = await createProjectFromFixture( + 'sast/shallow_sast_webgoat', + ); + server.setOrgSetting('sast', true); + deepCodeServer.setNextStatusCode(errorCodeObj.code); + deepCodeServer.setNextResponse({ + statusCode: errorCodeObj.code, + statusText: 'Unauthorized action', + apiName: 'code', + }); + + const { stdout, code, stderr } = await runSnykCLI( + `code test ${path()}`, + { + env, + }, + ); + + expect(stderr).toBe(''); + expect(stdout).toContain(expectedResult); + expect(code).toBe(EXIT_CODE_FAIL_WITH_ERROR); + }, + ); + + it("use remote LCE's url as base when LCE is enabled", async () => { + const localCodeEngineUrl = fakeDeepCodeServer(); + localCodeEngineUrl.listen(() => {}); + + const { path } = await createProjectFromFixture( + 'sast/shallow_sast_webgoat', + ); + server.setOrgSetting('sast', true); + server.setLocalCodeEngineConfiguration({ + enabled: true, + allowCloudUpload: true, + url: 'http://localhost:' + localCodeEngineUrl.getPort(), + }); + localCodeEngineUrl.setSarifResponse( + require('../../../fixtures/sast/sample-sarif.json'), + ); + + const { stdout, code, stderr } = await runSnykCLI(`code test ${path()}`, { + env, + }); + + expect(deepCodeServer.getRequests().length).toBe(0); + expect(localCodeEngineUrl.getRequests().length).toBeGreaterThan(0); + expect(stderr).toBe(''); + expect(stripAnsi(stdout)).toContain('✗ [Medium] Information Exposure'); + expect(code).toBe(EXIT_CODE_ACTION_NEEDED); + + await localCodeEngineUrl.close(() => {}); }); }); });