This is an automated email from the ASF dual-hosted git repository.

lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git


The following commit(s) were added to refs/heads/rocketmq-studio by this push:
     new 1b8911374 feat(acl): add ACL risk diagnostics (#3010)
1b8911374 is described below

commit 1b8911374a77cde8b5d12dff5e145dce584aeaa0
Author: coder999o <[email protected]>
AuthorDate: Fri Sep 4 14:55:52 2026 +0800

    feat(acl): add ACL risk diagnostics (#3010)
---
 web/src/i18n/translations.ts                      |  18 +
 web/src/pages/instance/__tests__/AclPage.test.tsx |  43 +-
 web/src/pages/instance/acl.tsx                    | 193 ++++++++
 web/src/utils/aclRiskDiagnostics.test.ts          | 172 +++++++
 web/src/utils/aclRiskDiagnostics.ts               | 557 ++++++++++++++++++++++
 5 files changed, 980 insertions(+), 3 deletions(-)

diff --git a/web/src/i18n/translations.ts b/web/src/i18n/translations.ts
index 71f0c18c6..b010c0dd2 100644
--- a/web/src/i18n/translations.ts
+++ b/web/src/i18n/translations.ts
@@ -1101,6 +1101,24 @@ const translations: Record<string, Record<Lang, string>> 
= {
   'acl.permType': { zh: '权限类型', en: 'Permission' },
   'acl.plainAccessSaved': { zh: 'Plain Access 账号已保存', en: 'Plain access 
account saved' },
   'acl.configExamined': { zh: '集群 ACL 配置已加载', en: 'Cluster ACL config loaded' 
},
+  'acl.riskDiagnostics': { zh: 'ACL 风险诊断', en: 'ACL Risk Diagnostics' },
+  'acl.riskHealthyMessage': {
+    zh: '未发现高风险 ACL 配置',
+    en: 'No high-risk ACL configuration found',
+  },
+  'acl.riskIssues': { zh: '风险项', en: 'Risks' },
+  'acl.riskRecommendations': { zh: '处理建议', en: 'Recommendations' },
+  'acl.riskSeverity': { zh: '级别', en: 'Severity' },
+  'acl.riskCritical': { zh: '高风险', en: 'Critical' },
+  'acl.riskWarning': { zh: '关注', en: 'Warning' },
+  'acl.riskInfo': { zh: '提示', en: 'Info' },
+  'acl.riskItem': { zh: '风险项', en: 'Risk' },
+  'acl.riskEvidence': { zh: '依据', en: 'Evidence' },
+  'acl.riskRecommendation': { zh: '建议', en: 'Recommendation' },
+  'acl.riskAdminAccounts': { zh: '管理员账号', en: 'Admin Accounts' },
+  'acl.riskDefaultAllows': { zh: '默认放行账号', en: 'Default Allows' },
+  'acl.riskWildcardAccounts': { zh: '通配授权账号', en: 'Wildcard Accounts' },
+  'acl.riskBroadWhitelists': { zh: '宽松白名单', en: 'Broad Whitelists' },
 
   // ─── Topic Page (unique keys, duplicates merged into Topic section below) 
───
   'topic.name': { zh: 'Topic 名称', en: 'Topic Name' },
diff --git a/web/src/pages/instance/__tests__/AclPage.test.tsx 
b/web/src/pages/instance/__tests__/AclPage.test.tsx
index a753c34c5..309903968 100644
--- a/web/src/pages/instance/__tests__/AclPage.test.tsx
+++ b/web/src/pages/instance/__tests__/AclPage.test.tsx
@@ -620,7 +620,7 @@ describe('ACL page', () => {
     await user.type(clusterInput, 'DefaultCluster');
     await user.click(await screen.findByRole('button', { name: /检\s*查\s*配\s*置/ 
}));
 
-    expect(await screen.findByText('rocketmq-admin')).toBeInTheDocument();
+    expect(await screen.findAllByText('rocketmq-admin')).not.toHaveLength(0);
     expect(screen.getByText('ACL 2.0')).toBeInTheDocument();
     expect(aclService.examineBrokerClusterAclConfig).toHaveBeenCalledTimes(1);
   });
@@ -717,8 +717,8 @@ describe('ACL page', () => {
       });
     });
 
-    expect(await screen.findByText('latest-account')).toBeInTheDocument();
-    expect(screen.getByText('ACL latest')).toBeInTheDocument();
+    expect(await screen.findAllByText('latest-account')).not.toHaveLength(0);
+    expect(screen.getAllByText('ACL latest')).not.toHaveLength(0);
     await waitFor(() => 
expect(examineButton).not.toHaveClass('ant-btn-loading'));
     expect(successSpy).toHaveBeenCalledTimes(1);
     expect(errorSpy).not.toHaveBeenCalled();
@@ -803,6 +803,43 @@ describe('ACL page', () => {
     expect(errorSpy).not.toHaveBeenCalled();
   });
 
+  it('renders ACL risk diagnostics for the examined cluster config', async () 
=> {
+    const user = userEvent.setup();
+    vi.mocked(aclService.examineBrokerClusterAclConfig).mockResolvedValue({
+      clusterId: 'DefaultCluster',
+      aclEnabled: true,
+      aclVersion: 'ACL 2.0',
+      globalWhiteRemoteAddresses: ['*'],
+      accounts: [
+        {
+          accessKey: 'admin-ak',
+          admin: true,
+          whiteRemoteAddress: '0.0.0.0/0',
+          defaultTopicPerm: 'ALL',
+          defaultGroupPerm: 'ALL',
+          topicPerms: ['*=ALL'],
+          groupPerms: ['*=SUB'],
+        },
+      ],
+      accountCount: 1,
+    });
+    renderWithProviders(<AclPage />);
+
+    await user.click(await screen.findByText('集群 ACL 配置'));
+    await user.click(await screen.findByRole('button', { name: /检\s*查\s*配\s*置/ 
}));
+
+    const diagnostics = await screen.findByTestId('acl-risk-diagnostics');
+    expect(within(diagnostics).getByText('ACL 风险诊断')).toBeInTheDocument();
+    expect(within(diagnostics).getByText('ACL 配置存在高风险')).toBeInTheDocument();
+    expect(within(diagnostics).getByText('全局 IP 白名单范围过大')).toBeInTheDocument();
+    expect(within(diagnostics).getByText('管理员账号可从宽网段访问')).toBeInTheDocument();
+    expect(
+      within(diagnostics).getAllByText(
+        '将默认 Topic 权限改为 DENY,并为确需访问的 Topic 配置最小权限。',
+      ),
+    ).not.toHaveLength(0);
+  });
+
   it('creates a plain access account', async () => {
     const user = userEvent.setup();
     vi.mocked(aclService.createAndUpdatePlainAccessConfig).mockResolvedValue({
diff --git a/web/src/pages/instance/acl.tsx b/web/src/pages/instance/acl.tsx
index d08ab9884..58f8924d1 100644
--- a/web/src/pages/instance/acl.tsx
+++ b/web/src/pages/instance/acl.tsx
@@ -17,6 +17,7 @@
 
 import { useEffect, useRef, useState } from 'react';
 import {
+  Alert,
   Table,
   Card,
   Button,
@@ -33,6 +34,8 @@ import {
   Badge,
   Typography,
   Flex,
+  Progress,
+  Statistic,
   message,
 } from 'antd';
 import {
@@ -66,6 +69,7 @@ import {
 import type { AclRule, AclUser, AclClusterConfig, PlainAccessConfig } from 
'../../api/acl';
 import { useInstanceFilter } from '../../hooks/useInstanceFilter';
 import { tableScrollX } from '../../utils/table';
+import { analyzeAclRisk, type AclRiskIssue } from 
'../../utils/aclRiskDiagnostics';
 
 type AclEntityId = AclRule['id'];
 type AclRuleFormValues = Pick<
@@ -856,6 +860,18 @@ const AclPageContent = ({
     DENY: 'red',
   };
 
+  const riskSeverityColor: Record<AclRiskIssue['severity'], string> = {
+    critical: 'red',
+    warning: 'gold',
+    info: 'blue',
+  };
+
+  const riskSeverityText: Record<AclRiskIssue['severity'], string> = {
+    critical: t('acl.riskCritical'),
+    warning: t('acl.riskWarning'),
+    info: t('acl.riskInfo'),
+  };
+
   const plainColumns: ColumnsType<PlainAccessConfig> = [
     {
       title: t('acl.accessKey'),
@@ -942,6 +958,108 @@ const AclPageContent = ({
     },
   ];
 
+  const aclRiskDiagnostics = clusterConfig ? analyzeAclRisk(clusterConfig) : 
null;
+
+  const aclRiskProgressStatus =
+    aclRiskDiagnostics?.status === 'critical'
+      ? 'exception'
+      : aclRiskDiagnostics?.status === 'healthy'
+        ? 'success'
+        : 'normal';
+
+  const aclRiskStrokeColor =
+    aclRiskDiagnostics?.status === 'critical'
+      ? '#ff4d4f'
+      : aclRiskDiagnostics?.status === 'warning'
+        ? '#faad14'
+        : '#52c41a';
+
+  const aclRiskSummaryItems = aclRiskDiagnostics
+    ? [
+        {
+          key: 'adminAccountCount',
+          label: t('acl.riskAdminAccounts'),
+          value: aclRiskDiagnostics.summary.adminAccountCount,
+        },
+        {
+          key: 'defaultAllowAccountCount',
+          label: t('acl.riskDefaultAllows'),
+          value: aclRiskDiagnostics.summary.defaultAllowAccountCount,
+        },
+        {
+          key: 'wildcardPermissionAccountCount',
+          label: t('acl.riskWildcardAccounts'),
+          value: aclRiskDiagnostics.summary.wildcardPermissionAccountCount,
+        },
+        {
+          key: 'broadWhitelistCount',
+          label: t('acl.riskBroadWhitelists'),
+          value: aclRiskDiagnostics.summary.broadWhitelistCount,
+        },
+      ]
+    : [];
+
+  const aclRiskColumns: ColumnsType<AclRiskIssue> = [
+    {
+      title: t('acl.riskSeverity'),
+      dataIndex: 'severity',
+      key: 'severity',
+      width: 100,
+      render: (severity: AclRiskIssue['severity']) => (
+        <Tag 
color={riskSeverityColor[severity]}>{riskSeverityText[severity]}</Tag>
+      ),
+    },
+    {
+      title: t('acl.riskItem'),
+      key: 'item',
+      width: 260,
+      render: (_: unknown, record) => (
+        <Space direction="vertical" size={2}>
+          <Typography.Text strong>{record.title}</Typography.Text>
+          <Typography.Text 
type="secondary">{record.description}</Typography.Text>
+        </Space>
+      ),
+    },
+    {
+      title: t('acl.accessKey'),
+      dataIndex: 'account',
+      key: 'account',
+      width: 160,
+      render: (account?: string) =>
+        account ? (
+          <Typography.Text style={{ fontFamily: 'monospace' 
}}>{account}</Typography.Text>
+        ) : (
+          <span style={{ color: '#8c8c8c' }}>-</span>
+        ),
+    },
+    {
+      title: t('acl.riskEvidence'),
+      dataIndex: 'evidence',
+      key: 'evidence',
+      width: 220,
+      render: (evidence: string[]) => (
+        <Space size={4} wrap>
+          {evidence.length === 0 ? (
+            <span style={{ color: '#8c8c8c' }}>-</span>
+          ) : (
+            evidence.map((item) => (
+              <Typography.Text key={item} code>
+                {item}
+              </Typography.Text>
+            ))
+          )}
+        </Space>
+      ),
+    },
+    {
+      title: t('acl.riskRecommendation'),
+      dataIndex: 'recommendation',
+      key: 'recommendation',
+      width: 280,
+      render: (text: string) => <Typography.Text>{text}</Typography.Text>,
+    },
+  ];
+
   /* ═══════════════════════════════════════════
      Render
      ═══════════════════════════════════════════ */
@@ -1222,6 +1340,81 @@ const AclPageContent = ({
                         )}
                       </div>
 
+                      {aclRiskDiagnostics && (
+                        <div
+                          data-testid="acl-risk-diagnostics"
+                          style={{
+                            border: '1px solid #f0f0f0',
+                            borderRadius: 8,
+                            padding: 16,
+                            marginBottom: 16,
+                          }}
+                        >
+                          <Flex gap={20} align="center" wrap="wrap" style={{ 
marginBottom: 16 }}>
+                            <Progress
+                              type="circle"
+                              percent={aclRiskDiagnostics.score}
+                              size={96}
+                              status={aclRiskProgressStatus}
+                              strokeColor={aclRiskStrokeColor}
+                              format={(percent) => `${percent}`}
+                            />
+                            <div style={{ minWidth: 220, flex: '1 1 260px' }}>
+                              <Typography.Title level={5} style={{ margin: 0 
}}>
+                                {t('acl.riskDiagnostics')}
+                              </Typography.Title>
+                              <Typography.Text type="secondary">
+                                {aclRiskDiagnostics.statusText}
+                              </Typography.Text>
+                              <div style={{ marginTop: 8 }}>
+                                <Tag color={aclRiskDiagnostics.statusColor}>
+                                  {t('acl.riskIssues')}: 
{aclRiskDiagnostics.issues.length}
+                                </Tag>
+                                <Tag>
+                                  {t('acl.accountCount')}: 
{aclRiskDiagnostics.summary.accountCount}
+                                </Tag>
+                              </div>
+                            </div>
+                            <Flex gap={16} wrap="wrap" style={{ flex: '2 1 
420px' }}>
+                              {aclRiskSummaryItems.map((item) => (
+                                <div key={item.key} style={{ minWidth: 118 }}>
+                                  <Statistic
+                                    title={item.label}
+                                    value={item.value}
+                                    valueStyle={{ fontSize: 22 }}
+                                  />
+                                </div>
+                              ))}
+                            </Flex>
+                          </Flex>
+
+                          {aclRiskDiagnostics.issues.length === 0 ? (
+                            <Alert type="success" showIcon 
message={t('acl.riskHealthyMessage')} />
+                          ) : (
+                            <Table<AclRiskIssue>
+                              columns={aclRiskColumns}
+                              dataSource={aclRiskDiagnostics.issues}
+                              rowKey="id"
+                              pagination={false}
+                              size="small"
+                              scroll={{ x: tableScrollX(aclRiskColumns) }}
+                              style={{ marginBottom: 12 }}
+                            />
+                          )}
+
+                          <div>
+                            <Typography.Text 
strong>{t('acl.riskRecommendations')}</Typography.Text>
+                            <ul style={{ margin: '8px 0 0', paddingLeft: 20 }}>
+                              {aclRiskDiagnostics.recommendations.map((item) 
=> (
+                                <li key={item}>
+                                  <Typography.Text>{item}</Typography.Text>
+                                </li>
+                              ))}
+                            </ul>
+                          </div>
+                        </div>
+                      )}
+
                       {/* Accounts table */}
                       <Table<PlainAccessConfig>
                         columns={plainColumns}
diff --git a/web/src/utils/aclRiskDiagnostics.test.ts 
b/web/src/utils/aclRiskDiagnostics.test.ts
new file mode 100644
index 000000000..c3648ec86
--- /dev/null
+++ b/web/src/utils/aclRiskDiagnostics.test.ts
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+import type { AclClusterConfig, PlainAccessConfig } from '../api/acl';
+import { analyzeAclRisk } from './aclRiskDiagnostics';
+
+const account = (overrides: Partial<PlainAccessConfig>): PlainAccessConfig => 
({
+  accessKey: 'svc-order',
+  secretKey: '******',
+  whiteRemoteAddress: '10.0.2.15',
+  admin: false,
+  defaultTopicPerm: 'DENY',
+  defaultGroupPerm: 'DENY',
+  topicPerms: ['order-events=PUB'],
+  groupPerms: ['cg-order=SUB'],
+  ...overrides,
+});
+
+const config = (overrides: Partial<AclClusterConfig>): AclClusterConfig => ({
+  clusterId: 'DefaultCluster',
+  aclEnabled: true,
+  aclVersion: 'ACL 2.0',
+  globalWhiteRemoteAddresses: [],
+  accounts: [account({})],
+  accountCount: 1,
+  ...overrides,
+});
+
+describe('ACL risk diagnostics', () => {
+  it('marks a least-privilege ACL config as healthy', () => {
+    const diagnostics = analyzeAclRisk(config({}));
+
+    expect(diagnostics.status).toBe('healthy');
+    expect(diagnostics.score).toBe(100);
+    expect(diagnostics.issues).toEqual([]);
+    expect(diagnostics.summary).toEqual({
+      accountCount: 1,
+      adminAccountCount: 0,
+      defaultAllowAccountCount: 0,
+      wildcardPermissionAccountCount: 0,
+      broadWhitelistCount: 0,
+      duplicateAccessKeyCount: 0,
+    });
+    expect(diagnostics.recommendations).toEqual([
+      '保持默认权限为 DENY,新增账号时继续按业务资源最小授权。',
+    ]);
+  });
+
+  it('flags disabled ACL and missing accounts as critical risks', () => {
+    const diagnostics = analyzeAclRisk(
+      config({
+        aclEnabled: false,
+        aclVersion: 'ACL 1.0',
+        accounts: [],
+        accountCount: 0,
+        globalWhiteRemoteAddresses: ['*'],
+      }),
+    );
+
+    expect(diagnostics.status).toBe('critical');
+    expect(diagnostics.score).toBeLessThan(60);
+    expect(diagnostics.summary.accountCount).toBe(0);
+    expect(diagnostics.summary.broadWhitelistCount).toBe(1);
+    expect(diagnostics.issues.map((item) => item.code)).toEqual(
+      expect.arrayContaining([
+        'ACL_DISABLED',
+        'LEGACY_ACL_VERSION',
+        'NO_PLAIN_ACCESS_ACCOUNTS',
+        'BROAD_GLOBAL_WHITELIST',
+      ]),
+    );
+  });
+
+  it('finds duplicated access keys and over-broad admin access', () => {
+    const diagnostics = analyzeAclRisk(
+      config({
+        accounts: [
+          account({
+            accessKey: 'admin-ak',
+            admin: true,
+            whiteRemoteAddress: '0.0.0.0/0',
+            defaultTopicPerm: 'ALL',
+            defaultGroupPerm: 'ALL',
+          }),
+          account({
+            accessKey: 'admin-ak',
+            admin: true,
+            whiteRemoteAddress: '10.0.0.0/8',
+            defaultTopicPerm: 'DENY',
+            defaultGroupPerm: 'DENY',
+          }),
+        ],
+        accountCount: 2,
+      }),
+    );
+
+    expect(diagnostics.status).toBe('critical');
+    expect(diagnostics.summary).toMatchObject({
+      accountCount: 2,
+      adminAccountCount: 2,
+      defaultAllowAccountCount: 1,
+      broadWhitelistCount: 2,
+      duplicateAccessKeyCount: 1,
+    });
+    expect(diagnostics.issues.map((item) => item.code)).toEqual(
+      expect.arrayContaining([
+        'MULTIPLE_ADMIN_ACCOUNTS',
+        'DUPLICATE_ACCESS_KEY',
+        'BROAD_ACCOUNT_WHITELIST',
+        'ADMIN_WITH_BROAD_ACCESS',
+        'DEFAULT_TOPIC_ALLOW',
+        'DEFAULT_GROUP_ALLOW',
+      ]),
+    );
+  });
+
+  it('reports wildcard permissions and malformed permission entries', () => {
+    const diagnostics = analyzeAclRisk(
+      config({
+        accounts: [
+          account({
+            topicPerms: ['*=ALL', 'billing-events=PUB'],
+            groupPerms: ['*=SUB', 'invalid-entry'],
+          }),
+        ],
+      }),
+    );
+
+    expect(diagnostics.status).toBe('critical');
+    expect(diagnostics.summary.wildcardPermissionAccountCount).toBe(1);
+    expect(diagnostics.issues).toEqual(
+      expect.arrayContaining([
+        expect.objectContaining({
+          code: 'WILDCARD_TOPIC_PERMISSION',
+          severity: 'critical',
+          evidence: ['*=ALL'],
+        }),
+        expect.objectContaining({
+          code: 'WILDCARD_GROUP_PERMISSION',
+          severity: 'warning',
+          evidence: ['*=SUB'],
+        }),
+        expect.objectContaining({
+          code: 'INVALID_PERMISSION_ENTRY',
+          severity: 'warning',
+          evidence: ['invalid-entry'],
+        }),
+      ]),
+    );
+    expect(diagnostics.recommendations).toEqual(
+      expect.arrayContaining([
+        '将通配 Topic 授权收敛为具体 Topic 或业务前缀,并避免 *=ALL。',
+        '按 resource=PUB、resource=SUB、resource=ALL 或 resource=DENY 的格式修正条目。',
+      ]),
+    );
+  });
+});
diff --git a/web/src/utils/aclRiskDiagnostics.ts 
b/web/src/utils/aclRiskDiagnostics.ts
new file mode 100644
index 000000000..0cbda1159
--- /dev/null
+++ b/web/src/utils/aclRiskDiagnostics.ts
@@ -0,0 +1,557 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { AclClusterConfig, PlainAccessConfig } from '../api/acl';
+
+export type AclRiskStatus = 'healthy' | 'warning' | 'critical';
+export type AclRiskSeverity = Exclude<AclRiskStatus, 'healthy'> | 'info';
+
+export type AclRiskIssueCode =
+  | 'ACL_DISABLED'
+  | 'NO_PLAIN_ACCESS_ACCOUNTS'
+  | 'LEGACY_ACL_VERSION'
+  | 'BROAD_GLOBAL_WHITELIST'
+  | 'BROAD_ACCOUNT_WHITELIST'
+  | 'DUPLICATE_ACCESS_KEY'
+  | 'MISSING_ACCESS_KEY'
+  | 'MULTIPLE_ADMIN_ACCOUNTS'
+  | 'ADMIN_WITH_BROAD_ACCESS'
+  | 'DEFAULT_TOPIC_ALLOW'
+  | 'DEFAULT_GROUP_ALLOW'
+  | 'WILDCARD_TOPIC_PERMISSION'
+  | 'WILDCARD_GROUP_PERMISSION'
+  | 'INVALID_PERMISSION_ENTRY';
+
+export interface AclRiskIssue {
+  id: string;
+  code: AclRiskIssueCode;
+  severity: AclRiskSeverity;
+  title: string;
+  description: string;
+  account?: string;
+  evidence: string[];
+  recommendation: string;
+}
+
+export interface AclRiskSummary {
+  accountCount: number;
+  adminAccountCount: number;
+  defaultAllowAccountCount: number;
+  wildcardPermissionAccountCount: number;
+  broadWhitelistCount: number;
+  duplicateAccessKeyCount: number;
+}
+
+export interface AclRiskDiagnostics {
+  status: AclRiskStatus;
+  statusText: string;
+  statusColor: 'success' | 'warning' | 'error';
+  score: number;
+  summary: AclRiskSummary;
+  issues: AclRiskIssue[];
+  recommendations: string[];
+}
+
+type AclPermission = 'DENY' | 'PUB' | 'SUB' | 'ALL' | 'UNKNOWN';
+
+interface ParsedPermissionEntry {
+  raw: string;
+  resource: string;
+  permission: AclPermission;
+}
+
+type WhitelistClass = 'open' | 'broad' | 'scoped';
+
+const STATUS_TEXT: Record<AclRiskStatus, string> = {
+  healthy: 'ACL 配置健康',
+  warning: 'ACL 配置需要关注',
+  critical: 'ACL 配置存在高风险',
+};
+
+const STATUS_COLOR: Record<AclRiskStatus, 'success' | 'warning' | 'error'> = {
+  healthy: 'success',
+  warning: 'warning',
+  critical: 'error',
+};
+
+const PERMISSION_ALLOW_RANK: Record<AclPermission, number> = {
+  DENY: 0,
+  PUB: 1,
+  SUB: 1,
+  ALL: 2,
+  UNKNOWN: 0,
+};
+
+const OPEN_WHITELIST_VALUES = new Set(['*', '0.0.0.0/0', '::/0', '0/0']);
+
+const normalizePermission = (value?: string | null): AclPermission => {
+  const normalized = (value ?? '').trim().toUpperCase();
+  if (
+    normalized === 'DENY' ||
+    normalized === 'PUB' ||
+    normalized === 'SUB' ||
+    normalized === 'ALL'
+  ) {
+    return normalized;
+  }
+  return 'UNKNOWN';
+};
+
+const splitWhitelist = (value?: string | null): string[] =>
+  (value ?? '')
+    .split(/[,\s;]+/)
+    .map((item) => item.trim())
+    .filter(Boolean);
+
+const cidrPrefix = (address: string): number | null => {
+  const match = address.match(/\/(\d{1,3})$/);
+  if (!match) return null;
+  const prefix = Number(match[1]);
+  return Number.isFinite(prefix) ? prefix : null;
+};
+
+const classifyWhitelistAddress = (address: string): WhitelistClass => {
+  const normalized = address.trim().toLowerCase();
+  if (!normalized) return 'scoped';
+  if (OPEN_WHITELIST_VALUES.has(normalized)) return 'open';
+  if (normalized.includes('*')) return 'open';
+
+  const prefix = cidrPrefix(normalized);
+  if (prefix === null) return 'scoped';
+  if (normalized.includes(':')) {
+    return prefix <= 16 ? 'broad' : 'scoped';
+  }
+  return prefix <= 8 ? 'broad' : 'scoped';
+};
+
+const classifyWhitelist = (addresses: string[]): WhitelistClass => {
+  if (addresses.some((address) => classifyWhitelistAddress(address) === 
'open')) return 'open';
+  if (addresses.some((address) => classifyWhitelistAddress(address) === 
'broad')) return 'broad';
+  return 'scoped';
+};
+
+const parsePermissionEntry = (entry: string): ParsedPermissionEntry => {
+  const raw = entry.trim();
+  const separatorIndex = raw.search(/[:=]/);
+  if (separatorIndex < 0) {
+    return {
+      raw,
+      resource: raw,
+      permission: 'UNKNOWN',
+    };
+  }
+  return {
+    raw,
+    resource: raw.slice(0, separatorIndex).trim(),
+    permission: normalizePermission(raw.slice(separatorIndex + 1)),
+  };
+};
+
+const parsePermissionEntries = (entries?: string[]): ParsedPermissionEntry[] =>
+  (entries ?? []).map(parsePermissionEntry).filter((entry) => entry.raw.length 
> 0);
+
+const isWildcardResource = (resource: string): boolean => {
+  const normalized = resource.trim();
+  return normalized === '*' || normalized === '*>*' || normalized === '*/*';
+};
+
+const accountKey = (account: PlainAccessConfig, index: number): string =>
+  (account.accessKey || `account-${index + 1}`).trim();
+
+const issue = (
+  code: AclRiskIssueCode,
+  severity: AclRiskSeverity,
+  title: string,
+  description: string,
+  recommendation: string,
+  options: {
+    account?: string;
+    evidence?: string[];
+    id?: string;
+  } = {},
+): AclRiskIssue => ({
+  id: options.id ?? [options.account, code, ...(options.evidence ?? 
[])].filter(Boolean).join(':'),
+  code,
+  severity,
+  title,
+  description,
+  account: options.account,
+  evidence: options.evidence ?? [],
+  recommendation,
+});
+
+const hasDefaultAllow = (account: PlainAccessConfig): boolean =>
+  PERMISSION_ALLOW_RANK[normalizePermission(account.defaultTopicPerm)] > 0 ||
+  PERMISSION_ALLOW_RANK[normalizePermission(account.defaultGroupPerm)] > 0;
+
+const wildcardEntries = (entries: ParsedPermissionEntry[]): 
ParsedPermissionEntry[] =>
+  entries.filter((entry) => isWildcardResource(entry.resource) && 
entry.permission !== 'DENY');
+
+const hasWildcardPermission = (account: PlainAccessConfig): boolean =>
+  wildcardEntries(parsePermissionEntries(account.topicPerms)).length > 0 ||
+  wildcardEntries(parsePermissionEntries(account.groupPerms)).length > 0;
+
+const collectDuplicateAccessKeys = (accounts: PlainAccessConfig[]): 
Set<string> => {
+  const seen = new Set<string>();
+  const duplicates = new Set<string>();
+
+  accounts.forEach((account, index) => {
+    const key = accountKey(account, index);
+    if (!account.accessKey?.trim()) return;
+    if (seen.has(key)) duplicates.add(key);
+    seen.add(key);
+  });
+
+  return duplicates;
+};
+
+const accountWhitelistClass = (account: PlainAccessConfig): WhitelistClass =>
+  classifyWhitelist(splitWhitelist(account.whiteRemoteAddress));
+
+const addDefaultPermissionIssues = (
+  issues: AclRiskIssue[],
+  account: PlainAccessConfig,
+  accessKey: string,
+) => {
+  const topicPermission = normalizePermission(account.defaultTopicPerm);
+  const groupPermission = normalizePermission(account.defaultGroupPerm);
+
+  if (topicPermission === 'ALL') {
+    issues.push(
+      issue(
+        'DEFAULT_TOPIC_ALLOW',
+        'critical',
+        '默认 Topic 权限过大',
+        '该账号默认允许所有 Topic 操作,新增 Topic 会自动继承高权限。',
+        '将默认 Topic 权限改为 DENY,并为确需访问的 Topic 配置最小权限。',
+        { account: accessKey, evidence: 
[`defaultTopicPerm=${account.defaultTopicPerm ?? '-'}`] },
+      ),
+    );
+  } else if (topicPermission === 'PUB' || topicPermission === 'SUB') {
+    issues.push(
+      issue(
+        'DEFAULT_TOPIC_ALLOW',
+        'warning',
+        '默认 Topic 权限非 DENY',
+        '该账号会自动获得新增 Topic 的默认访问能力,权限边界依赖命名规范。',
+        '优先使用 DENY 作为默认 Topic 权限,再通过 Topic 权限列表授权。',
+        { account: accessKey, evidence: 
[`defaultTopicPerm=${account.defaultTopicPerm ?? '-'}`] },
+      ),
+    );
+  }
+
+  if (groupPermission === 'ALL') {
+    issues.push(
+      issue(
+        'DEFAULT_GROUP_ALLOW',
+        'critical',
+        '默认 Group 权限过大',
+        '该账号默认允许所有 Consumer Group 操作,新增 Group 会自动继承高权限。',
+        '将默认 Group 权限改为 DENY,并为确需订阅的 Group 配置最小权限。',
+        { account: accessKey, evidence: 
[`defaultGroupPerm=${account.defaultGroupPerm ?? '-'}`] },
+      ),
+    );
+  } else if (groupPermission === 'PUB' || groupPermission === 'SUB') {
+    issues.push(
+      issue(
+        'DEFAULT_GROUP_ALLOW',
+        'warning',
+        '默认 Group 权限非 DENY',
+        '该账号会自动获得新增 Consumer Group 的默认访问能力。',
+        '优先使用 DENY 作为默认 Group 权限,再通过 Group 权限列表授权。',
+        { account: accessKey, evidence: 
[`defaultGroupPerm=${account.defaultGroupPerm ?? '-'}`] },
+      ),
+    );
+  }
+};
+
+const addWildcardPermissionIssues = (
+  issues: AclRiskIssue[],
+  account: PlainAccessConfig,
+  accessKey: string,
+) => {
+  const topicWildcards = 
wildcardEntries(parsePermissionEntries(account.topicPerms));
+  const groupWildcards = 
wildcardEntries(parsePermissionEntries(account.groupPerms));
+
+  topicWildcards.forEach((entry) => {
+    issues.push(
+      issue(
+        'WILDCARD_TOPIC_PERMISSION',
+        entry.permission === 'ALL' ? 'critical' : 'warning',
+        'Topic 通配授权过大',
+        '该账号通过通配资源获得 Topic 访问能力,可能覆盖未来新增 Topic。',
+        '将通配 Topic 授权收敛为具体 Topic 或业务前缀,并避免 *=ALL。',
+        { account: accessKey, evidence: [entry.raw] },
+      ),
+    );
+  });
+
+  groupWildcards.forEach((entry) => {
+    issues.push(
+      issue(
+        'WILDCARD_GROUP_PERMISSION',
+        entry.permission === 'ALL' ? 'critical' : 'warning',
+        'Group 通配授权过大',
+        '该账号通过通配资源获得 Consumer Group 访问能力,可能覆盖未来新增 Group。',
+        '将通配 Group 授权收敛为具体 Group 或业务前缀,并避免 *=ALL。',
+        { account: accessKey, evidence: [entry.raw] },
+      ),
+    );
+  });
+};
+
+const addInvalidPermissionEntryIssues = (
+  issues: AclRiskIssue[],
+  account: PlainAccessConfig,
+  accessKey: string,
+) => {
+  const invalidEntries = [
+    ...parsePermissionEntries(account.topicPerms),
+    ...parsePermissionEntries(account.groupPerms),
+  ].filter((entry) => entry.permission === 'UNKNOWN');
+
+  invalidEntries.forEach((entry) => {
+    issues.push(
+      issue(
+        'INVALID_PERMISSION_ENTRY',
+        'warning',
+        '权限条目格式无法识别',
+        '该权限条目没有明确的 PUB、SUB、ALL 或 DENY 决策,诊断无法判断最终权限。',
+        '按 resource=PUB、resource=SUB、resource=ALL 或 resource=DENY 的格式修正条目。',
+        { account: accessKey, evidence: [entry.raw] },
+      ),
+    );
+  });
+};
+
+const addAccountIssues = (
+  issues: AclRiskIssue[],
+  account: PlainAccessConfig,
+  index: number,
+  duplicateAccessKeys: Set<string>,
+) => {
+  const accessKey = accountKey(account, index);
+  const whitelistClass = accountWhitelistClass(account);
+  const whitelist = splitWhitelist(account.whiteRemoteAddress);
+
+  if (!account.accessKey?.trim()) {
+    issues.push(
+      issue(
+        'MISSING_ACCESS_KEY',
+        'critical',
+        'Access Key 缺失',
+        'Plain Access 账号缺少 Access Key,无法形成可审计的身份边界。',
+        '补全 Access Key,或删除无法识别身份的账号配置。',
+        { account: accessKey, id: `${index}:MISSING_ACCESS_KEY` },
+      ),
+    );
+  }
+
+  if (duplicateAccessKeys.has(accessKey)) {
+    issues.push(
+      issue(
+        'DUPLICATE_ACCESS_KEY',
+        'critical',
+        'Access Key 重复',
+        '同一个 Access Key 出现在多个 Plain Access 账号中,权限合并结果容易被误判。',
+        '保留唯一账号定义,合并必要权限后删除重复条目。',
+        {
+          account: accessKey,
+          evidence: [accessKey],
+          id: `${index}:${accessKey}:DUPLICATE_ACCESS_KEY`,
+        },
+      ),
+    );
+  }
+
+  if (whitelistClass === 'open' || whitelistClass === 'broad') {
+    issues.push(
+      issue(
+        'BROAD_ACCOUNT_WHITELIST',
+        whitelistClass === 'open' ? 'critical' : 'warning',
+        '账号 IP 白名单范围过大',
+        '该账号的 IP 白名单覆盖范围过宽,弱化了 ACL 账号和网络来源的双重约束。',
+        '将账号白名单收敛到应用出口地址或可信网段。',
+        { account: accessKey, evidence: whitelist.length ? whitelist : 
['<empty>'] },
+      ),
+    );
+  }
+
+  if (account.admin && (whitelistClass === 'open' || whitelistClass === 
'broad')) {
+    issues.push(
+      issue(
+        'ADMIN_WITH_BROAD_ACCESS',
+        'critical',
+        '管理员账号可从宽网段访问',
+        '管理员账号叠加宽松 IP 白名单后,误用或泄露影响范围会扩大到整个集群。',
+        '为管理员账号配置专用 Access Key、强约束 IP 白名单,并尽量减少长期管理员账号。',
+        { account: accessKey, evidence: whitelist.length ? whitelist : 
['<empty>'] },
+      ),
+    );
+  }
+
+  addDefaultPermissionIssues(issues, account, accessKey);
+  addWildcardPermissionIssues(issues, account, accessKey);
+  addInvalidPermissionEntryIssues(issues, account, accessKey);
+};
+
+const buildSummary = (config: AclClusterConfig): AclRiskSummary => {
+  const duplicateAccessKeys = collectDuplicateAccessKeys(config.accounts);
+  const broadGlobalCount = config.globalWhiteRemoteAddresses.filter((address) 
=> {
+    const whitelistClass = classifyWhitelistAddress(address);
+    return whitelistClass === 'open' || whitelistClass === 'broad';
+  }).length;
+
+  const accountBroadCount = config.accounts.filter((account) => {
+    const whitelistClass = accountWhitelistClass(account);
+    return whitelistClass === 'open' || whitelistClass === 'broad';
+  }).length;
+
+  return {
+    accountCount: config.accountCount ?? config.accounts.length,
+    adminAccountCount: config.accounts.filter((account) => 
account.admin).length,
+    defaultAllowAccountCount: config.accounts.filter(hasDefaultAllow).length,
+    wildcardPermissionAccountCount: 
config.accounts.filter(hasWildcardPermission).length,
+    broadWhitelistCount: broadGlobalCount + accountBroadCount,
+    duplicateAccessKeyCount: duplicateAccessKeys.size,
+  };
+};
+
+const scoreDiagnostics = (issues: AclRiskIssue[]): number => {
+  const penalty = issues.reduce((sum, item) => {
+    if (item.severity === 'critical') return sum + 25;
+    if (item.severity === 'warning') return sum + 10;
+    return sum + 4;
+  }, 0);
+  return Math.max(0, 100 - penalty);
+};
+
+const statusFromIssues = (issues: AclRiskIssue[], score: number): 
AclRiskStatus => {
+  if (issues.some((item) => item.severity === 'critical') || score < 60) 
return 'critical';
+  if (issues.some((item) => item.severity === 'warning') || score < 90) return 
'warning';
+  return 'healthy';
+};
+
+const buildRecommendations = (issues: AclRiskIssue[]): string[] => {
+  const recommendations: string[] = [];
+  const seen = new Set<string>();
+
+  issues.forEach((item) => {
+    if (seen.has(item.recommendation)) return;
+    seen.add(item.recommendation);
+    recommendations.push(item.recommendation);
+  });
+
+  if (recommendations.length === 0) {
+    recommendations.push('保持默认权限为 DENY,新增账号时继续按业务资源最小授权。');
+  }
+
+  return recommendations.slice(0, 6);
+};
+
+export const analyzeAclRisk = (config: AclClusterConfig): AclRiskDiagnostics 
=> {
+  const issues: AclRiskIssue[] = [];
+  const accounts = config.accounts ?? [];
+  const duplicateAccessKeys = collectDuplicateAccessKeys(accounts);
+  const globalWhitelistClass = 
classifyWhitelist(config.globalWhiteRemoteAddresses ?? []);
+
+  if (!config.aclEnabled) {
+    issues.push(
+      issue(
+        'ACL_DISABLED',
+        'critical',
+        'ACL 未启用',
+        '当前集群没有启用 ACL,客户端访问主要依赖网络边界。',
+        '在生产集群启用 ACL,并为管理员和应用账号配置最小权限。',
+        { evidence: [`clusterId=${config.clusterId}`] },
+      ),
+    );
+  }
+
+  if (!config.aclVersion.toUpperCase().includes('2.0')) {
+    issues.push(
+      issue(
+        'LEGACY_ACL_VERSION',
+        'info',
+        'ACL 版本较旧或未知',
+        '当前版本不是明确的 ACL 2.0,部分细粒度权限能力可能不可用。',
+        '确认集群 ACL 版本,并在升级窗口评估迁移到 ACL 2.0。',
+        { evidence: [config.aclVersion || '<unknown>'] },
+      ),
+    );
+  }
+
+  if (accounts.length === 0) {
+    issues.push(
+      issue(
+        'NO_PLAIN_ACCESS_ACCOUNTS',
+        config.aclEnabled ? 'critical' : 'warning',
+        '未配置 Plain Access 账号',
+        '集群配置中没有 Plain Access 账号,启用 ACL 后可能导致客户端或运维账号无法认证。',
+        '至少配置一个受控管理员账号和必要的应用账号,再启用严格 ACL 策略。',
+        { evidence: [`accountCount=${config.accountCount ?? 0}`] },
+      ),
+    );
+  }
+
+  if (globalWhitelistClass === 'open' || globalWhitelistClass === 'broad') {
+    issues.push(
+      issue(
+        'BROAD_GLOBAL_WHITELIST',
+        globalWhitelistClass === 'open' ? 'critical' : 'warning',
+        '全局 IP 白名单范围过大',
+        '全局白名单会绕过账号级权限判断,过宽网段会降低 ACL 的实际隔离效果。',
+        '删除全局通配白名单,改为按账号配置必要的应用出口地址。',
+        { evidence: config.globalWhiteRemoteAddresses },
+      ),
+    );
+  }
+
+  const adminAccounts = accounts.filter((account) => account.admin);
+  if (adminAccounts.length > 1) {
+    issues.push(
+      issue(
+        'MULTIPLE_ADMIN_ACCOUNTS',
+        'warning',
+        '管理员账号数量偏多',
+        '多个长期管理员账号会增加凭据轮转和误授权的管理成本。',
+        '保留最少数量的管理员账号,并将日常应用访问改为非管理员账号。',
+        { evidence: adminAccounts.map((account, index) => accountKey(account, 
index)) },
+      ),
+    );
+  }
+
+  accounts.forEach((account, index) => {
+    addAccountIssues(issues, account, index, duplicateAccessKeys);
+  });
+
+  const score = scoreDiagnostics(issues);
+  const status = statusFromIssues(issues, score);
+
+  return {
+    status,
+    statusText: STATUS_TEXT[status],
+    statusColor: STATUS_COLOR[status],
+    score,
+    summary: buildSummary({
+      ...config,
+      accounts,
+    }),
+    issues,
+    recommendations: buildRecommendations(issues),
+  };
+};

Reply via email to