From 8df5a3cc0ea2bb3e50dd19bc032a4efe562df5fe Mon Sep 17 00:00:00 2001 From: Kevin Zhu Date: Mon, 25 Mar 2024 15:02:26 +0800 Subject: [PATCH 1/3] feat: add FileOwnerSchema; fix repository based on new schema --- src/api/common.ts | 22 ++++++++++++++++++++++ src/api/sastCommon.ts | 2 ++ src/api/scaCommon.ts | 4 ++++ src/components/Fields/RepositoryField.tsx | 8 ++------ src/datasource.ts | 8 +++----- 5 files changed, 33 insertions(+), 11 deletions(-) create mode 100644 src/api/common.ts diff --git a/src/api/common.ts b/src/api/common.ts new file mode 100644 index 0000000..1a21481 --- /dev/null +++ b/src/api/common.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; + +export const FileOwnerSchema = z.object({ + name: z.string(), + type: z.string(), +}); +export type FileOwner = z.infer + + +export const RepositorySchema = z.object({ + id: z.number(), + name: z.string(), + fullName: z.string(), + visibility: z.string(), + owner: z.object({ + id: z.number(), + name: z.string(), + slug: z.string(), + type: z.string(), + }), +}); +export type Repository = z.infer diff --git a/src/api/sastCommon.ts b/src/api/sastCommon.ts index 2d0f2fe..c7e1c3c 100644 --- a/src/api/sastCommon.ts +++ b/src/api/sastCommon.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { FileOwnerSchema } from './common'; export const SastFinding = z.object({ tenantId: z.string(), @@ -17,4 +18,5 @@ export const SastFinding = z.object({ startLine: z.number(), endLine: z.number(), isAllowlisted: z.boolean(), + fileOwners: z.array(FileOwnerSchema).nullable(), }); diff --git a/src/api/scaCommon.ts b/src/api/scaCommon.ts index 3d4c727..7df85ea 100644 --- a/src/api/scaCommon.ts +++ b/src/api/scaCommon.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { FileOwnerSchema } from './common'; const ScaEventsCve = z.object({ id: z.string().optional(), @@ -30,6 +31,8 @@ export const ScaEventsDependencyFinding = z.object({ packageFilePath: z.string().optional(), version: z.string().optional(), filePath: z.string().optional(), + repository: z.string().optional(), + branch: z.string().optional(), line: z.number().optional(), numCritical: z.number().default(0), numHigh: z.number().default(0), @@ -37,4 +40,5 @@ export const ScaEventsDependencyFinding = z.object({ numLow: z.number().default(0), numUnknown: z.number().default(0), vulnerabilities: z.array(ScaEventsVulnerability).optional().nullable(), + fileOwners: z.array(FileOwnerSchema).nullable(), }); diff --git a/src/components/Fields/RepositoryField.tsx b/src/components/Fields/RepositoryField.tsx index 849b609..39fb095 100644 --- a/src/components/Fields/RepositoryField.tsx +++ b/src/components/Fields/RepositoryField.tsx @@ -1,13 +1,9 @@ import { SelectableValue, TypedVariableModel } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; import { MultiSelect } from '@grafana/ui'; +import { Repository } from 'api/common'; import React, { useEffect, useRef, useState } from 'react'; -export interface Repository { - id: string; - name: string; -} - export interface RepositoryFieldProps { getRepositories: () => Promise; selectedRepositoryIdsOrQueries: Array; @@ -36,7 +32,7 @@ export const RepositoryField = (props: RepositoryFieldProps) => { useEffect(() => { const formatRepositorySelectableValue = (repo: Repository): SelectableValue => { - return { label: `${repo.name} (${repo.id})`, value: Number(repo.id) }; + return { label: `${repo.name} (${repo.id})`, value: repo.id }; }; const formatVariableSelectableValue = (variableName: string): SelectableValue => { return { label: `$${variableName}`, value: `$${variableName}` }; diff --git a/src/datasource.ts b/src/datasource.ts index d6b104c..e0dda4d 100644 --- a/src/datasource.ts +++ b/src/datasource.ts @@ -19,6 +19,7 @@ import { processScaSummary } from 'api/scaSummary'; import { processScaEvents } from 'api/scaEvents'; import { processSecretsSummary } from 'api/secretsSummary'; import { processSecretsEvents } from 'api/secretsEvents'; +import { Repository, RepositorySchema } from 'api/common'; export class NullifyDataSource extends DataSourceApi { instanceUrl?: string; @@ -37,14 +38,11 @@ export class NullifyDataSource extends DataSourceApi ({text: repo.name, value: repo.id})) || []; } - async getRepositories() { + async getRepositories(): Promise { const response = await this._request('admin/repositories'); const AdminRepositoriesSchema = z.object({ repositories: z.array( - z.object({ - id: z.string(), - name: z.string(), - }) + RepositorySchema ), }); From 41343ce3c22fee56d43f0df778f63a81f85f0c05 Mon Sep 17 00:00:00 2001 From: Kevin Zhu Date: Mon, 25 Mar 2024 20:49:45 +0800 Subject: [PATCH 2/3] feat: owner filter + query in sast/sca summary/events --- src/api/common.ts | 30 ++++++- src/api/sastEvents.ts | 6 ++ src/api/sastSummary.ts | 18 +++- src/api/scaEvents.ts | 6 ++ src/api/scaSummary.ts | 31 ++++++- src/components/Fields/OwnersField.tsx | 90 ++++++++++++++++++++ src/components/Fields/RepositoryField.tsx | 4 +- src/components/Query/SastEventsSubquery.tsx | 30 ++++++- src/components/Query/SastSummarySubquery.tsx | 30 ++++++- src/components/Query/ScaEventsSubquery.tsx | 30 ++++++- src/components/Query/ScaSummarySubquery.tsx | 30 ++++++- src/datasource.ts | 45 ++++++++-- src/types.ts | 16 ++++ 13 files changed, 344 insertions(+), 22 deletions(-) create mode 100644 src/components/Fields/OwnersField.tsx diff --git a/src/api/common.ts b/src/api/common.ts index 1a21481..c93c45b 100644 --- a/src/api/common.ts +++ b/src/api/common.ts @@ -4,8 +4,7 @@ export const FileOwnerSchema = z.object({ name: z.string(), type: z.string(), }); -export type FileOwner = z.infer - +export type FileOwner = z.infer; export const RepositorySchema = z.object({ id: z.number(), @@ -19,4 +18,29 @@ export const RepositorySchema = z.object({ type: z.string(), }), }); -export type Repository = z.infer +export type Repository = z.infer; + +export const TeamSchema = z.object({ + id: z.number(), + name: z.string(), + slug: z.string(), + privacy: z.string(), + numMembers: z.number(), +}); +export type Team = z.infer; + +export const UserSchema = z.object({ + id: z.number(), + name: z.string(), + slug: z.string(), +}); +export type User = z.infer; + +export const OrganizationSchema = z.object({ + id: z.number(), + name: z.string(), + slug: z.string(), + teams: z.array(TeamSchema), + members: z.array(UserSchema), +}); +export type Organization = z.infer; diff --git a/src/api/sastEvents.ts b/src/api/sastEvents.ts index ffcd240..28a0438 100644 --- a/src/api/sastEvents.ts +++ b/src/api/sastEvents.ts @@ -259,6 +259,7 @@ type SastEventsEvent = z.infer; interface SastEventsApiRequest { githubRepositoryId?: number[]; + fileOwnerName?: string[]; branch?: string; eventType?: string[]; fromTime?: string; // ISO string @@ -283,6 +284,11 @@ export const processSastEvents = async ( ), } : {}), + ...(queryOptions.queryParameters.ownerNamesOrQueries + ? { + fileOwnerName: queryOptions.queryParameters.ownerNamesOrQueries, + } + : {}), ...(queryOptions.queryParameters.branch ? { branch: queryOptions.queryParameters.branch } : {}), ...(queryOptions.queryParameters.eventTypes && queryOptions.queryParameters.eventTypes.length > 0 ? { eventType: queryOptions.queryParameters.eventTypes } diff --git a/src/api/sastSummary.ts b/src/api/sastSummary.ts index 9187d73..7a47c56 100644 --- a/src/api/sastSummary.ts +++ b/src/api/sastSummary.ts @@ -12,6 +12,7 @@ const SastSummaryApiResponseSchema = z.object({ interface SastSummaryApiRequest { githubRepositoryId?: number[]; + fileOwnerName?: string[]; severity?: string; } @@ -27,6 +28,11 @@ export const processSastSummary = async ( ), } : {}), + ...(queryOptions.queryParameters.ownerNamesOrQueries + ? { + fileOwnerName: queryOptions.queryParameters.ownerNamesOrQueries, + } + : {}), ...(queryOptions.queryParameters.severity ? { severity: queryOptions.queryParameters.severity } : {}), }; const endpointPath = 'sast/summary'; @@ -85,10 +91,20 @@ export const processSastSummary = async ( values: parseResult.data.vulnerabilities.map((vuln) => vuln.isAllowlisted), }, { - name: 'repositoryName', + name: 'repository', type: FieldType.string, values: parseResult.data.vulnerabilities.map((vuln) => vuln.repository), }, + { + name: 'branch', + type: FieldType.string, + values: parseResult.data.vulnerabilities.map((vuln) => vuln.branch), + }, + { + name: 'owners', + type: FieldType.string, + values: parseResult.data.vulnerabilities.map((vuln) => vuln.fileOwners?.map((owner) => owner.name).join(', ')), + }, ], }); }; diff --git a/src/api/scaEvents.ts b/src/api/scaEvents.ts index 1324131..883a960 100644 --- a/src/api/scaEvents.ts +++ b/src/api/scaEvents.ts @@ -208,6 +208,7 @@ type ScaEventsEvent = z.infer; interface ScaEventsApiRequest { githubRepositoryId?: number[]; + fileOwnerName?: string[]; branch?: string; eventType?: string[]; fromTime?: string; // ISO string @@ -232,6 +233,11 @@ export const processScaEvents = async ( ), } : {}), + ...(queryOptions.queryParameters.ownerNamesOrQueries + ? { + fileOwnerName: queryOptions.queryParameters.ownerNamesOrQueries, + } + : {}), ...(queryOptions.queryParameters.branch ? { branch: queryOptions.queryParameters.branch } : {}), ...(queryOptions.queryParameters.eventTypes && queryOptions.queryParameters.eventTypes.length > 0 ? { eventType: queryOptions.queryParameters.eventTypes } diff --git a/src/api/scaSummary.ts b/src/api/scaSummary.ts index 9fa7e72..bac4b59 100644 --- a/src/api/scaSummary.ts +++ b/src/api/scaSummary.ts @@ -12,6 +12,7 @@ const ScaSummaryApiResponseSchema = z.object({ interface ScaSummaryApiRequest { githubRepositoryId?: number[]; + fileOwnerName?: string[]; severity?: string; } @@ -28,6 +29,9 @@ export interface UnwoundScaEventsDependencyFinding { numMedium: number | undefined; numLow: number | undefined; numUnknown: number | undefined; + repository: string | undefined; + branch: string | undefined; + owners: string[] | undefined; vulnerabilityHasFix: boolean | undefined; vulnerabilityTitle: string | undefined; vulnerabilitySeverity: string | undefined; @@ -50,6 +54,11 @@ export const processScaSummary = async ( ), } : {}), + ...(queryOptions.queryParameters.ownerNamesOrQueries + ? { + fileOwnerName: queryOptions.queryParameters.ownerNamesOrQueries, + } + : {}), ...(queryOptions.queryParameters.package ? { package: queryOptions.queryParameters.package } : {}), }; const endpointPath = 'sca/summary'; @@ -69,7 +78,7 @@ export const processScaSummary = async ( }; } - const unwoundFindings = parseResult.data.vulnerabilities?.flatMap(finding => { + const unwoundFindings = parseResult.data.vulnerabilities?.flatMap((finding) => { let result: UnwoundScaEventsDependencyFinding[] = []; for (const vuln of finding.vulnerabilities ?? []) { result.push({ @@ -85,6 +94,9 @@ export const processScaSummary = async ( numMedium: finding.numMedium, numLow: finding.numLow, numUnknown: finding.numUnknown, + repository: finding.repository, + branch: finding.branch, + owners: finding.fileOwners?.map((owner) => owner.name), vulnerabilityHasFix: vuln.hasFix, vulnerabilityTitle: vuln.title, vulnerabilitySeverity: vuln.severity, @@ -95,7 +107,7 @@ export const processScaSummary = async ( vulnerabilityVersion: vuln.version, }); } - return result + return result; }); return createDataFrame({ @@ -151,6 +163,21 @@ export const processScaSummary = async ( type: FieldType.number, values: unwoundFindings?.map((vuln) => vuln.numLow), }, + { + name: 'repository', + type: FieldType.string, + values: unwoundFindings?.map((vuln) => vuln.repository), + }, + { + name: 'branch', + type: FieldType.string, + values: unwoundFindings?.map((vuln) => vuln.branch), + }, + { + name: 'owners', + type: FieldType.string, + values: unwoundFindings?.map((vuln) => vuln.owners?.join(', ')), + }, { name: 'vulnerabilityHasFix', type: FieldType.boolean, diff --git a/src/components/Fields/OwnersField.tsx b/src/components/Fields/OwnersField.tsx new file mode 100644 index 0000000..8601653 --- /dev/null +++ b/src/components/Fields/OwnersField.tsx @@ -0,0 +1,90 @@ +import { SelectableValue, TypedVariableModel } from '@grafana/data'; +import { getTemplateSrv } from '@grafana/runtime'; +import { MultiSelect } from '@grafana/ui'; +import { Organization } from 'api/common'; +import React, { useEffect, useRef, useState } from 'react'; +import { OwnerEntity, OwnerEntityType } from 'types'; + +export interface OwnerFieldProps { + getOwnerEntities: () => Promise; + selectedOwnerNamesOrQueries: string[]; + setSelectedOwnerNamesOrQueries: (selectedOwners: string[]) => void; +} + +export const OwnerField = (props: OwnerFieldProps) => { + const [selectedValues, setSelectedValues] = useState>>( + props.selectedOwnerNamesOrQueries.map((selected) => ({ label: selected.toString(), value: selected })) + ); + const [customOptions, setCustomOptions] = useState>>([]); + const [allOwnerOptions, setAllOwnerOptions] = useState> | undefined>(undefined); + + const getOwnerEntitiesRef = useRef(props.getOwnerEntities); + const selectedOwnerNamesOrQueriesRef = useRef(props.selectedOwnerNamesOrQueries); + + const onSelectedValuesChange = (newSelectedValues: Array>) => { + setSelectedValues(newSelectedValues); + const selectedOwners = newSelectedValues + .map((selectable) => selectable.value) + .filter((ownerNameOrQuery): ownerNameOrQuery is string => ownerNameOrQuery !== undefined); + props.setSelectedOwnerNamesOrQueries(selectedOwners); + }; + + useEffect(() => { + const formatOwnerSelectableValue = (owner: OwnerEntity): SelectableValue => { + return { label: `${owner.name} (${owner.type})`, value: owner.name }; + }; + const formatVariableSelectableValue = (variableName: string): SelectableValue => { + return { label: `$${variableName}`, value: `$${variableName}` }; + }; + + const variables: TypedVariableModel[] = getTemplateSrv().getVariables(); + + getOwnerEntitiesRef + .current() + .then((ownerEntities) => { + if (ownerEntities) { + setAllOwnerOptions([ + ...variables.map((variable) => formatVariableSelectableValue(variable.id)), + ...ownerEntities.map(formatOwnerSelectableValue), + ]); + + setSelectedValues([ + ...variables + .map((variable) => formatVariableSelectableValue(variable.id)) + .filter((selectableValue) => + selectedOwnerNamesOrQueriesRef.current.includes(selectableValue.value || '') + ), + ...ownerEntities + .map(formatOwnerSelectableValue) + .filter((selectableValue) => + selectedOwnerNamesOrQueriesRef.current.includes(selectableValue.value || '') + ), + ]); + } else { + setAllOwnerOptions([]); + } + }) + .catch(() => { + setAllOwnerOptions([]); + }); + }, []); + + return ( + <> + { + setCustomOptions([...customOptions, { value: optionValue, label: optionValue }]); + onSelectedValuesChange([...selectedValues, { value: optionValue, label: optionValue }]); + }} + /> + + ); +}; diff --git a/src/components/Fields/RepositoryField.tsx b/src/components/Fields/RepositoryField.tsx index 39fb095..a273f8a 100644 --- a/src/components/Fields/RepositoryField.tsx +++ b/src/components/Fields/RepositoryField.tsx @@ -76,12 +76,12 @@ export const RepositoryField = (props: RepositoryFieldProps) => { isLoading={allRepositoryOptions === undefined} closeMenuOnSelect={false} placeholder="Select repositories or enter a repository ID" - noOptionsMessage="No repositories found. To query a repository not listed, enter the repository ID." + noOptionsMessage="No repositories found. To query a repository not listed, enter the repository ID number." allowCustomValue value={selectedValues} onChange={onSelectedValuesChange} isValidNewOption={(inputValue) => { - return !isNaN(Number(inputValue)); + return !isNaN(Number(inputValue)) && Number(inputValue) !== 0; }} onCreateOption={(optionValue) => { const optionNumber = Number(optionValue); diff --git a/src/components/Query/SastEventsSubquery.tsx b/src/components/Query/SastEventsSubquery.tsx index c3fb583..a7ceec5 100644 --- a/src/components/Query/SastEventsSubquery.tsx +++ b/src/components/Query/SastEventsSubquery.tsx @@ -4,6 +4,7 @@ import { QueryEditorProps, SelectableValue } from '@grafana/data'; import { NullifyDataSource } from '../../datasource'; import { NullifyDataSourceOptions, NullifyQueryOptions, SastEventTypeDescriptions } from '../../types'; import { RepositoryField } from 'components/Fields/RepositoryField'; +import { OwnerField } from 'components/Fields/OwnersField'; type Props = QueryEditorProps; @@ -13,9 +14,14 @@ const SastEventTypeOptions: Array> = Object.entries(Sast export function SastEventsSubquery(props: Props) { const { query, onChange, onRunQuery } = props; - const [selectedRepositories, setSelectedRepositories] = useState>(query.queryParameters?.githubRepositoryIdsOrQueries || []); + const [selectedRepositories, setSelectedRepositories] = useState>( + query.endpoint === 'sast/events' ? query.queryParameters?.githubRepositoryIdsOrQueries || [] : [] + ); + const [selectedOwners, setSelectedOwners] = useState( + query.endpoint === 'sast/events' ? query.queryParameters?.ownerNamesOrQueries || [] : [] + ); - const onRepositoriesChange = (repositories: Array<(number | string)>) => { + const onRepositoriesChange = (repositories: Array) => { setSelectedRepositories(repositories); onChange({ ...query, @@ -25,6 +31,16 @@ export function SastEventsSubquery(props: Props) { onRunQuery(); }; + const onOwnersChange = (owners: string[]) => { + setSelectedOwners(owners); + onChange({ + ...query, + endpoint: 'sast/events', + queryParameters: { ...query.queryParameters, ownerNamesOrQueries: owners }, + }); + onRunQuery(); + }; + const onBranchChange = (event: ChangeEvent) => { onChange({ ...query, @@ -46,6 +62,16 @@ export function SastEventsSubquery(props: Props) { setSelectedRepositoryIdsOrQueries={onRepositoriesChange} /> + + + ; @@ -17,9 +18,14 @@ const severity_options: Array> = [ export function SastSummarySubquery(props: Props) { const { query, onChange, onRunQuery } = props; - const [selectedRepositories, setSelectedRepositories] = useState>(query.queryParameters?.githubRepositoryIdsOrQueries || []); + const [selectedRepositories, setSelectedRepositories] = useState>( + query.endpoint === 'sast/summary' ? query.queryParameters?.githubRepositoryIdsOrQueries || [] : [] + ); + const [selectedOwners, setSelectedOwners] = useState( + query.endpoint === 'sast/summary' ? query.queryParameters?.ownerNamesOrQueries || [] : [] + ); - const onRepositoriesChange = (repositories: Array<(number | string)>) => { + const onRepositoriesChange = (repositories: Array) => { setSelectedRepositories(repositories); onChange({ ...query, @@ -29,6 +35,16 @@ export function SastSummarySubquery(props: Props) { onRunQuery(); }; + const onOwnersChange = (owners: string[]) => { + setSelectedOwners(owners); + onChange({ + ...query, + endpoint: 'sast/summary', + queryParameters: { ...query.queryParameters, ownerNamesOrQueries: owners }, + }); + onRunQuery(); + }; + const onSeverityChange = (new_severity: string) => { onChange({ ...query, @@ -50,6 +66,16 @@ export function SastSummarySubquery(props: Props) { setSelectedRepositoryIdsOrQueries={onRepositoriesChange} /> + + + ; @@ -13,9 +14,14 @@ const ScaEventTypeOptions: Array> = Object.entries(ScaEv export function ScaEventsSubquery(props: Props) { const { query, onChange, onRunQuery } = props; - const [selectedRepositories, setSelectedRepositories] = useState>(query.queryParameters?.githubRepositoryIdsOrQueries || []); + const [selectedRepositories, setSelectedRepositories] = useState>( + query.endpoint === 'sca/events' ? query.queryParameters?.githubRepositoryIdsOrQueries || [] : [] + ); + const [selectedOwners, setSelectedOwners] = useState( + query.endpoint === 'sca/events' ? query.queryParameters?.ownerNamesOrQueries || [] : [] + ); - const onRepositoriesChange = (repositories: Array<(number | string)>) => { + const onRepositoriesChange = (repositories: Array) => { setSelectedRepositories(repositories); onChange({ ...query, @@ -25,6 +31,16 @@ export function ScaEventsSubquery(props: Props) { onRunQuery(); }; + const onOwnersChange = (owners: string[]) => { + setSelectedOwners(owners); + onChange({ + ...query, + endpoint: 'sca/events', + queryParameters: { ...query.queryParameters, ownerNamesOrQueries: owners }, + }); + onRunQuery(); + }; + const onBranchChange = (event: ChangeEvent) => { onChange({ ...query, @@ -46,6 +62,16 @@ export function ScaEventsSubquery(props: Props) { setSelectedRepositoryIdsOrQueries={onRepositoriesChange} /> + + + ; export function ScaSummarySubquery(props: Props) { const { query, onChange, onRunQuery } = props; - const [selectedRepositories, setSelectedRepositories] = useState>(query.queryParameters?.githubRepositoryIdsOrQueries || []); + const [selectedRepositories, setSelectedRepositories] = useState>( + query.endpoint === 'sca/summary' ? query.queryParameters?.githubRepositoryIdsOrQueries || [] : [] + ); + const [selectedOwners, setSelectedOwners] = useState( + query.endpoint === 'sca/summary' ? query.queryParameters?.ownerNamesOrQueries || [] : [] + ); - const onRepositoriesChange = (repositories: Array<(number | string)>) => { + const onRepositoriesChange = (repositories: Array) => { setSelectedRepositories(repositories); onChange({ ...query, @@ -21,6 +27,16 @@ export function ScaSummarySubquery(props: Props) { onRunQuery(); }; + const onOwnersChange = (owners: string[]) => { + setSelectedOwners(owners); + onChange({ + ...query, + endpoint: 'sca/summary', + queryParameters: { ...query.queryParameters, ownerNamesOrQueries: owners }, + }); + onRunQuery(); + }; + const onPackageChange = (event: ChangeEvent) => { onChange({ ...query, @@ -41,6 +57,16 @@ export function ScaSummarySubquery(props: Props) { setSelectedRepositoryIdsOrQueries={onRepositoriesChange} /> + + + { instanceUrl?: string; @@ -35,15 +41,13 @@ export class NullifyDataSource extends DataSourceApi ({text: repo.name, value: repo.id})) || []; + return repos?.map((repo) => ({ text: repo.name, value: repo.id })) || []; } async getRepositories(): Promise { const response = await this._request('admin/repositories'); const AdminRepositoriesSchema = z.object({ - repositories: z.array( - RepositorySchema - ), + repositories: z.array(RepositorySchema), }); let result = AdminRepositoriesSchema.safeParse(response.data); @@ -55,6 +59,35 @@ export class NullifyDataSource extends DataSourceApi { + const response = await this._request('admin/organization'); + const AdminOrganizationSchema = z.object({ + organization: OrganizationSchema, + }); + + let result = AdminOrganizationSchema.safeParse(response.data); + if (!result.success) { + console.error('Error in data from admin organization API', result.error); + console.log('admin organization response:', response); + return null; + } + return result.data.organization; + } + + async getOwnerEntities(): Promise { + const organization = await this.getOrganization(); + if (organization) { + return [ + ...organization.teams.map((team) => ({ + name: `${organization.slug}/${team.slug}`, + type: OwnerEntityType.Team, + })), + ...organization.members.map((user) => ({ name: user.slug, type: OwnerEntityType.User })), + ]; + } + return null; + } + async query(options: DataQueryRequest): Promise { const promises = options.targets.map(async (target) => { if (target.endpoint === 'sast/summary') { diff --git a/src/types.ts b/src/types.ts index cb10531..142402d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,6 +44,7 @@ export interface SastSummaryQueryOptions extends BaseQueryOptions { export interface SastSummaryQueryParameters { githubRepositoryIdsOrQueries?: Array; + ownerNamesOrQueries?: string[]; severity?: string; } @@ -56,6 +57,7 @@ export interface SastEventsQueryOptions extends BaseQueryOptions { export interface SastEventsQueryParameters { githubRepositoryIdsOrQueries?: Array; + ownerNamesOrQueries?: string[]; branch?: string; eventTypes?: string[]; } @@ -109,6 +111,7 @@ export interface ScaSummaryQueryOptions extends BaseQueryOptions { export interface ScaSummaryQueryParameters { githubRepositoryIdsOrQueries?: Array; + ownerNamesOrQueries?: string[]; package?: string; } @@ -121,6 +124,7 @@ export interface ScaEventsQueryOptions extends BaseQueryOptions { export interface ScaEventsQueryParameters { githubRepositoryIdsOrQueries?: Array; + ownerNamesOrQueries?: string[]; branch?: string; eventTypes?: string[]; } @@ -198,6 +202,18 @@ export const SecretsEventTypeDescriptions: Record = { [SecretsEventType.NewAllowlistedFindings]: 'New Allowlisted Findings', }; +// OWNER ENTITY TYPES + +export enum OwnerEntityType { + Team = 'Team', + User = 'User', +} + +export interface OwnerEntity { + name: string; + type: OwnerEntityType; +} + export type NullifyQueryOptions = | SastSummaryQueryOptions | SastEventsQueryOptions From 48aeac21db083b557c0f73325d935cca3b2e92d3 Mon Sep 17 00:00:00 2001 From: Kevin Zhu Date: Tue, 26 Mar 2024 01:39:36 +0800 Subject: [PATCH 3/3] feat: owner variable query (dashboard-wide query) --- example/Nullify Demo Dashboard.json | 45 +++++++++++++++++++++-- src/api/sastEvents.ts | 4 +- src/api/sastSummary.ts | 4 +- src/api/scaEvents.ts | 4 +- src/api/scaSummary.ts | 4 +- src/components/Fields/OwnersField.tsx | 10 ++++- src/components/Fields/RepositoryField.tsx | 9 ++++- src/components/VariableQueryEditor.tsx | 33 ++++++++++++++--- src/datasource.ts | 15 +++++++- src/types.ts | 7 +++- src/utils/utils.ts | 8 ++++ 11 files changed, 118 insertions(+), 25 deletions(-) diff --git a/example/Nullify Demo Dashboard.json b/example/Nullify Demo Dashboard.json index 713a951..999927f 100644 --- a/example/Nullify Demo Dashboard.json +++ b/example/Nullify Demo Dashboard.json @@ -162,6 +162,9 @@ "queryParameters": { "githubRepositoryIdsOrQueries": [ "$repository" + ], + "ownerNamesOrQueries": [ + "$owner" ] }, "refId": "A" @@ -359,7 +362,8 @@ "queryParameters": { "githubRepositoryIdsOrQueries": [ "$repository" - ] + ], + "ownerNamesOrQueries": [] }, "refId": "A" } @@ -548,6 +552,9 @@ "queryParameters": { "githubRepositoryIdsOrQueries": [ "$repository" + ], + "ownerNamesOrQueries": [ + "$owner" ] }, "refId": "A" @@ -719,7 +726,8 @@ ], "githubRepositoryIdsOrQueries": [ "$repository" - ] + ], + "ownerNamesOrQueries": [] }, "refId": "A" } @@ -845,6 +853,9 @@ "queryParameters": { "githubRepositoryIdsOrQueries": [ "$repository" + ], + "ownerNamesOrQueries": [ + "$owner" ] }, "refId": "A" @@ -1269,6 +1280,9 @@ "queryParameters": { "githubRepositoryIdsOrQueries": [ "$repository" + ], + "ownerNamesOrQueries": [ + "$owner" ] }, "refId": "A" @@ -1642,7 +1656,7 @@ "uid": "${DS_NULLIFY_DATASOURCE}" }, "definition": "Nullify Repository Query", - "description": "Filter for the repositories for which to query data", + "description": "Filter repositories for which to query data", "hide": 0, "includeAll": true, "label": "Repository", @@ -1657,6 +1671,29 @@ "skipUrlSync": false, "sort": 0, "type": "query" + }, + { + "current": {}, + "datasource": { + "type": "nullify-grafana-datasource", + "uid": "${DS_NULLIFY_DATASOURCE}" + }, + "definition": "Nullify Owner Query", + "description": "Filter code owners for which to query data", + "hide": 0, + "includeAll": true, + "label": "Owner", + "multi": true, + "name": "owner", + "options": [], + "query": { + "queryType": "Owner" + }, + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "sort": 0, + "type": "query" } ] }, @@ -1668,6 +1705,6 @@ "timezone": "", "title": "Nullify Demo Dashboard", "uid": "ecf0f5c8-bf81-479a-b72f-94b5c2855d04", - "version": 15, + "version": 3, "weekStart": "" } \ No newline at end of file diff --git a/src/api/sastEvents.ts b/src/api/sastEvents.ts index 28a0438..b4e3607 100644 --- a/src/api/sastEvents.ts +++ b/src/api/sastEvents.ts @@ -3,7 +3,7 @@ import { DataFrame, FieldType, TimeRange, createDataFrame } from '@grafana/data' import { FetchResponse, isFetchError } from '@grafana/runtime'; import { SastEventType, SastEventsQueryOptions } from 'types'; import { SastFinding } from './sastCommon'; -import { unwrapRepositoryTemplateVariables } from 'utils/utils'; +import { unwrapOwnerTemplateVariables, unwrapRepositoryTemplateVariables } from 'utils/utils'; const MAX_API_REQUESTS = 10; @@ -286,7 +286,7 @@ export const processSastEvents = async ( : {}), ...(queryOptions.queryParameters.ownerNamesOrQueries ? { - fileOwnerName: queryOptions.queryParameters.ownerNamesOrQueries, + fileOwnerName: unwrapOwnerTemplateVariables(queryOptions.queryParameters.ownerNamesOrQueries), } : {}), ...(queryOptions.queryParameters.branch ? { branch: queryOptions.queryParameters.branch } : {}), diff --git a/src/api/sastSummary.ts b/src/api/sastSummary.ts index 7a47c56..3bb176a 100644 --- a/src/api/sastSummary.ts +++ b/src/api/sastSummary.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { DataFrame, FieldType, createDataFrame } from '@grafana/data'; import { FetchResponse } from '@grafana/runtime'; import { SastSummaryQueryOptions } from 'types'; -import { prepend_severity_idx, unwrapRepositoryTemplateVariables } from 'utils/utils'; +import { prepend_severity_idx, unwrapOwnerTemplateVariables, unwrapRepositoryTemplateVariables } from 'utils/utils'; import { SastFinding } from './sastCommon'; const SastSummaryApiResponseSchema = z.object({ @@ -30,7 +30,7 @@ export const processSastSummary = async ( : {}), ...(queryOptions.queryParameters.ownerNamesOrQueries ? { - fileOwnerName: queryOptions.queryParameters.ownerNamesOrQueries, + fileOwnerName: unwrapOwnerTemplateVariables(queryOptions.queryParameters.ownerNamesOrQueries), } : {}), ...(queryOptions.queryParameters.severity ? { severity: queryOptions.queryParameters.severity } : {}), diff --git a/src/api/scaEvents.ts b/src/api/scaEvents.ts index 883a960..41e1912 100644 --- a/src/api/scaEvents.ts +++ b/src/api/scaEvents.ts @@ -3,7 +3,7 @@ import { DataFrame, FieldType, TimeRange, createDataFrame } from '@grafana/data' import { FetchResponse } from '@grafana/runtime'; import { ScaEventType, ScaEventsQueryOptions } from 'types'; import { ScaEventsDependencyFinding } from './scaCommon'; -import { unwrapRepositoryTemplateVariables } from 'utils/utils'; +import { unwrapOwnerTemplateVariables, unwrapRepositoryTemplateVariables } from 'utils/utils'; const MAX_API_REQUESTS = 10; @@ -235,7 +235,7 @@ export const processScaEvents = async ( : {}), ...(queryOptions.queryParameters.ownerNamesOrQueries ? { - fileOwnerName: queryOptions.queryParameters.ownerNamesOrQueries, + fileOwnerName: unwrapOwnerTemplateVariables(queryOptions.queryParameters.ownerNamesOrQueries), } : {}), ...(queryOptions.queryParameters.branch ? { branch: queryOptions.queryParameters.branch } : {}), diff --git a/src/api/scaSummary.ts b/src/api/scaSummary.ts index bac4b59..cec730c 100644 --- a/src/api/scaSummary.ts +++ b/src/api/scaSummary.ts @@ -3,7 +3,7 @@ import { DataFrame, FieldType, createDataFrame } from '@grafana/data'; import { FetchResponse } from '@grafana/runtime'; import { ScaSummaryQueryOptions } from 'types'; import { ScaEventsDependencyFinding } from './scaCommon'; -import { unwrapRepositoryTemplateVariables } from 'utils/utils'; +import { unwrapOwnerTemplateVariables, unwrapRepositoryTemplateVariables } from 'utils/utils'; const ScaSummaryApiResponseSchema = z.object({ vulnerabilities: z.array(ScaEventsDependencyFinding).nullable(), @@ -56,7 +56,7 @@ export const processScaSummary = async ( : {}), ...(queryOptions.queryParameters.ownerNamesOrQueries ? { - fileOwnerName: queryOptions.queryParameters.ownerNamesOrQueries, + fileOwnerName: unwrapOwnerTemplateVariables(queryOptions.queryParameters.ownerNamesOrQueries), } : {}), ...(queryOptions.queryParameters.package ? { package: queryOptions.queryParameters.package } : {}), diff --git a/src/components/Fields/OwnersField.tsx b/src/components/Fields/OwnersField.tsx index 8601653..f6d2a9c 100644 --- a/src/components/Fields/OwnersField.tsx +++ b/src/components/Fields/OwnersField.tsx @@ -3,7 +3,7 @@ import { getTemplateSrv } from '@grafana/runtime'; import { MultiSelect } from '@grafana/ui'; import { Organization } from 'api/common'; import React, { useEffect, useRef, useState } from 'react'; -import { OwnerEntity, OwnerEntityType } from 'types'; +import { NullifyVariableQuery, NullifyVariableQueryType, OwnerEntity, OwnerEntityType } from 'types'; export interface OwnerFieldProps { getOwnerEntities: () => Promise; @@ -37,7 +37,13 @@ export const OwnerField = (props: OwnerFieldProps) => { return { label: `$${variableName}`, value: `$${variableName}` }; }; - const variables: TypedVariableModel[] = getTemplateSrv().getVariables(); + const variables: TypedVariableModel[] = getTemplateSrv() + .getVariables() + .filter( + (variable) => + variable.type === 'query' && + (variable.query as NullifyVariableQuery).queryType === NullifyVariableQueryType.Owner + ); getOwnerEntitiesRef .current() diff --git a/src/components/Fields/RepositoryField.tsx b/src/components/Fields/RepositoryField.tsx index a273f8a..240902f 100644 --- a/src/components/Fields/RepositoryField.tsx +++ b/src/components/Fields/RepositoryField.tsx @@ -3,6 +3,7 @@ import { getTemplateSrv } from '@grafana/runtime'; import { MultiSelect } from '@grafana/ui'; import { Repository } from 'api/common'; import React, { useEffect, useRef, useState } from 'react'; +import { NullifyVariableQuery, NullifyVariableQueryType } from 'types'; export interface RepositoryFieldProps { getRepositories: () => Promise; @@ -38,7 +39,13 @@ export const RepositoryField = (props: RepositoryFieldProps) => { return { label: `$${variableName}`, value: `$${variableName}` }; }; - const variables: TypedVariableModel[] = getTemplateSrv().getVariables(); + const variables: TypedVariableModel[] = getTemplateSrv() + .getVariables() + .filter( + (variable) => + variable.type === 'query' && + (variable.query as NullifyVariableQuery).queryType === NullifyVariableQueryType.Repository + ); getRepositoriesRef .current() diff --git a/src/components/VariableQueryEditor.tsx b/src/components/VariableQueryEditor.tsx index 9663460..c37293b 100644 --- a/src/components/VariableQueryEditor.tsx +++ b/src/components/VariableQueryEditor.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useState } from 'react'; -import { NullifyVariableQuery, VariableQueryType } from '../types'; +import { NullifyVariableQuery, NullifyVariableQueryType } from '../types'; +import { SelectableValue } from '@grafana/data'; +import { Field, Select } from '@grafana/ui'; interface VariableQueryProps { query: NullifyVariableQuery; @@ -7,14 +9,33 @@ interface VariableQueryProps { } export const VariableQueryEditor = ({ onChange, query }: VariableQueryProps) => { - useEffect(() => { - onChange({ queryType: VariableQueryType.Repository }, `Nullify ${VariableQueryType.Repository} Query`); - }, [onChange]); + const queryTypeToSelectableValue = ( + queryType: NullifyVariableQueryType + ): SelectableValue => { + return { label: queryType, value: queryType }; + }; + + const variableOptions: Array> = + Object.values(NullifyVariableQueryType).map(queryTypeToSelectableValue); + + const [variableSelection, setVariableSelection] = useState | null>( + queryTypeToSelectableValue(query.queryType) + ); return ( <> - This query variable enables the creation of a dashboard-wide filter for repositories. - {/* ADD SELECTOR FOR OTHER QUERY TYPES (e.g. Repo/Branch/Team) */} + +