diff --git a/src/analysis.js b/src/analysis.js index c2cea44d..b3bd3a4d 100644 --- a/src/analysis.js +++ b/src/analysis.js @@ -9,7 +9,7 @@ import { addProxyAgent, getCustom, getTokenHeaders , TRUSTIFY_DA_OPERATION_TYPE_ /** Media type for CycloneDX JSON batch payloads (batch-analysis API). */ export const CYCLONEDX_JSON_MEDIA_TYPE = 'application/vnd.cyclonedx+json' -export default { requestComponent, requestStack, requestStackBatch, requestImages, validateToken } +export default { requestComponent, requestStack, requestStackBatch, requestImages, validateToken, appendAnalysisQueryParams } /** * Send a stack analysis request and get the report as 'text/html' or 'application/json'. @@ -47,9 +47,7 @@ async function requestStack(provider, manifest, url, html = false, opts = {}) { }, opts); const finalUrl = new URL(`${url}/api/v5/analysis`); - if (getCustom('TRUSTIFY_DA_RECOMMEND', 'true', opts) === 'false') { - finalUrl.searchParams.append('recommend', 'false'); - } + appendAnalysisQueryParams(finalUrl, opts); let resp = await fetch(finalUrl, fetchOptions) let result @@ -112,9 +110,7 @@ async function requestComponent(provider, manifest, url, opts = {}) { }, opts); const finalUrl = new URL(`${url}/api/v5/analysis`); - if (getCustom('TRUSTIFY_DA_RECOMMEND', 'true', opts) === 'false') { - finalUrl.searchParams.append('recommend', 'false'); - } + appendAnalysisQueryParams(finalUrl, opts); let resp = await fetch(finalUrl, fetchOptions) let result @@ -156,9 +152,7 @@ async function requestComponent(provider, manifest, url, opts = {}) { */ async function requestStackBatch(sbomByPurl, url, html = false, opts = {}) { const finalUrl = new URL(`${url}/api/v5/batch-analysis`) - if (getCustom('TRUSTIFY_DA_RECOMMEND', 'true', opts) === 'false') { - finalUrl.searchParams.append('recommend', 'false') - } + appendAnalysisQueryParams(finalUrl, opts); const fetchOptions = addProxyAgent({ method: 'POST', @@ -209,9 +203,7 @@ async function requestImages(imageRefs, url, html = false, opts = {}) { } const finalUrl = new URL(`${url}/api/v5/batch-analysis`); - if (getCustom('TRUSTIFY_DA_RECOMMEND', 'true', opts) === 'false') { - finalUrl.searchParams.append('recommend', 'false'); - } + appendAnalysisQueryParams(finalUrl, opts); const fetchOptions = addProxyAgent({ method: 'POST', @@ -247,6 +239,25 @@ async function requestImages(imageRefs, url, html = false, opts = {}) { } } +/** + * Append common analysis query parameters (recommend, providers, sources) to a URL. + * @param {URL} url - the URL object to append query parameters to + * @param {import("index.js").Options} [opts={}] - options containing parameter overrides + */ +function appendAnalysisQueryParams(url, opts = {}) { + if (getCustom('TRUSTIFY_DA_RECOMMEND', 'true', opts) === 'false') { + url.searchParams.append('recommend', 'false'); + } + const providers = getCustom('TRUSTIFY_DA_PROVIDERS', null, opts); + if (providers) { + url.searchParams.append('providers', providers); + } + const sources = getCustom('TRUSTIFY_DA_SOURCES', null, opts); + if (sources) { + url.searchParams.append('sources', sources); + } +} + /** * * @param url the backend url to send the request to diff --git a/src/cli.js b/src/cli.js index ec0d21df..545d6453 100644 --- a/src/cli.js +++ b/src/cli.js @@ -28,11 +28,25 @@ const component = { desc: 'Workspace root directory (for monorepos; lock file is expected here)', type: 'string', normalize: true, + }, + providers: { + desc: 'Comma-separated list of vulnerability providers (env: TRUSTIFY_DA_PROVIDERS)', + type: 'string', + }, + sources: { + desc: 'Comma-separated list of vulnerability sources (env: TRUSTIFY_DA_SOURCES)', + type: 'string', } }), handler: async args => { let manifestName = args['/path/to/manifest'] const opts = args.workspaceDir ? { TRUSTIFY_DA_WORKSPACE_DIR: args.workspaceDir } : {} + if (args.providers) { + opts.TRUSTIFY_DA_PROVIDERS = args.providers + } + if (args.sources) { + opts.TRUSTIFY_DA_SOURCES = args.sources + } let res = await client.componentAnalysis(manifestName, opts) console.log(JSON.stringify(res, null, 2)) } @@ -88,6 +102,14 @@ const image = { desc: 'For JSON report, get only the \'summary\'', type: 'boolean', conflicts: 'html' + }, + providers: { + desc: 'Comma-separated list of vulnerability providers (env: TRUSTIFY_DA_PROVIDERS)', + type: 'string', + }, + sources: { + desc: 'Comma-separated list of vulnerability sources (env: TRUSTIFY_DA_SOURCES)', + type: 'string', } }), handler: async args => { @@ -97,7 +119,14 @@ const image = { } let html = args['html'] let summary = args['summary'] - let res = await client.imageAnalysis(imageRefs, html) + const opts = {} + if (args.providers) { + opts.TRUSTIFY_DA_PROVIDERS = args.providers + } + if (args.sources) { + opts.TRUSTIFY_DA_SOURCES = args.sources + } + let res = await client.imageAnalysis(imageRefs, html, opts) if(summary && !html) { let summaries = {} for (let [imageRef, report] of Object.entries(res)) { @@ -152,6 +181,14 @@ const stack = { desc: 'Workspace root directory (for monorepos; lock file is expected here)', type: 'string', normalize: true, + }, + providers: { + desc: 'Comma-separated list of vulnerability providers (env: TRUSTIFY_DA_PROVIDERS)', + type: 'string', + }, + sources: { + desc: 'Comma-separated list of vulnerability sources (env: TRUSTIFY_DA_SOURCES)', + type: 'string', } }), handler: async args => { @@ -159,6 +196,12 @@ const stack = { let html = args['html'] let summary = args['summary'] const opts = args.workspaceDir ? { TRUSTIFY_DA_WORKSPACE_DIR: args.workspaceDir } : {} + if (args.providers) { + opts.TRUSTIFY_DA_PROVIDERS = args.providers + } + if (args.sources) { + opts.TRUSTIFY_DA_SOURCES = args.sources + } let theProvidersSummary = new Map(); let theProvidersObject ={} let res = await client.stackAnalysis(manifest, html, opts) @@ -230,6 +273,14 @@ const stackBatch = { desc: 'Stop on first invalid package.json or SBOM error (env: TRUSTIFY_DA_CONTINUE_ON_ERROR=false)', type: 'boolean', default: false, + }, + providers: { + desc: 'Comma-separated list of vulnerability providers (env: TRUSTIFY_DA_PROVIDERS)', + type: 'string', + }, + sources: { + desc: 'Comma-separated list of vulnerability sources (env: TRUSTIFY_DA_SOURCES)', + type: 'string', } }), handler: async args => { @@ -250,6 +301,12 @@ const stackBatch = { if (args.failFast) { opts.continueOnError = false } + if (args.providers) { + opts.TRUSTIFY_DA_PROVIDERS = args.providers + } + if (args.sources) { + opts.TRUSTIFY_DA_SOURCES = args.sources + } let res = await client.stackAnalysisBatch(workspaceRoot, html, opts) const batchAnalysis = res && typeof res === 'object' && res != null && 'analysis' in res ? res.analysis : res diff --git a/src/index.js b/src/index.js index f4aaa2c0..62ac4e87 100644 --- a/src/index.js +++ b/src/index.js @@ -61,7 +61,9 @@ export { * TRUSTIFY_DA_PYTHON_PATH?: string | undefined, * TRUSTIFY_DA_PYTHON_VIRTUAL_ENV?: string | undefined, * TRUSTIFY_DA_PYTHON3_PATH?: string | undefined, + * TRUSTIFY_DA_PROVIDERS?: string | undefined, * TRUSTIFY_DA_RECOMMEND?: string | undefined, + * TRUSTIFY_DA_SOURCES?: string | undefined, * TRUSTIFY_DA_SKOPEO_CONFIG_PATH?: string | undefined, * TRUSTIFY_DA_SKOPEO_PATH?: string | undefined, * TRUSTIFY_DA_SYFT_CONFIG_PATH?: string | undefined, diff --git a/test/analysis.test.js b/test/analysis.test.js index 484b51da..20e23137 100644 --- a/test/analysis.test.js +++ b/test/analysis.test.js @@ -207,6 +207,140 @@ suite('testing the analysis module for sending api requests', () => { )) }) + suite('appendAnalysisQueryParams — URL construction with providers and sources', () => { + /** Verifies that providers param is appended when set via opts. */ + test('appends providers query param from opts', () => { + const url = new URL('http://example.com/api/v5/analysis') + analysis.appendAnalysisQueryParams(url, { TRUSTIFY_DA_PROVIDERS: 'redhat,lightwell' }) + expect(url.searchParams.get('providers')).to.equal('redhat,lightwell') + }) + + /** Verifies that sources param is appended when set via opts. */ + test('appends sources query param from opts', () => { + const url = new URL('http://example.com/api/v5/analysis') + analysis.appendAnalysisQueryParams(url, { TRUSTIFY_DA_SOURCES: 'osv' }) + expect(url.searchParams.get('sources')).to.equal('osv') + }) + + /** Verifies that both providers and sources are appended together. */ + test('appends both providers and sources when both are set', () => { + const url = new URL('http://example.com/api/v5/analysis') + analysis.appendAnalysisQueryParams(url, { + TRUSTIFY_DA_PROVIDERS: 'redhat', + TRUSTIFY_DA_SOURCES: 'osv,nvd' + }) + expect(url.searchParams.get('providers')).to.equal('redhat') + expect(url.searchParams.get('sources')).to.equal('osv,nvd') + }) + + /** Verifies that no providers or sources params are added when neither is set. */ + test('does not append providers or sources when neither is set', () => { + const url = new URL('http://example.com/api/v5/analysis') + analysis.appendAnalysisQueryParams(url, {}) + expect(url.searchParams.has('providers')).to.equal(false) + expect(url.searchParams.has('sources')).to.equal(false) + }) + + /** Verifies that environment variables are respected as fallback for providers. */ + test('reads providers from environment variable when opts is empty', () => { + process.env['TRUSTIFY_DA_PROVIDERS'] = 'trustify' + try { + const url = new URL('http://example.com/api/v5/analysis') + analysis.appendAnalysisQueryParams(url, {}) + expect(url.searchParams.get('providers')).to.equal('trustify') + } finally { + delete process.env['TRUSTIFY_DA_PROVIDERS'] + } + }) + + /** Verifies that environment variables are respected as fallback for sources. */ + test('reads sources from environment variable when opts is empty', () => { + process.env['TRUSTIFY_DA_SOURCES'] = 'osv' + try { + const url = new URL('http://example.com/api/v5/analysis') + analysis.appendAnalysisQueryParams(url, {}) + expect(url.searchParams.get('sources')).to.equal('osv') + } finally { + delete process.env['TRUSTIFY_DA_SOURCES'] + } + }) + + /** Verifies that recommend=false is still appended alongside providers/sources. */ + test('appends recommend=false alongside providers and sources', () => { + const url = new URL('http://example.com/api/v5/analysis') + analysis.appendAnalysisQueryParams(url, { + TRUSTIFY_DA_RECOMMEND: 'false', + TRUSTIFY_DA_PROVIDERS: 'redhat', + TRUSTIFY_DA_SOURCES: 'osv' + }) + expect(url.searchParams.get('recommend')).to.equal('false') + expect(url.searchParams.get('providers')).to.equal('redhat') + expect(url.searchParams.get('sources')).to.equal('osv') + }) + }) + + suite('verify providers and sources are forwarded in HTTP requests', () => { + let fakeManifest = 'fake-file-providers.typ' + let stackProviderStub = stub() + let fakeProvided = { + ecosystem: 'dummy-ecosystem', + content: 'dummy-content', + contentType: 'dummy-content-type' + } + stackProviderStub.withArgs(fakeManifest).returns(fakeProvided) + let fakeProvider = { + provideComponent: () => { }, + provideStack: stackProviderStub, + isSupported: () => { } + } + + setup(() => { + fs.writeFileSync(fakeManifest, 'dummy-content') + }) + + teardown(() => { + if (fs.existsSync(fakeManifest)) { + fs.unlinkSync(fakeManifest) + } + }) + + /** Verifies that providers query param reaches the backend URL during stack analysis. */ + test('requestStack sends providers query param to backend', interceptAndRun( + http.post(`${backendUrl}/api/v5/analysis`, ({ request }) => { + const url = new URL(request.url) + if (url.searchParams.get('providers') === 'redhat,lightwell') { + return HttpResponse.json({ filtered: true }) + } + return new HttpResponse(null, { status: 400 }) + }), + async () => { + let res = await analysis.requestStack( + fakeProvider, fakeManifest, backendUrl, false, + { TRUSTIFY_DA_PROVIDERS: 'redhat,lightwell' } + ) + expect(res).to.deep.equal({ filtered: true }) + } + )) + + /** Verifies that sources query param reaches the backend URL during stack analysis. */ + test('requestStack sends sources query param to backend', interceptAndRun( + http.post(`${backendUrl}/api/v5/analysis`, ({ request }) => { + const url = new URL(request.url) + if (url.searchParams.get('sources') === 'osv') { + return HttpResponse.json({ sourceFiltered: true }) + } + return new HttpResponse(null, { status: 400 }) + }), + async () => { + let res = await analysis.requestStack( + fakeProvider, fakeManifest, backendUrl, false, + { TRUSTIFY_DA_SOURCES: 'osv' } + ) + expect(res).to.deep.equal({ sourceFiltered: true }) + } + )) + }) + suite('addProxyAgent', () => { afterEach(() => { delete process.env['TRUSTIFY_DA_PROXY_URL']