From 1a493de8c4c4597deffa310833f4e9d1e1f37204 Mon Sep 17 00:00:00 2001 From: Kevin Zhu Date: Tue, 20 Feb 2024 23:06:39 +1100 Subject: [PATCH 1/2] feat: strong api types with zod --- package-lock.json | 11 +- package.json | 3 +- src/api/sastCommon.ts | 20 ++ src/api/sastEvents.ts | 375 +++++++++++++++++++++++++++++--------- src/api/sastSummary.ts | 97 ++++------ src/api/scaCommon.ts | 40 ++++ src/api/scaEvents.ts | 346 ++++++++++++++++++++++++----------- src/api/scaSummary.ts | 106 +++++------ src/api/secretsCommon.ts | 22 +++ src/api/secretsEvents.ts | 196 +++++++++++--------- src/api/secretsSummary.ts | 70 +++---- 11 files changed, 864 insertions(+), 422 deletions(-) create mode 100644 src/api/sastCommon.ts create mode 100644 src/api/scaCommon.ts create mode 100644 src/api/secretsCommon.ts diff --git a/package-lock.json b/package-lock.json index dd5a345..1061f8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,8 @@ "@grafana/ui": "10.1.0", "react": "18.2.0", "react-dom": "18.2.0", - "tslib": "2.5.3" + "tslib": "2.5.3", + "zod": "^3.22.4" }, "devDependencies": { "@babel/core": "^7.21.4", @@ -20193,6 +20194,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zstddec": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.1.0.tgz", diff --git a/package.json b/package.json index 983c8e3..6cbf021 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,8 @@ "@grafana/ui": "10.1.0", "react": "18.2.0", "react-dom": "18.2.0", - "tslib": "2.5.3" + "tslib": "2.5.3", + "zod": "^3.22.4" }, "packageManager": "npm@9.8.1" } diff --git a/src/api/sastCommon.ts b/src/api/sastCommon.ts new file mode 100644 index 0000000..2d0f2fe --- /dev/null +++ b/src/api/sastCommon.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +export const SastFinding = z.object({ + tenantId: z.string(), + repositoryId: z.string(), + repository: z.string(), + branch: z.string(), + id: z.string(), + title: z.string(), + severity: z.string(), + language: z.string(), + message: z.string(), + filePath: z.string(), + cwe: z.number(), + ruleId: z.string(), + ruleUrl: z.string(), + startLine: z.number(), + endLine: z.number(), + isAllowlisted: z.boolean(), +}); diff --git a/src/api/sastEvents.ts b/src/api/sastEvents.ts index 9772c6e..a3bdd7f 100644 --- a/src/api/sastEvents.ts +++ b/src/api/sastEvents.ts @@ -1,83 +1,263 @@ +import { z } from 'zod'; import { DataFrame, FieldType, TimeRange, createDataFrame } from '@grafana/data'; import { FetchResponse } from '@grafana/runtime'; import { SastEventsQueryOptions } from 'types'; +import { SastFinding } from './sastCommon'; const MAX_API_REQUESTS = 10; -interface SastEventsApiResponse { - events: SastEventsEvent[]; - numItems: number; - nextEventId: string; -} +const _BaseEventSchema = z.object({ + id: z.string(), + time: z.string(), + timestampUnix: z.number(), +}); -interface SastEventsEvent { - id: string; - time: string; - timestampUnix: number; - type: SastEventType; - data: SastEventsData; -} +const SastEventsGitProvider = z.object({ + id: z.string(), + github: z + .object({ + installationId: z.number(), + ownerId: z.number(), + owner: z.string(), + ownerType: z.string(), + repositoryName: z.string(), + repositoryId: z.number(), + hasIssue: z.boolean(), + }) + .optional(), + bitbucket: z.any().optional(), +}); -interface SastEventsData { - id: string; - provider: SastEventsProvider; - branch: string; - commitHash: string; - numFindings: number; - numCritical: number; - numHigh: number; - numMedium: number; - numLow: number; - numUnknown: number; -} +const SastEventsEventSchema = z.union([ + _BaseEventSchema.extend({ + type: z.literal('new-branch-summary'), + data: z.object({ + id: z.string(), + provider: SastEventsGitProvider, + branch: z.string(), + commitHash: z.string(), + numFindings: z.number(), + numCritical: z.number(), + numHigh: z.number(), + numMedium: z.number(), + numLow: z.number(), + numUnknown: z.number(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-finding'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + finding: SastFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-findings'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + findings: z.array(SastFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-fix'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + finding: SastFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-fixes'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + findings: z.array(SastFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-allowlisted-finding'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + finding: SastFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-allowlisted-findings'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + findings: z.array(SastFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-unallowlisted-finding'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + finding: SastFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-unallowlisted-findings'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + findings: z.array(SastFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-finding'), + data: z.object({ + id: z.string(), + provider: SastEventsGitProvider, + pullRequestId: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + finding: SastFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-findings'), + data: z.object({ + id: z.string(), + provider: SastEventsGitProvider, + pullRequestId: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + findings: z.array(SastFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-fix'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + finding: SastFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-fixes'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + findings: z.array(SastFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-allowlisted-finding'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + finding: SastFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-allowlisted-findings'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + findings: z.array(SastFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-unallowlisted-finding'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + finding: SastFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-unallowlisted-findings'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SastEventsGitProvider, + findings: z.array(SastFinding).nullable(), + userId: z.string(), + }), + }), +]); -interface SastEventsProvider { - id: string; - github: { - installationId: number; - ownerId: number; - owner: string; - ownerType: string; - repositoryName: string; - repositoryId: number; - hasIssue: boolean; - }; - bitbucket: any; -} +const SastEventsApiResponseSchema = z.object({ + events: z.array(SastEventsEventSchema).nullable().nullable(), + numItems: z.number(), + nextEventId: z.string(), +}); -type SastEventType = - | 'new-branch-summary' - | 'new-finding' - | 'new-findings' - | 'new-fix' - | 'new-fixes' - | 'new-allowlisted-finding' - | 'new-allowlisted-findings' - | 'new-unallowlisted-finding' - | 'new-unallowlisted-findings' - | 'new-pull-request-finding' - | 'new-pull-request-findings' - | 'new-pull-request-fix' - | 'new-pull-request-fixes' - | 'new-pull-request-allowlisted-finding' - | 'new-pull-request-allowlisted-findings' - | 'new-pull-request-unallowlisted-finding' - | 'new-pull-request-unallowlisted-findings'; - -interface SastEventsApiRequest { - githubRepositoryId?: string; - branch?: string; - fromTime?: string; // ISO string - fromEvent?: string; - numItems?: number; //max 100 - sort?: string; // asc | desc - eventTypes?: string; //comma separated types -} +type SastEventsEvent = z.infer; export const processSastEvents = async ( queryOptions: SastEventsQueryOptions, range: TimeRange, - request_fn: (endpoint_path: string, params?: Record) => Promise> + request_fn: (endpoint_path: string, params?: Record) => Promise> ): Promise => { let events: SastEventsEvent[] = []; let prevEventId = null; @@ -93,25 +273,30 @@ export const processSastEvents = async ( ...(prevEventId ? { fromEvent: prevEventId } : { fromTime: range.from.toISOString() }), sort: 'asc', }; + console.log('sast event request:', params); const response: any = await request_fn('sast/events', params); - const datapoints: SastEventsApiResponse = response.data as unknown as SastEventsApiResponse; - if (datapoints === undefined || !('events' in datapoints)) { - throw new Error('Remote endpoint reponse does not contain "events" property.'); + console.log('sast event response:', response); + + const parseResult = SastEventsApiResponseSchema.safeParse(response.data); + if (!parseResult.success) { + throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); } - if (response?.data?.events) { - events.push(...response.data.events); + + if (parseResult.data.events) { + events.push(...parseResult.data.events); } - console.log('response', response); + console.log('parseResult', parseResult); console.log('events', events); - if (!response.data.events || response.data.events.length === 0 || !response.data.nextEventId) { + + if (!parseResult.data.events || parseResult.data.events.length === 0 || !parseResult.data.nextEventId) { // No more events break; - } else if (response.data.events[0].timestampUnix > range.to.unix()) { + } else if (parseResult.data.events[0].timestampUnix > range.to.unix()) { // No more events required break; } else { - prevEventId = response.data.nextEventId; + prevEventId = parseResult.data.nextEventId; } } @@ -125,21 +310,45 @@ export const processSastEvents = async ( values: events.map((event) => new Date(event.timestampUnix * 1000)), }, { name: 'type', type: FieldType.string, values: events.map((event) => event.type) }, - { name: 'numFindings', type: FieldType.number, values: events.map((event) => event.data.numFindings) }, - { name: 'numCritical', type: FieldType.number, values: events.map((event) => event.data.numCritical) }, - { name: 'numHigh', type: FieldType.number, values: events.map((event) => event.data.numHigh) }, - { name: 'numMedium', type: FieldType.number, values: events.map((event) => event.data.numMedium) }, - { name: 'numLow', type: FieldType.number, values: events.map((event) => event.data.numLow) }, - { name: 'numUnknown', type: FieldType.number, values: events.map((event) => event.data.numUnknown) }, + { + name: 'numFindings', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numFindings : undefined)), + }, + { + name: 'numCritical', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numCritical : undefined)), + }, + { + name: 'numHigh', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numHigh : undefined)), + }, + { + name: 'numMedium', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numMedium : undefined)), + }, + { + name: 'numLow', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numLow : undefined)), + }, + { + name: 'numUnknown', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numUnknown : undefined)), + }, { name: 'repositoryId', type: FieldType.string, - values: events.map((event) => event.data.provider.github.repositoryId), + values: events.map((event) => event.data.provider.github?.repositoryId), }, { name: 'repositoryName', type: FieldType.string, - values: events.map((event) => event.data.provider.github.repositoryName), + values: events.map((event) => event.data.provider.github?.repositoryName), }, ], }); diff --git a/src/api/sastSummary.ts b/src/api/sastSummary.ts index 4e47643..8437fde 100644 --- a/src/api/sastSummary.ts +++ b/src/api/sastSummary.ts @@ -1,38 +1,18 @@ +import { z } from 'zod'; import { DataFrame, FieldType, createDataFrame } from '@grafana/data'; import { FetchResponse } from '@grafana/runtime'; import { SastSummaryQueryOptions } from 'types'; import { prepend_severity_idx } from 'utils/utils'; +import { SastFinding } from './sastCommon'; -interface SastSummaryVulnerability { - id: string; - title: string; - severity: string; - language: string; - message: string; - filePath: string; - cwe: number; - ruleId: string; - ruleUrl: string; - startLine: number; - endLine: number; - isAutoFixable: boolean; - suggestions?: any[]; - exampleFixes?: any[]; - isAllowlisted: boolean; - latest: boolean; -} - -interface SastSummaryApiResponse { - vulnerabilities: SastSummaryVulnerability[]; - numItems: number; -} +const SastSummaryApiResponseSchema = z.object({ + vulnerabilities: z.array(SastFinding), + numItems: z.number(), +}); export const processSastSummary = async ( queryOptions: SastSummaryQueryOptions, - request_fn: ( - endpoint_path: string, - params?: Record - ) => Promise> + request_fn: (endpoint_path: string, params?: Record) => Promise> ): Promise => { const response = await request_fn('sast/summary', { ...(queryOptions.queryParameters.githubRepositoryId @@ -40,44 +20,43 @@ export const processSastSummary = async ( : {}), ...(queryOptions.queryParameters.severity ? { severity: queryOptions.queryParameters.severity } : {}), }); - const datapoints: SastSummaryApiResponse = response.data as unknown as SastSummaryApiResponse; - if (!datapoints || !('vulnerabilities' in datapoints)) { - throw new Error('Remote endpoint does not contain the required field: vulnerabilities'); - } + console.log('sast summary response:', response); - let ids: string[] = []; - let formatted_severities: string[] = []; - let severities: string[] = []; - let cwes: number[] = []; - let languages: string[] = []; - let filePaths: string[] = []; - let isAutoFixables: boolean[] = []; - let isAllowlisteds: boolean[] = []; - let latests: boolean[] = []; - for (const vuln of datapoints.vulnerabilities) { - ids.push(vuln.id); - formatted_severities.push(prepend_severity_idx(vuln.severity)); - severities.push(vuln.severity); - cwes.push(vuln.cwe); - languages.push(vuln.language); - filePaths.push(vuln.filePath); - isAutoFixables.push(vuln.isAutoFixable); - isAllowlisteds.push(vuln.isAllowlisted); - latests.push(vuln.latest); + const parseResult = SastSummaryApiResponseSchema.safeParse(response.data); + if (!parseResult.success) { + throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); } return createDataFrame({ refId: queryOptions.refId, fields: [ - { name: 'id', type: FieldType.string, values: ids }, - { name: 'formatted_severity', type: FieldType.string, values: formatted_severities }, - { name: 'severity', type: FieldType.string, values: severities }, - { name: 'cwe', type: FieldType.number, values: cwes }, - { name: 'language', type: FieldType.string, values: languages }, - { name: 'filePath', type: FieldType.string, values: filePaths }, - { name: 'isAutoFixable', type: FieldType.boolean, values: isAutoFixables }, - { name: 'isAllowlisted', type: FieldType.boolean, values: isAllowlisteds }, - { name: 'latest', type: FieldType.boolean, values: latests }, + { name: 'id', type: FieldType.string, values: parseResult.data.vulnerabilities.map((vuln) => vuln.id) }, + { + name: 'formatted_severity', + type: FieldType.string, + values: parseResult.data.vulnerabilities.map((vuln) => prepend_severity_idx(vuln.severity)), + }, + { + name: 'severity', + type: FieldType.string, + values: parseResult.data.vulnerabilities.map((vuln) => vuln.severity), + }, + { name: 'cwe', type: FieldType.number, values: parseResult.data.vulnerabilities.map((vuln) => vuln.cwe) }, + { + name: 'language', + type: FieldType.string, + values: parseResult.data.vulnerabilities.map((vuln) => vuln.language), + }, + { + name: 'filePath', + type: FieldType.string, + values: parseResult.data.vulnerabilities.map((vuln) => vuln.filePath), + }, + { + name: 'isAllowlisted', + type: FieldType.boolean, + values: parseResult.data.vulnerabilities.map((vuln) => vuln.isAllowlisted), + }, ], }); }; diff --git a/src/api/scaCommon.ts b/src/api/scaCommon.ts new file mode 100644 index 0000000..abbfc2f --- /dev/null +++ b/src/api/scaCommon.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +const ScaEventsCve = z.object({ + id: z.string().optional(), + epss: z.number().optional(), + epssPercentile: z.number().optional(), + cisaKev: z.boolean().optional(), + cvss: z.number().optional(), + priority: z.string().optional(), + severity: z.string().optional(), +}); + +const ScaEventsVulnerability = z.object({ + hasFix: z.boolean().optional(), + title: z.string().optional(), + details: z.string().optional(), + severity: z.string().optional(), + cves: z.array(ScaEventsCve).optional().nullable(), + cwes: z.array(z.string()).optional().nullable(), + introduced: z.string().optional(), + fixed: z.string().optional(), + version: z.string().optional(), + references: z.array(z.string()).optional().nullable(), +}); + +export const ScaEventsDependencyFinding = z.object({ + id: z.string().optional(), + isDirect: z.boolean().optional(), + package: z.string().optional(), + packageFilePath: z.string().optional(), + version: z.string().optional(), + filePath: z.string().optional(), + line: z.number().optional(), + numCritical: z.number().optional(), + numHigh: z.number().optional(), + numMedium: z.number().optional(), + numLow: z.number().optional(), + numUnknown: z.number().optional(), + vulnerabilities: z.array(ScaEventsVulnerability).optional().nullable(), +}); diff --git a/src/api/scaEvents.ts b/src/api/scaEvents.ts index d7c52fb..63eedcc 100644 --- a/src/api/scaEvents.ts +++ b/src/api/scaEvents.ts @@ -1,98 +1,208 @@ +import { z } from 'zod'; import { DataFrame, FieldType, TimeRange, createDataFrame } from '@grafana/data'; import { FetchResponse } from '@grafana/runtime'; import { ScaEventsQueryOptions } from 'types'; +import { ScaEventsDependencyFinding } from './scaCommon'; const MAX_API_REQUESTS = 10; -interface ScaEventsApiResponse { - events: ScaEventsEvent[]; - numItems: number; - nextEventId: string; -} +const _BaseEventSchema = z.object({ + id: z.string(), + time: z.string(), + timeUnix: z.number(), +}); -interface ScaEventsEvent { - id: string; - time: string; - timeUnix: number; - type: ScaEventType; - data: ScaEventsData; -} +const ScaEventsGitProvider = z.object({ + id: z.string(), + github: z + .object({ + installationId: z.number(), + ownerId: z.number(), + owner: z.string(), + ownerType: z.string(), + repositoryName: z.string(), + repositoryId: z.number(), + hasIssue: z.boolean(), + }) + .optional(), + bitbucket: z.any().optional(), +}); -interface ScaEventsData { - id: string; - provider: { - id: string; - github: { - installationId: number; - ownerId: number; - owner: string; - ownerType: string; - repositoryName: string; - repositoryId: number; - hasIssue: boolean; - }; - }; - pullRequestId?: string; - branch: string; - commit: string; - cloneUrl?: string; - finding?: ScaEventsFinding; - userId?: string; - findings?: ScaEventsFinding[]; - numFindings?: number; - numVulnerabilities?: number; - numCritical?: number; - numHigh?: number; - numMedium?: number; - numLow?: number; - numUnknown?: number; -} -interface ScaEventsFinding { - id: string; - isDirect?: boolean; - package: string; - packageFilePath: string; - version: string; - filePath: string; - line?: number; - numHigh?: number; - vulnerabilities: ScaEventsVulnerability[]; - numMedium?: number; - numCritical?: number; - numLow?: number; - numUnknown?: number; -} +const ScaEventsEventSchema = z.union([ + _BaseEventSchema.extend({ + type: z.literal('new-branch-summary'), + data: z.object({ + id: z.string(), + provider: ScaEventsGitProvider, + branch: z.string(), + commit: z.string(), + numFindings: z.number(), + numVulnerabilities: z.number(), + numCritical: z.number(), + numHigh: z.number(), + numMedium: z.number(), + numLow: z.number(), + numUnknown: z.number(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-finding'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: ScaEventsGitProvider, + finding: ScaEventsDependencyFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-findings'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: ScaEventsGitProvider, + findings: z.array(ScaEventsDependencyFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-allowlisted-finding'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: ScaEventsGitProvider, + finding: ScaEventsDependencyFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-allowlisted-findings'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: ScaEventsGitProvider, + findings: z.array(ScaEventsDependencyFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-fix'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: ScaEventsGitProvider, + finding: ScaEventsDependencyFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-fixes'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: ScaEventsGitProvider, + findings: z.array(ScaEventsDependencyFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-finding'), + data: z.object({ + id: z.string(), + provider: ScaEventsGitProvider, + pullRequestId: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + finding: ScaEventsDependencyFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-findings'), + data: z.object({ + id: z.string(), + provider: ScaEventsGitProvider, + pullRequestId: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + findings: z.array(ScaEventsDependencyFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-fix'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: ScaEventsGitProvider, + finding: ScaEventsDependencyFinding, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-pull-request-fixes'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: ScaEventsGitProvider, + findings: z.array(ScaEventsDependencyFinding).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('bot-interaction'), // bot interaction pr + data: z.object({ + id: z.string(), + userId: z.string(), + repositoryId: z.string(), + pullRequestNumber: z.string(), + botRequestType: z.string(), + botRequest: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('bot-interaction'), // bot interaction issue + data: z.object({ + id: z.string(), + userId: z.string(), + repositoryId: z.string(), + issueNumber: z.string(), + issueTitle: z.string(), + botRequestType: z.string(), + botRequest: z.string(), + }), + }), +]); -export interface ScaEventsVulnerability { - hasFix?: boolean; - title: string; - details?: string; - severity: string; - introduced: string; - fixed: string; - references?: string[]; - cwes?: string[]; - cves?: Array<{ - id: string; - epss?: number; - epssPercentile?: number; - priority: string; - }>; -} +const ScaEventsApiResponseSchema = z.object({ + events: z.array(ScaEventsEventSchema).nullable().nullable(), + numItems: z.number(), + nextEventId: z.string(), +}); -type ScaEventType = - | 'new-branch-summary' - | 'new-finding' - | 'new-findings' - | 'new-fix' - | 'new-fixes' - | 'new-allowlisted-finding' - | 'new-allowlisted-findings' - | 'new-pull-request-finding' - | 'new-pull-request-findings' - | 'new-pull-request-fix' - | 'new-pull-request-fixes'; +type ScaEventsEvent = z.infer; interface ScaEventsApiRequest { branch?: string; @@ -106,7 +216,7 @@ interface ScaEventsApiRequest { export const processScaEvents = async ( queryOptions: ScaEventsQueryOptions, range: TimeRange, - request_fn: (endpoint_path: string, params?: Record) => Promise> + request_fn: (endpoint_path: string, params?: Record) => Promise> ): Promise => { let events: ScaEventsEvent[] = []; let prevEventId = null; @@ -122,25 +232,29 @@ export const processScaEvents = async ( ...(prevEventId ? { fromEvent: prevEventId } : { fromTime: range.from.toISOString() }), sort: 'asc', }; + console.log('sca event request:', params); const response = await request_fn('sca/events', params); - const datapoints: ScaEventsApiResponse = response.data as unknown as ScaEventsApiResponse; - if (datapoints === undefined || !('events' in datapoints)) { - throw new Error('Remote endpoint reponse does not contain "events" property.'); + console.log('sca event response:', response); + + const parseResult = ScaEventsApiResponseSchema.safeParse(response.data); + if (!parseResult.success) { + throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); } - if (response?.data?.events) { - events.push(...response.data.events); + + if (parseResult.data.events) { + events.push(...parseResult.data.events); } - console.log('response', response); + console.log('parseResult', parseResult); console.log('events', events); - if (!response.data.events || response.data.events.length === 0 || !response.data.nextEventId) { + if (!parseResult.data.events || parseResult.data.events.length === 0 || !parseResult.data.nextEventId) { // No more events break; - } else if (response.data.events[0].timeUnix > range.to.unix()) { + } else if (parseResult.data.events[0].timeUnix > range.to.unix()) { // No more events required break; } else { - prevEventId = response.data.nextEventId; + prevEventId = parseResult.data.nextEventId; } } @@ -154,21 +268,49 @@ export const processScaEvents = async ( values: events.map((event) => new Date(event.timeUnix * 1000)), }, { name: 'type', type: FieldType.string, values: events.map((event) => event.type) }, - { name: 'numFindings', type: FieldType.number, values: events.map((event) => event.data.numFindings) }, - { name: 'numCritical', type: FieldType.number, values: events.map((event) => event.data.numCritical) }, - { name: 'numHigh', type: FieldType.number, values: events.map((event) => event.data.numHigh) }, - { name: 'numMedium', type: FieldType.number, values: events.map((event) => event.data.numMedium) }, - { name: 'numLow', type: FieldType.number, values: events.map((event) => event.data.numLow) }, - { name: 'numUnknown', type: FieldType.number, values: events.map((event) => event.data.numUnknown) }, + { + name: 'numFindings', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numFindings : undefined)), + }, + { + name: 'numCritical', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numCritical : undefined)), + }, + { + name: 'numHigh', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numHigh : undefined)), + }, + { + name: 'numMedium', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numMedium : undefined)), + }, + { + name: 'numLow', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numLow : undefined)), + }, + { + name: 'numUnknown', + type: FieldType.number, + values: events.map((event) => (event.type === 'new-branch-summary' ? event.data.numUnknown : undefined)), + }, { name: 'repositoryId', type: FieldType.string, - values: events.map((event) => event.data.provider.github.repositoryId), + values: events.map((event) => + event.type !== 'bot-interaction' ? event.data.provider.github?.repositoryId : undefined + ), }, { name: 'repositoryName', type: FieldType.string, - values: events.map((event) => event.data.provider.github.repositoryName), + values: events.map((event) => + event.type !== 'bot-interaction' ? event.data.provider.github?.repositoryName : undefined + ), }, ], }); diff --git a/src/api/scaSummary.ts b/src/api/scaSummary.ts index 2f6fcc8..a244fc1 100644 --- a/src/api/scaSummary.ts +++ b/src/api/scaSummary.ts @@ -1,46 +1,17 @@ +import { z } from 'zod'; import { DataFrame, FieldType, createDataFrame } from '@grafana/data'; import { FetchResponse } from '@grafana/runtime'; import { ScaSummaryQueryOptions } from 'types'; -import { prepend_severity_idx } from 'utils/utils'; +import { ScaEventsDependencyFinding } from './scaCommon'; -interface ScaSummaryApiResponse { - vulnerabilities: ScaSummaryPackage[]; - numItems: number; -} - -interface ScaSummaryPackage { - id: string; - isDirect?: boolean; - package: string; - packageFilePath: string; - version?: string; - filePath: string; - line?: number; - numHigh?: number; - numMedium?: number; - vulnerabilities: ScaSummaryPackageVulnerability[]; - numCritical?: number; - numLow?: number; -} - -interface ScaSummaryPackageVulnerability { - hasFix?: boolean; - title: string; - details: string; - severity: string; - cves?: Array<{ - id: string; - }>; - cwes?: string[]; - introduced?: string; - fixed?: string; - references: string[]; - version?: string; -} +const ScaSummaryApiResponseSchema = z.object({ + vulnerabilities: z.array(ScaEventsDependencyFinding).nullable(), + numItems: z.number(), +}); export const processScaSummary = async ( queryOptions: ScaSummaryQueryOptions, - request_fn: (endpoint_path: string, params?: Record) => Promise> + request_fn: (endpoint_path: string, params?: Record) => Promise> ): Promise => { const response = await request_fn('sca/summary', { ...(queryOptions.queryParameters.githubRepositoryId @@ -48,26 +19,59 @@ export const processScaSummary = async ( : {}), ...(queryOptions.queryParameters.package ? { package: queryOptions.queryParameters.package } : {}), }); - const datapoints: ScaSummaryApiResponse = response.data as unknown as ScaSummaryApiResponse; - if (!datapoints || !('vulnerabilities' in datapoints)) { - throw new Error('Remote endpoint does not contain the required field: vulnerabilities'); - } + console.log('sca summary response:', response); + const parseResult = ScaSummaryApiResponseSchema.safeParse(response.data); + if (!parseResult.success) { + throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); + } return createDataFrame({ refId: queryOptions.refId, fields: [ - { name: 'id', type: FieldType.string, values: datapoints.vulnerabilities.map((vuln) => vuln.id) }, - { name: 'isDirect', type: FieldType.string, values: datapoints.vulnerabilities.map((vuln) => vuln.isDirect) }, - { name: 'package', type: FieldType.string, values: datapoints.vulnerabilities.map((vuln) => vuln.package) }, - { name: 'packageFilePath', type: FieldType.string, values: datapoints.vulnerabilities.map((vuln) => vuln.packageFilePath) }, - { name: 'version', type: FieldType.string, values: datapoints.vulnerabilities.map((vuln) => vuln.version) }, - { name: 'filePath', type: FieldType.string, values: datapoints.vulnerabilities.map((vuln) => vuln.filePath) }, - { name: 'numCritical', type: FieldType.number, values: datapoints.vulnerabilities.map((vuln) => vuln.numCritical ?? 0) }, - { name: 'numHigh', type: FieldType.number, values: datapoints.vulnerabilities.map((vuln) => vuln.numHigh ?? 0) }, - { name: 'numMedium', type: FieldType.number, values: datapoints.vulnerabilities.map((vuln) => vuln.numMedium ?? 0) }, - { name: 'numLow', type: FieldType.number, values: datapoints.vulnerabilities.map((vuln) => vuln.numLow ?? 0) }, - { name: 'numVulnerabilities', type: FieldType.number, values: datapoints.vulnerabilities.map((vuln) => vuln.vulnerabilities?.length ?? 0) }, + { name: 'id', type: FieldType.string, values: parseResult.data.vulnerabilities?.map((vuln) => vuln.id) }, + { + name: 'isDirect', + type: FieldType.string, + values: parseResult.data.vulnerabilities?.map((vuln) => vuln.isDirect), + }, + { name: 'package', type: FieldType.string, values: parseResult.data.vulnerabilities?.map((vuln) => vuln.package) }, + { + name: 'packageFilePath', + type: FieldType.string, + values: parseResult.data.vulnerabilities?.map((vuln) => vuln.packageFilePath), + }, + { name: 'version', type: FieldType.string, values: parseResult.data.vulnerabilities?.map((vuln) => vuln.version) }, + { + name: 'filePath', + type: FieldType.string, + values: parseResult.data.vulnerabilities?.map((vuln) => vuln.filePath), + }, + { + name: 'numCritical', + type: FieldType.number, + values: parseResult.data.vulnerabilities?.map((vuln) => vuln.numCritical ?? 0), + }, + { + name: 'numHigh', + type: FieldType.number, + values: parseResult.data.vulnerabilities?.map((vuln) => vuln.numHigh ?? 0), + }, + { + name: 'numMedium', + type: FieldType.number, + values: parseResult.data.vulnerabilities?.map((vuln) => vuln.numMedium ?? 0), + }, + { + name: 'numLow', + type: FieldType.number, + values: parseResult.data.vulnerabilities?.map((vuln) => vuln.numLow ?? 0), + }, + { + name: 'numVulnerabilities', + type: FieldType.number, + values: parseResult.data.vulnerabilities?.map((vuln) => vuln.vulnerabilities?.length ?? 0), + }, ], }); }; diff --git a/src/api/secretsCommon.ts b/src/api/secretsCommon.ts new file mode 100644 index 0000000..c79434f --- /dev/null +++ b/src/api/secretsCommon.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const SecretsScannerFindingEvent = z.object({ + id: z.string(), + secretType: z.string(), + filePath: z.string(), + author: z.string(), + commit: z.string(), + timeStamp: z.string(), + ruleId: z.string(), + entropy: z.number(), + startLine: z.number(), + endLine: z.number(), + startColumn: z.number(), + endColumn: z.number(), + secretHash: z.string(), + hyperlink: z.string(), + isBranchHead: z.boolean(), + branches: z.array(z.string()).nullable(), + firstCommitTimestamp: z.string(), + isAllowlisted: z.boolean(), +}); diff --git a/src/api/secretsEvents.ts b/src/api/secretsEvents.ts index c08732f..9df5ed2 100644 --- a/src/api/secretsEvents.ts +++ b/src/api/secretsEvents.ts @@ -1,72 +1,93 @@ +import { z } from 'zod'; import { DataFrame, FieldType, TimeRange, createDataFrame } from '@grafana/data'; import { FetchResponse } from '@grafana/runtime'; import { SecretsEventsQueryOptions } from 'types'; +import { SecretsScannerFindingEvent } from './secretsCommon'; const MAX_API_REQUESTS = 10; -interface SecretsEventsApiResponse { - events: SecretsEventsEvent[]; - numItems: number; - nextEventId: string; -} +const _BaseEventSchema = z.object({ + id: z.string(), + time: z.string(), + timestampUnix: z.number(), +}); -export interface SecretsEventsEvent { - id: string; - time: string; - timestampUnix: number; - type: SecretsEventType; - data: SecretsEventData; -} +const SecretsEventsGitProvider = z.object({ + id: z.string(), + github: z + .object({ + installationId: z.number(), + ownerId: z.number(), + owner: z.string(), + ownerType: z.string(), + repositoryName: z.string(), + repositoryId: z.number(), + hasIssue: z.boolean(), + }) + .optional(), + bitbucket: z.any().optional(), +}); -type SecretsEventType = 'new-finding' | 'new-findings' | 'new-allowlisted-finding' | 'new-allowlisted-findings'; +const SecretsEventsEventSchema = z.union([ + _BaseEventSchema.extend({ + type: z.literal('new-finding'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SecretsEventsGitProvider, + finding: SecretsScannerFindingEvent, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-findings'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SecretsEventsGitProvider, + findings: z.array(SecretsScannerFindingEvent).nullable(), + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-allowlisted-finding'), + data: z.object({ + id: z.string(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SecretsEventsGitProvider, + finding: SecretsScannerFindingEvent, + userId: z.string(), + }), + }), + _BaseEventSchema.extend({ + type: z.literal('new-allowlisted-findings'), + data: z.object({ + id: z.string(), + part: z.number().optional(), + branch: z.string(), + commit: z.string(), + cloneUrl: z.string(), + provider: SecretsEventsGitProvider, + findings: z.array(SecretsScannerFindingEvent).nullable(), + userId: z.string(), + }), + }), +]); -export interface SecretsEventData { - id: string; - branch: string; - commit: string; - cloneUrl: string; - provider: { - id: string; - github: { - installationId: number; - ownerId: number; - owner: string; - ownerType: string; - repositoryName: string; - repositoryId: number; - hasIssue: boolean; - }; - bitbucket: null; - }; - finding?: SecretsEventFinding; - userId: string; - part?: number; - findings?: SecretsEventFinding[]; -} +const SecretsEventsApiResponseSchema = z.object({ + events: z.array(SecretsEventsEventSchema).nullable().nullable(), + numItems: z.number(), + nextEventId: z.string(), +}); -export interface SecretsEventFinding { - id: string; - secretType: string; - value: string; - filePath: string; - author: string; - commit: string; - timeStamp: string; - ruleId: string; - entropy: number; - startLine: number; - endLine: number; - startColumn: number; - endColumn: number; - secret: string; - secretHash: string; - match: string; - hyperlink: string; - isBranchHead: boolean; - branches: null; - firstCommitTimestamp: string; - isAllowlisted: boolean; -} +type SecretsEventsEvent = z.infer; interface SecretsEventsApiRequest { branch?: string; @@ -80,7 +101,7 @@ interface SecretsEventsApiRequest { export const processSecretsEvents = async ( queryOptions: SecretsEventsQueryOptions, range: TimeRange, - request_fn: (endpoint_path: string, params?: Record) => Promise> + request_fn: (endpoint_path: string, params?: Record) => Promise> ): Promise => { let events: SecretsEventsEvent[] = []; let prevEventId = null; @@ -96,25 +117,29 @@ export const processSecretsEvents = async ( ...(prevEventId ? { fromEvent: prevEventId } : { fromTime: range.from.toISOString() }), sort: 'asc', }; + console.log('secrets event request:', params); const response = await request_fn('secrets/events', params); - const datapoints: SecretsEventsApiResponse = response.data as unknown as SecretsEventsApiResponse; - if (datapoints === undefined || !('events' in datapoints)) { - throw new Error('Remote endpoint reponse does not contain "events" property.'); + console.log('secrets event response:', response); + + const parseResult = SecretsEventsApiResponseSchema.safeParse(response.data); + if (!parseResult.success) { + throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); } - if (response?.data?.events) { - events.push(...response.data.events); + + if (parseResult.data.events) { + events.push(...parseResult.data.events); } - console.log('response', response); + console.log('parseResult', parseResult); console.log('events', events); - if (!response.data.events || response.data.events.length === 0 || !response.data.nextEventId) { + if (!parseResult.data.events || parseResult.data.events.length === 0 || !parseResult.data.nextEventId) { // No more events break; - } else if (response.data.events[0].timestampUnix > range.to.unix()) { + } else if (parseResult.data.events[0].timestampUnix > range.to.unix()) { // No more events required break; } else { - prevEventId = response.data.nextEventId; + prevEventId = parseResult.data.nextEventId; } } @@ -137,24 +162,29 @@ export const processSecretsEvents = async ( let finding_isAllowlisteds: Array = []; for (const event of events) { - for (const finding of event.data.findings ?? [event.data.finding]) { + let findings = + event.type === 'new-finding' || event.type === 'new-allowlisted-finding' + ? [event.data.finding] + : event.data.findings ?? []; + + for (const finding of findings) { ids.push(event.id); types.push(event.type); branchs.push(event.data.branch); commits.push(event.data.commit); - repository_names.push(event.data.provider.github.repositoryName); - repository_ids.push(event.data.provider.github.repositoryId); - finding_ids.push(finding?.id ?? ''); - finding_secretTypes.push(finding?.secretType ?? ''); - finding_filePaths.push(finding?.filePath ?? ''); - finding_authors.push(finding?.author ?? ''); - finding_commits.push(finding?.commit ?? ''); - finding_timeStamps.push(new Date(finding?.timeStamp ?? 0)); - finding_ruleIds.push(finding?.ruleId ?? ''); - finding_entropys.push(finding?.entropy ?? -1); - finding_isBranchHeads.push(finding?.isBranchHead); - finding_firstCommitTimestamps.push(new Date(finding?.firstCommitTimestamp ?? 0)); - finding_isAllowlisteds.push(finding?.isAllowlisted); + repository_names.push(event.data.provider.github?.repositoryName ?? ''); + repository_ids.push(event.data.provider.github?.repositoryId ?? -1); + finding_ids.push(finding.id); + finding_secretTypes.push(finding.secretType); + finding_filePaths.push(finding.filePath); + finding_authors.push(finding.author); + finding_commits.push(finding.commit); + finding_timeStamps.push(new Date(finding.timeStamp)); + finding_ruleIds.push(finding.ruleId); + finding_entropys.push(finding.entropy); + finding_isBranchHeads.push(finding.isBranchHead); + finding_firstCommitTimestamps.push(new Date(finding.firstCommitTimestamp)); + finding_isAllowlisteds.push(finding.isAllowlisted); } } diff --git a/src/api/secretsSummary.ts b/src/api/secretsSummary.ts index 8055d0e..9552a2c 100644 --- a/src/api/secretsSummary.ts +++ b/src/api/secretsSummary.ts @@ -1,39 +1,17 @@ +import { z } from 'zod'; import { DataFrame, FieldType, createDataFrame } from '@grafana/data'; import { FetchResponse } from '@grafana/runtime'; import { SecretsSummaryQueryOptions } from 'types'; +import { SecretsScannerFindingEvent } from './secretsCommon'; -interface SecretsSummaryApiResponse { - secrets: SecretsSummarySecret[]; - numItems: number; -} - -export interface SecretsSummarySecret { - id: string; - secretType: string; - value: string; - filePath: string; - author: string; - commit: string; - timeStamp: string; - ruleId: string; - entropy: number; - startLine: number; - endLine: number; - startColumn: number; - endColumn: number; - secret: string; - secretHash: string; - match: string; - hyperlink: string; - isBranchHead: boolean; - branches: null; - firstCommitTimestamp: string; - isAllowlisted: boolean; -} +const SecretsSummaryApiResponseSchema = z.object({ + secrets: z.array(SecretsScannerFindingEvent).nullable(), + numItems: z.number(), +}); export const processSecretsSummary = async ( queryOptions: SecretsSummaryQueryOptions, - request_fn: (endpoint_path: string, params?: Record) => Promise> + request_fn: (endpoint_path: string, params?: Record) => Promise> ): Promise => { const response = await request_fn('secrets/summary', { ...(queryOptions.queryParameters.githubRepositoryId @@ -43,39 +21,47 @@ export const processSecretsSummary = async ( ...(queryOptions.queryParameters.type ? { type: queryOptions.queryParameters.type } : {}), ...(queryOptions.queryParameters.allowlisted ? { allowlisted: queryOptions.queryParameters.allowlisted } : {}), }); - const datapoints: SecretsSummaryApiResponse = response.data as unknown as SecretsSummaryApiResponse; - if (!datapoints || !('secrets' in datapoints)) { - throw new Error('Remote endpoint does not contain the required field: secrets'); + console.log('secrets summary response:', response); + + const parseResult = SecretsSummaryApiResponseSchema.safeParse(response.data); + if (!parseResult.success) { + throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); } + console.log('parseResult', parseResult); + return createDataFrame({ refId: queryOptions.refId, fields: [ - { name: 'id', type: FieldType.string, values: datapoints.secrets.map((secret) => secret.id) }, - { name: 'secretType', type: FieldType.string, values: datapoints.secrets.map((secret) => secret.secretType) }, - { name: 'filePath', type: FieldType.string, values: datapoints.secrets.map((secret) => secret.filePath) }, - { name: 'author', type: FieldType.string, values: datapoints.secrets.map((secret) => secret.author) }, + { name: 'id', type: FieldType.string, values: parseResult.data.secrets?.map((secret) => secret.id) }, + { + name: 'secretType', + type: FieldType.string, + values: parseResult.data.secrets?.map((secret) => secret.secretType), + }, + { name: 'filePath', type: FieldType.string, values: parseResult.data.secrets?.map((secret) => secret.filePath) }, + { name: 'author', type: FieldType.string, values: parseResult.data.secrets?.map((secret) => secret.author) }, { name: 'timeStamp', type: FieldType.time, - values: datapoints.secrets.map((secret) => new Date(secret.timeStamp)), + values: parseResult.data.secrets?.map((secret) => new Date(secret.timeStamp)), }, - { name: 'ruleId', type: FieldType.string, values: datapoints.secrets.map((secret) => secret.ruleId) }, - { name: 'entropy', type: FieldType.number, values: datapoints.secrets.map((secret) => secret.entropy) }, + { name: 'ruleId', type: FieldType.string, values: parseResult.data.secrets?.map((secret) => secret.ruleId) }, + { name: 'entropy', type: FieldType.number, values: parseResult.data.secrets?.map((secret) => secret.entropy) }, { name: 'isBranchHead', type: FieldType.boolean, - values: datapoints.secrets.map((secret) => secret.isBranchHead), + values: parseResult.data.secrets?.map((secret) => secret.isBranchHead), }, { name: 'firstCommitTimestamp', type: FieldType.time, - values: datapoints.secrets.map((secret) => new Date(secret.firstCommitTimestamp)), + values: parseResult.data.secrets?.map((secret) => new Date(secret.firstCommitTimestamp)), }, { name: 'isAllowlisted', type: FieldType.boolean, - values: datapoints.secrets.map((secret) => secret.isAllowlisted), + values: parseResult.data.secrets?.map((secret) => secret.isAllowlisted), }, ], }); From 82cf37ce5c9226c24f06d95c723e37649799840c Mon Sep 17 00:00:00 2001 From: Kevin Zhu Date: Wed, 21 Feb 2024 00:19:09 +1100 Subject: [PATCH 2/2] feat: add functional data source testing --- src/api/sastEvents.ts | 11 ++--- src/api/sastSummary.ts | 4 +- src/api/scaEvents.ts | 12 +++--- src/api/scaSummary.ts | 17 ++++++-- src/api/secretsEvents.ts | 10 ++--- src/api/secretsSummary.ts | 7 +-- src/datasource.ts | 91 ++++++++++++++++++++++++++++----------- 7 files changed, 103 insertions(+), 49 deletions(-) diff --git a/src/api/sastEvents.ts b/src/api/sastEvents.ts index a3bdd7f..f64500e 100644 --- a/src/api/sastEvents.ts +++ b/src/api/sastEvents.ts @@ -274,20 +274,21 @@ export const processSastEvents = async ( sort: 'asc', }; - console.log('sast event request:', params); const response: any = await request_fn('sast/events', params); - console.log('sast event response:', response); const parseResult = SastEventsApiResponseSchema.safeParse(response.data); if (!parseResult.success) { - throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); + console.error('Error in data from sast events API', parseResult.error); + console.log('sast events request:', params); + console.log('sast events response:', response); + throw new Error(`Data from the API is misformed. See console log for more details.`); } if (parseResult.data.events) { events.push(...parseResult.data.events); } - console.log('parseResult', parseResult); - console.log('events', events); + // console.log('parseResult', parseResult); + // console.log('events', events); if (!parseResult.data.events || parseResult.data.events.length === 0 || !parseResult.data.nextEventId) { // No more events diff --git a/src/api/sastSummary.ts b/src/api/sastSummary.ts index 8437fde..52f02c2 100644 --- a/src/api/sastSummary.ts +++ b/src/api/sastSummary.ts @@ -24,7 +24,9 @@ export const processSastSummary = async ( const parseResult = SastSummaryApiResponseSchema.safeParse(response.data); if (!parseResult.success) { - throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); + console.error('Error in data from sast summary API', parseResult.error); + console.log('SAST summary response:', response); + throw new Error(`Data from the API is misformed. See console log for more details.`); } return createDataFrame({ diff --git a/src/api/scaEvents.ts b/src/api/scaEvents.ts index 63eedcc..afcf046 100644 --- a/src/api/scaEvents.ts +++ b/src/api/scaEvents.ts @@ -28,7 +28,6 @@ const ScaEventsGitProvider = z.object({ bitbucket: z.any().optional(), }); - const ScaEventsEventSchema = z.union([ _BaseEventSchema.extend({ type: z.literal('new-branch-summary'), @@ -233,20 +232,21 @@ export const processScaEvents = async ( sort: 'asc', }; - console.log('sca event request:', params); const response = await request_fn('sca/events', params); - console.log('sca event response:', response); const parseResult = ScaEventsApiResponseSchema.safeParse(response.data); if (!parseResult.success) { - throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); + console.error('Error in data from sca events API', parseResult.error); + console.log('sca events request:', params); + console.log('sca events response:', response); + throw new Error(`Data from the API is misformed. See console log for more details.`); } if (parseResult.data.events) { events.push(...parseResult.data.events); } - console.log('parseResult', parseResult); - console.log('events', events); + // console.log('parseResult', parseResult); + // console.log('events', events); if (!parseResult.data.events || parseResult.data.events.length === 0 || !parseResult.data.nextEventId) { // No more events break; diff --git a/src/api/scaSummary.ts b/src/api/scaSummary.ts index a244fc1..0843d41 100644 --- a/src/api/scaSummary.ts +++ b/src/api/scaSummary.ts @@ -19,11 +19,12 @@ export const processScaSummary = async ( : {}), ...(queryOptions.queryParameters.package ? { package: queryOptions.queryParameters.package } : {}), }); - console.log('sca summary response:', response); const parseResult = ScaSummaryApiResponseSchema.safeParse(response.data); if (!parseResult.success) { - throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); + console.error('Error in data from sca summary API', parseResult.error); + console.log('sca summary response:', response); + throw new Error(`Data from the API is misformed. See console log for more details.`); } return createDataFrame({ @@ -35,13 +36,21 @@ export const processScaSummary = async ( type: FieldType.string, values: parseResult.data.vulnerabilities?.map((vuln) => vuln.isDirect), }, - { name: 'package', type: FieldType.string, values: parseResult.data.vulnerabilities?.map((vuln) => vuln.package) }, + { + name: 'package', + type: FieldType.string, + values: parseResult.data.vulnerabilities?.map((vuln) => vuln.package), + }, { name: 'packageFilePath', type: FieldType.string, values: parseResult.data.vulnerabilities?.map((vuln) => vuln.packageFilePath), }, - { name: 'version', type: FieldType.string, values: parseResult.data.vulnerabilities?.map((vuln) => vuln.version) }, + { + name: 'version', + type: FieldType.string, + values: parseResult.data.vulnerabilities?.map((vuln) => vuln.version), + }, { name: 'filePath', type: FieldType.string, diff --git a/src/api/secretsEvents.ts b/src/api/secretsEvents.ts index 9df5ed2..0d9cffc 100644 --- a/src/api/secretsEvents.ts +++ b/src/api/secretsEvents.ts @@ -118,20 +118,20 @@ export const processSecretsEvents = async ( sort: 'asc', }; - console.log('secrets event request:', params); const response = await request_fn('secrets/events', params); - console.log('secrets event response:', response); const parseResult = SecretsEventsApiResponseSchema.safeParse(response.data); if (!parseResult.success) { - throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); + console.error('Error in data from secrets event API', parseResult.error); + console.log('Secrets event request:', params); + console.log('Secrets event response:', response); + throw new Error(`Data from the API is misformed. See console log for more details.`); } if (parseResult.data.events) { events.push(...parseResult.data.events); } - console.log('parseResult', parseResult); - console.log('events', events); + // console.log('Secrets events', events); if (!parseResult.data.events || parseResult.data.events.length === 0 || !parseResult.data.nextEventId) { // No more events break; diff --git a/src/api/secretsSummary.ts b/src/api/secretsSummary.ts index 9552a2c..7f98b6c 100644 --- a/src/api/secretsSummary.ts +++ b/src/api/secretsSummary.ts @@ -21,14 +21,15 @@ export const processSecretsSummary = async ( ...(queryOptions.queryParameters.type ? { type: queryOptions.queryParameters.type } : {}), ...(queryOptions.queryParameters.allowlisted ? { allowlisted: queryOptions.queryParameters.allowlisted } : {}), }); - console.log('secrets summary response:', response); const parseResult = SecretsSummaryApiResponseSchema.safeParse(response.data); if (!parseResult.success) { - throw new Error(`Data from the API is misformed. Error:${parseResult.error}`); + console.error('Error in data from secrets summary API', parseResult.error); + console.log('secrets summary response:', response); + throw new Error(`Data from the API is misformed. See console log for more details.`); } - console.log('parseResult', parseResult); + // console.log('parseResult', parseResult); return createDataFrame({ refId: queryOptions.refId, diff --git a/src/datasource.ts b/src/datasource.ts index 66a940d..1d61b4d 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -1,4 +1,11 @@ -import { DataQueryRequest, DataQueryResponse, DataSourceApi, DataSourceInstanceSettings } from '@grafana/data'; +import { + DataQueryRequest, + DataQueryResponse, + DataSourceApi, + DataSourceInstanceSettings, + TimeRange, + dateTime, +} from '@grafana/data'; import _ from 'lodash'; import { getBackendSrv, isFetchError } from '@grafana/runtime'; @@ -34,7 +41,7 @@ export class NullifyDataSource extends DataSourceApi ({ status: 'error', message: `Error in SAST Summary: ${JSON.stringify(err.message ?? err)}` })), + processSastEvents( + { refId: 'test', endpoint: 'sast/events', queryParameters: {} }, + testTimeRange, + this._request.bind(this) + ).catch((err) => ({ status: 'error', message: `Error in SAST Events: ${JSON.stringify(err.message ?? err)}` })), + processScaSummary( + { refId: 'test', endpoint: 'sca/summary', queryParameters: {} }, + this._request.bind(this) + ).catch((err) => ({ status: 'error', message: `Error in SCA Summary: ${JSON.stringify(err.message ?? err)}` })), + processScaEvents( + { refId: 'test', endpoint: 'sca/events', queryParameters: {} }, + testTimeRange, + this._request.bind(this) + ).catch((err) => ({ status: 'error', message: `Error in SCA Events: ${JSON.stringify(err.message ?? err)}` })), + processSecretsSummary( + { refId: 'test', endpoint: 'secrets/summary', queryParameters: {} }, + this._request.bind(this) + ).catch((err) => ({ status: 'error', message: `Error in Secrets Summary: ${JSON.stringify(err.message ?? err)}` })), + processSecretsEvents( + { refId: 'test', endpoint: 'secrets/events', queryParameters: {} }, + testTimeRange, + this._request.bind(this) + ).catch((err) => ({ status: 'error', message: `Error in Secrets Events: ${JSON.stringify(err.message ?? err)}` })), + ]; + + const results = await Promise.allSettled(promises); + const err: string[] = []; + + results.forEach((result) => { + if (result.status === 'rejected') { + if (isFetchError(result.reason)) { + err.push(`Fetch error: ${result.reason.statusText}`); + } else { + err.push(result.reason); } + } else if (result.status === 'fulfilled' && 'status' in result.value && result.value.status === 'error') { + err.push(result.value.message); } + }); + + if (err.length > 0) { + console.error('Test failed', err.join('\n')); return { status: 'error', - message, + message: err.join('\n'), }; } + + console.log('Tests completed - all tests passed'); + + return { + status: 'success', + message: 'All tests passed', + }; } }