mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[Cloud Security][Onboarding]GCP Onboarding - Manual (#161913)
## Summary Added basic form for GCP onboardinge99f0eac
-02f4-4f37-a41c-b78825e7e230 <img width="924" alt="Screenshot 2023-07-20 at 3 51 25 PM" src="7f5783fa
-6e29-4cb8-837a-3095c6fb292a"> <img width="849" alt="Screenshot 2023-07-20 at 3 53 25 PM" src="2ef71e15
-3597-4dc8-a97d-40990462d7ce"> <img width="934" alt="Screenshot 2023-07-20 at 3 53 33 PM" src="a8b14e54
-079a-4c23-900a-f0d237568bd2">
This commit is contained in:
parent
cbe2a09598
commit
cb04b5553e
7 changed files with 318 additions and 10 deletions
|
@ -89,11 +89,7 @@ export const cloudPostureIntegrations: CloudPostureIntegrations = {
|
|||
benchmark: i18n.translate('xpack.csp.cspmIntegration.gcpOption.benchmarkTitle', {
|
||||
defaultMessage: 'CIS GCP',
|
||||
}),
|
||||
disabled: true,
|
||||
icon: 'logoGCP',
|
||||
tooltip: i18n.translate('xpack.csp.cspmIntegration.gcpOption.tooltipContent', {
|
||||
defaultMessage: 'Coming soon',
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: CLOUDBEAT_AZURE,
|
||||
|
@ -214,3 +210,4 @@ export const cloudPostureIntegrations: CloudPostureIntegrations = {
|
|||
},
|
||||
};
|
||||
export const FINDINGS_DOCS_URL = 'https://ela.st/findings';
|
||||
export const MIN_VERSION_GCP_CIS = '1.5.0';
|
||||
|
|
|
@ -0,0 +1,295 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
EuiFieldText,
|
||||
EuiFormRow,
|
||||
EuiLink,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
EuiSelect,
|
||||
EuiForm,
|
||||
EuiCallOut,
|
||||
EuiTextArea,
|
||||
} from '@elastic/eui';
|
||||
import type { NewPackagePolicy } from '@kbn/fleet-plugin/public';
|
||||
import { NewPackagePolicyInput, PackageInfo } from '@kbn/fleet-plugin/common';
|
||||
import { FormattedMessage } from '@kbn/i18n-react';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { RadioGroup } from './csp_boxed_radio_group';
|
||||
import { getPosturePolicy, NewPackagePolicyPostureInput } from './utils';
|
||||
import { MIN_VERSION_GCP_CIS } from '../../common/constants';
|
||||
|
||||
type SetupFormatGCP = 'google_cloud_shell' | 'manual';
|
||||
const GCPSetupInfoContent = () => (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiTitle size="s">
|
||||
<h2>
|
||||
<FormattedMessage
|
||||
id="xpack.csp.gcpIntegration.setupInfoContentTitle"
|
||||
defaultMessage="Setup Access"
|
||||
/>
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiText color={'subdued'} size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.csp.gcpIntegration.setupInfoContent"
|
||||
defaultMessage="The integration will need elevated access to run some CIS benchmark rules. Select your preferred
|
||||
method of providing the GCP credentials this integration will use. You can follow these
|
||||
step-by-step instructions to generate the necessary credentials."
|
||||
/>
|
||||
</EuiText>
|
||||
</>
|
||||
);
|
||||
|
||||
/* NEED TO FIND THE REAL URL HERE LATER*/
|
||||
const DocsLink = (
|
||||
<EuiText color={'subdued'} size="s">
|
||||
<FormattedMessage
|
||||
id="xpack.csp.gcpIntegration.docsLink"
|
||||
defaultMessage="Read the {docs} for more details"
|
||||
values={{
|
||||
docs: (
|
||||
<EuiLink href="https://cloud.google.com/docs/authentication" external>
|
||||
documentation
|
||||
</EuiLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</EuiText>
|
||||
);
|
||||
|
||||
const CredentialFileText = i18n.translate(
|
||||
'xpack.csp.findings.gcpIntegration.gcpInputText.credentialFileText',
|
||||
{ defaultMessage: 'Path to JSON file containing the credentials and key used to subscribe' }
|
||||
);
|
||||
const CredentialJSONText = i18n.translate(
|
||||
'xpack.csp.findings.gcpIntegration.gcpInputText.credentialJSONText',
|
||||
{ defaultMessage: 'JSON blob containing the credentials and key used to subscribe' }
|
||||
);
|
||||
|
||||
type GcpCredentialsType = 'credentials_file' | 'credentials_json';
|
||||
type GcpFields = Record<string, { label: string; type?: 'password' | 'text' }>;
|
||||
interface GcpInputFields {
|
||||
fields: GcpFields;
|
||||
}
|
||||
|
||||
const gcpField: GcpInputFields = {
|
||||
fields: {
|
||||
project_id: {
|
||||
label: i18n.translate('xpack.csp.gcpIntegration.projectidFieldLabel', {
|
||||
defaultMessage: 'Project ID',
|
||||
}),
|
||||
type: 'text',
|
||||
},
|
||||
credentials_file: {
|
||||
label: i18n.translate('xpack.csp.gcpIntegration.credentialsFileFieldLabel', {
|
||||
defaultMessage: 'Credentials File',
|
||||
}),
|
||||
type: 'text',
|
||||
},
|
||||
credentials_json: {
|
||||
label: i18n.translate('xpack.csp.gcpIntegration.credentialsJSONFieldLabel', {
|
||||
defaultMessage: 'Credentials JSON',
|
||||
}),
|
||||
type: 'text',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const credentialOptionsList = [
|
||||
{
|
||||
label: i18n.translate('xpack.csp.gcpIntegration.credentialsFileOption', {
|
||||
defaultMessage: 'Credentials File',
|
||||
}),
|
||||
text: 'Credentials File',
|
||||
},
|
||||
{
|
||||
label: i18n.translate('xpack.csp.gcpIntegration.credentialsjsonOption', {
|
||||
defaultMessage: 'Credentials JSON',
|
||||
}),
|
||||
text: 'Credentials JSON',
|
||||
},
|
||||
];
|
||||
|
||||
const getSetupFormatOptions = (): Array<{
|
||||
id: SetupFormatGCP;
|
||||
label: string;
|
||||
disabled: boolean;
|
||||
}> => [
|
||||
{
|
||||
id: 'google_cloud_shell',
|
||||
label: i18n.translate('xpack.csp.gcpIntegration.setupFormatOptions.googleCloudShell', {
|
||||
defaultMessage: 'Google Cloud Shell',
|
||||
}),
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
id: 'manual',
|
||||
label: i18n.translate('xpack.csp.gcpIntegration.setupFormatOptions.manual', {
|
||||
defaultMessage: 'Manual',
|
||||
}),
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
newPolicy: NewPackagePolicy;
|
||||
input: Extract<
|
||||
NewPackagePolicyPostureInput,
|
||||
{ type: 'cloudbeat/cis_aws' | 'cloudbeat/cis_eks' | 'cloudbeat/cis_gcp' }
|
||||
>;
|
||||
updatePolicy(updatedPolicy: NewPackagePolicy): void;
|
||||
packageInfo: PackageInfo;
|
||||
setIsValid: (isValid: boolean) => void;
|
||||
onChange: any;
|
||||
}
|
||||
|
||||
const getInputVarsFields = (
|
||||
input: NewPackagePolicyInput,
|
||||
fields: GcpInputFields[keyof GcpInputFields]
|
||||
) =>
|
||||
Object.entries(input.streams[0].vars || {})
|
||||
.filter(([id]) => id in fields)
|
||||
.map(([id, inputVar]) => {
|
||||
const field = fields[id];
|
||||
return {
|
||||
id,
|
||||
label: field.label,
|
||||
type: field.type || 'text',
|
||||
value: inputVar.value,
|
||||
} as const;
|
||||
});
|
||||
|
||||
export const GcpCredentialsForm = ({
|
||||
input,
|
||||
newPolicy,
|
||||
updatePolicy,
|
||||
packageInfo,
|
||||
setIsValid,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const fields = getInputVarsFields(input, gcpField.fields);
|
||||
|
||||
useEffect(() => {
|
||||
const isInvalid = packageInfo.version < MIN_VERSION_GCP_CIS;
|
||||
|
||||
setIsValid(!isInvalid);
|
||||
|
||||
onChange({
|
||||
isValid: !isInvalid,
|
||||
updatedPolicy: newPolicy,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [input, packageInfo]);
|
||||
|
||||
if (packageInfo.version < MIN_VERSION_GCP_CIS) {
|
||||
return (
|
||||
<>
|
||||
<EuiSpacer size="l" />
|
||||
<EuiCallOut color="warning">
|
||||
<FormattedMessage
|
||||
id="xpack.csp.gcpIntegration.gcpNotSupportedMessage"
|
||||
defaultMessage="CIS GCP is not supported on the current Integration version, please upgrade your integration to the latest version to use CIS GCP"
|
||||
/>
|
||||
</EuiCallOut>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<GCPSetupInfoContent />
|
||||
<EuiSpacer size="l" />
|
||||
<GcpSetupAccessSelector
|
||||
onChange={(optionId) => updatePolicy(getPosturePolicy(newPolicy, input.type))}
|
||||
/>
|
||||
<EuiSpacer size="l" />
|
||||
<GcpInputVarFields
|
||||
fields={fields}
|
||||
onChange={(key, value) =>
|
||||
updatePolicy(getPosturePolicy(newPolicy, input.type, { [key]: { value } }))
|
||||
}
|
||||
/>
|
||||
<EuiSpacer size="s" />
|
||||
{DocsLink}
|
||||
<EuiSpacer />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const GcpSetupAccessSelector = ({ onChange }: { onChange(type: GcpCredentialsType): void }) => (
|
||||
<RadioGroup
|
||||
size="s"
|
||||
options={getSetupFormatOptions()}
|
||||
idSelected={'manual'}
|
||||
onChange={(id: GcpCredentialsType) => onChange(id)}
|
||||
/>
|
||||
);
|
||||
|
||||
const GcpInputVarFields = ({
|
||||
fields,
|
||||
onChange,
|
||||
}: {
|
||||
fields: Array<GcpFields[keyof GcpFields] & { value: string; id: string }>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
}) => {
|
||||
const [credentialOption, setCredentialOption] = useState('Credentials File');
|
||||
const targetFieldName = (id: string) => {
|
||||
return fields.find((element) => element.id === id);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<EuiForm component="form">
|
||||
<EuiFormRow fullWidth label={gcpField.fields.project_id.label}>
|
||||
<EuiFieldText
|
||||
id={targetFieldName('project_id')!.id}
|
||||
fullWidth
|
||||
value={targetFieldName('project_id')!.value || ''}
|
||||
onChange={(event) => onChange(targetFieldName('project_id')!.id, event.target.value)}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
<EuiFormRow fullWidth label={'Credentials'}>
|
||||
<EuiSelect
|
||||
fullWidth
|
||||
options={credentialOptionsList}
|
||||
value={credentialOption}
|
||||
onChange={(optionElem) => {
|
||||
setCredentialOption(optionElem.target.value);
|
||||
}}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
{credentialOption === 'Credentials File' && (
|
||||
<EuiFormRow fullWidth label={CredentialFileText}>
|
||||
<EuiFieldText
|
||||
id={targetFieldName('credentials_file')!.id}
|
||||
fullWidth
|
||||
value={targetFieldName('credentials_file')!.value || ''}
|
||||
onChange={(event) =>
|
||||
onChange(targetFieldName('credentials_file')!.id, event.target.value)
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
{credentialOption === 'Credentials JSON' && (
|
||||
<EuiFormRow fullWidth label={CredentialJSONText}>
|
||||
<EuiTextArea
|
||||
id={targetFieldName('credentials_json')!.id}
|
||||
fullWidth
|
||||
value={targetFieldName('credentials_json')!.value || ''}
|
||||
onChange={(event) =>
|
||||
onChange(targetFieldName('credentials_json')!.id, event.target.value)
|
||||
}
|
||||
/>
|
||||
</EuiFormRow>
|
||||
)}
|
||||
</EuiForm>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -197,7 +197,7 @@ describe('<CspPolicyTemplateForm />', () => {
|
|||
expect(option2).toBeInTheDocument();
|
||||
expect(option3).toBeInTheDocument();
|
||||
expect(option1).toBeEnabled();
|
||||
expect(option2).toBeDisabled();
|
||||
expect(option2).toBeEnabled();
|
||||
expect(option3).toBeDisabled();
|
||||
expect(option1).toBeChecked();
|
||||
});
|
||||
|
|
|
@ -20,6 +20,7 @@ import { getPolicyTemplateInputOptions, type NewPackagePolicyPostureInput } from
|
|||
import { RadioGroup } from './csp_boxed_radio_group';
|
||||
import { AwsCredentialsForm } from './aws_credentials_form/aws_credentials_form';
|
||||
import { EksCredentialsForm } from './eks_credentials_form';
|
||||
import { GcpCredentialsForm } from './gcp_credential_form';
|
||||
|
||||
interface PolicyTemplateSelectorProps {
|
||||
selectedTemplate: CloudSecurityPolicyTemplate;
|
||||
|
@ -79,6 +80,8 @@ export const PolicyTemplateVarsForm = ({ input, ...props }: PolicyTemplateVarsFo
|
|||
return <AwsCredentialsForm {...props} input={input} />;
|
||||
case 'cloudbeat/cis_eks':
|
||||
return <EksCredentialsForm {...props} input={input} />;
|
||||
case 'cloudbeat/cis_gcp':
|
||||
return <GcpCredentialsForm {...props} input={input} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -2152,6 +2152,12 @@
|
|||
"data.sessions.management.flyoutTitle": "Inspecter la session de recherche",
|
||||
"data.triggers.applyFilterDescription": "Lorsque le filtre Kibana est appliqué. Peut être un filtre simple ou un filtre de plage.",
|
||||
"data.triggers.applyFilterTitle": "Appliquer le filtre",
|
||||
"savedSearch.kibana_context.filters.help": "Spécifier des filtres génériques Kibana",
|
||||
"savedSearch.kibana_context.help": "Met à jour le contexte général de Kibana.",
|
||||
"savedSearch.kibana_context.q.help": "Spécifier une recherche en texte libre Kibana",
|
||||
"savedSearch.kibana_context.savedSearchId.help": "Spécifier l'ID de recherche enregistrée à utiliser pour les requêtes et les filtres",
|
||||
"savedSearch.kibana_context.timeRange.help": "Spécifier le filtre de plage temporelle Kibana",
|
||||
"savedSearch.legacyURLConflict.errorMessage": "Cette recherche a la même URL qu'un alias hérité. Désactiver l'alias pour résoudre cette erreur : {json}",
|
||||
"dataViews.deprecations.scriptedFieldsMessage": "Vous avez {numberOfIndexPatternsWithScriptedFields} vues de données ({titlesPreview}…) qui utilisent des champs scriptés. Les champs scriptés sont déclassés et seront supprimés à l'avenir. Utilisez plutôt des champs d'exécution.",
|
||||
"dataViews.fetchFieldErrorTitle": "Erreur lors de l'extraction des champs pour la vue de données {title} (ID : {id})",
|
||||
"dataViews.aliasLabel": "Alias",
|
||||
|
@ -4991,7 +4997,6 @@
|
|||
"savedObjectsManagement.view.savedObjectProblemErrorMessage": "Un problème est survenu avec cet objet enregistré.",
|
||||
"savedObjectsManagement.view.savedSearchDoesNotExistErrorMessage": "La recherche enregistrée associée à cet objet n'existe plus.",
|
||||
"savedSearch.legacyURLConflict.errorMessage": "Cette recherche a la même URL qu'un alias hérité. Désactiver l'alias pour résoudre cette erreur : {json}",
|
||||
"savedSearch.contentManagementType": "Recherche enregistrée",
|
||||
"securitySolutionPackages.dataTable.eventsTab.unit": "{totalCount, plural, =1 {alerte} one {alertes} many {alertes} other {alertes}}",
|
||||
"securitySolutionPackages.dataTable.unit": "{totalCount, plural, =1 {alerte} one {alertes} many {alertes} other {alertes}}",
|
||||
"securitySolutionPackages.ecsDataQualityDashboard.allTab.allFieldsTableTitle": "Tous les champs – {indexName}",
|
||||
|
@ -11442,7 +11447,6 @@
|
|||
"xpack.csp.cspmIntegration.azureOption.tooltipContent": "Bientôt disponible",
|
||||
"xpack.csp.cspmIntegration.gcpOption.benchmarkTitle": "CIS GCP",
|
||||
"xpack.csp.cspmIntegration.gcpOption.nameTitle": "GCP",
|
||||
"xpack.csp.cspmIntegration.gcpOption.tooltipContent": "Bientôt disponible",
|
||||
"xpack.csp.cspmIntegration.integration.nameTitle": "Gestion du niveau de sécurité du cloud",
|
||||
"xpack.csp.cspmIntegration.integration.shortNameTitle": "CSPM",
|
||||
"xpack.csp.dashboard.benchmarkSection.clusterTitleTooltip.clusterPrefixTitle": "Afficher tous les résultats pour ",
|
||||
|
|
|
@ -2166,6 +2166,12 @@
|
|||
"data.sessions.management.flyoutTitle": "検索セッションの検査",
|
||||
"data.triggers.applyFilterDescription": "Kibanaフィルターが適用されるとき。単一の値または範囲フィルターにすることができます。",
|
||||
"data.triggers.applyFilterTitle": "フィルターを適用",
|
||||
"savedSearch.kibana_context.filters.help": "Kibana ジェネリックフィルターを指定します",
|
||||
"savedSearch.kibana_context.help": "Kibana グローバルコンテキストを更新します",
|
||||
"savedSearch.kibana_context.q.help": "自由形式の Kibana テキストクエリを指定します",
|
||||
"savedSearch.kibana_context.savedSearchId.help": "クエリとフィルターに使用する保存検索ID を指定します。",
|
||||
"savedSearch.kibana_context.timeRange.help": "Kibana 時間範囲フィルターを指定します",
|
||||
"savedSearch.legacyURLConflict.errorMessage": "この検索にはレガシーエイリアスと同じURLがあります。このエラーを解決するには、エイリアスを無効にしてください:{json}",
|
||||
"dataViews.deprecations.scriptedFieldsMessage": "スクリプト化されたフィールドを使用する{numberOfIndexPatternsWithScriptedFields}データビュー({titlesPreview}...)があります。スクリプト化されたフィールドは廃止予定であり、今後は削除されます。ランタイムフィールドを使用してください。",
|
||||
"dataViews.fetchFieldErrorTitle": "データビューのフィールド取得中にエラーが発生 {title}(ID:{id})",
|
||||
"dataViews.aliasLabel": "エイリアス",
|
||||
|
@ -11456,7 +11462,6 @@
|
|||
"xpack.csp.cspmIntegration.azureOption.tooltipContent": "まもなくリリース",
|
||||
"xpack.csp.cspmIntegration.gcpOption.benchmarkTitle": "CIS GCP",
|
||||
"xpack.csp.cspmIntegration.gcpOption.nameTitle": "GCP",
|
||||
"xpack.csp.cspmIntegration.gcpOption.tooltipContent": "まもなくリリース",
|
||||
"xpack.csp.cspmIntegration.integration.nameTitle": "クラウドセキュリティ態勢管理",
|
||||
"xpack.csp.cspmIntegration.integration.shortNameTitle": "CSPM",
|
||||
"xpack.csp.dashboard.benchmarkSection.clusterTitleTooltip.clusterPrefixTitle": "すべての調査結果を表示 ",
|
||||
|
|
|
@ -2166,6 +2166,12 @@
|
|||
"data.sessions.management.flyoutTitle": "检查搜索会话",
|
||||
"data.triggers.applyFilterDescription": "应用 kibana 筛选时。可能是单个值或范围筛选。",
|
||||
"data.triggers.applyFilterTitle": "应用筛选",
|
||||
"savedSearch.kibana_context.filters.help": "指定 Kibana 常规筛选",
|
||||
"savedSearch.kibana_context.help": "更新 kibana 全局上下文",
|
||||
"savedSearch.kibana_context.q.help": "指定 Kibana 自由格式文本查询",
|
||||
"savedSearch.kibana_context.savedSearchId.help": "指定要用于查询和筛选的已保存搜索 ID",
|
||||
"savedSearch.kibana_context.timeRange.help": "指定 Kibana 时间范围筛选",
|
||||
"savedSearch.legacyURLConflict.errorMessage": "此搜索具有与旧版别名相同的 URL。请禁用别名以解决此错误:{json}",
|
||||
"dataViews.deprecations.scriptedFieldsMessage": "您具有 {numberOfIndexPatternsWithScriptedFields} 个使用脚本字段的数据视图 ({titlesPreview}...)。脚本字段已过时,将在未来移除。请改为使用运行时脚本。",
|
||||
"dataViews.fetchFieldErrorTitle": "提取数据视图 {title}(ID:{id})的字段时出错",
|
||||
"dataViews.aliasLabel": "别名",
|
||||
|
@ -5004,7 +5010,6 @@
|
|||
"savedObjectsManagement.view.indexPatternDoesNotExistErrorMessage": "与此对象关联的数据视图不再存在。",
|
||||
"savedObjectsManagement.view.savedObjectProblemErrorMessage": "此已保存对象有问题",
|
||||
"savedObjectsManagement.view.savedSearchDoesNotExistErrorMessage": "与此对象关联的已保存搜索已不存在。",
|
||||
"savedSearch.legacyURLConflict.errorMessage": "此搜索具有与旧版别名相同的 URL。请禁用别名以解决此错误:{json}",
|
||||
"savedSearch.contentManagementType": "已保存搜索",
|
||||
"securitySolutionPackages.dataTable.eventsTab.unit": "{totalCount, plural, =1 {告警} other {告警}}",
|
||||
"securitySolutionPackages.dataTable.unit": "{totalCount, plural, =1 {告警} other {告警}}",
|
||||
|
@ -11456,7 +11461,6 @@
|
|||
"xpack.csp.cspmIntegration.azureOption.tooltipContent": "即将推出",
|
||||
"xpack.csp.cspmIntegration.gcpOption.benchmarkTitle": "CIS GCP",
|
||||
"xpack.csp.cspmIntegration.gcpOption.nameTitle": "GCP",
|
||||
"xpack.csp.cspmIntegration.gcpOption.tooltipContent": "即将推出",
|
||||
"xpack.csp.cspmIntegration.integration.nameTitle": "云安全态势管理",
|
||||
"xpack.csp.cspmIntegration.integration.shortNameTitle": "CSPM",
|
||||
"xpack.csp.dashboard.benchmarkSection.clusterTitleTooltip.clusterPrefixTitle": "显示以下所有结果 ",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue