From 5ba5a480eed3a090bc48e0e4be39c1434c97591f Mon Sep 17 00:00:00 2001 From: llh <756459687@qq.com> Date: Thu, 5 Mar 2026 08:19:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E5=BC=80=E5=8F=91=E5=AF=B9=E5=A4=96?= =?UTF-8?q?=E5=BC=80=E6=94=BE=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .flattened-pom.xml | 2 +- doc/open-api-interface.md | 238 ++++++ doc/sql/20260303_open_api.sql | 6 + doc/sql/llm_20260208_pg.sql | 7 +- .../llm/guard/system/api/domain/SysUser.java | 28 + llm-guard-auth/.flattened-pom.xml | 25 +- .../src/main/resources/bootstrap.yml | 2 +- llm-guard-gateway/.flattened-pom.xml | 25 +- llm-guard-modules/.flattened-pom.xml | 1 + .../llm-guard-biz/.flattened-pom.xml | 25 +- .../llm-guard-open-api/.flattened-pom.xml | 104 +++ llm-guard-modules/llm-guard-open-api/pom.xml | 125 +++ .../guard/openapi/LlmOpenApiApplication.java | 16 + .../controller/OpenApiGuardController.java | 98 +++ .../domain/OpenApiGuardCheckRequest.java | 27 + .../openapi/domain/OpenApiGuardRequest.java | 64 ++ .../openapi/domain/OpenApiGuardResponse.java | 27 + .../guard/openapi/domain/OpenApiHitResp.java | 31 + .../openapi/service/OpenApiGuardService.java | 801 ++++++++++++++++++ .../src/main/resources/bootstrap.yml | 23 + .../openapi/OpenApiGuardSimulationTest.java | 231 +++++ .../llm-guard-system/.flattened-pom.xml | 25 +- .../config/UserApiCredentialInitRunner.java | 28 + .../guard/system/mapper/SysUserMapper.java | 17 + .../guard/system/service/ISysUserService.java | 7 + .../service/impl/SysUserServiceImpl.java | 62 ++ .../resources/mapper/system/SysUserMapper.xml | 29 +- llm-guard-modules/pom.xml | 1 + 28 files changed, 2056 insertions(+), 19 deletions(-) create mode 100644 doc/open-api-interface.md create mode 100644 doc/sql/20260303_open_api.sql create mode 100644 llm-guard-modules/llm-guard-open-api/.flattened-pom.xml create mode 100644 llm-guard-modules/llm-guard-open-api/pom.xml create mode 100644 llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/LlmOpenApiApplication.java create mode 100644 llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/controller/OpenApiGuardController.java create mode 100644 llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardCheckRequest.java create mode 100644 llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardRequest.java create mode 100644 llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardResponse.java create mode 100644 llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiHitResp.java create mode 100644 llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/service/OpenApiGuardService.java create mode 100644 llm-guard-modules/llm-guard-open-api/src/main/resources/bootstrap.yml create mode 100644 llm-guard-modules/llm-guard-open-api/src/test/java/com/llm/guard/openapi/OpenApiGuardSimulationTest.java create mode 100644 llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/config/UserApiCredentialInitRunner.java diff --git a/.flattened-pom.xml b/.flattened-pom.xml index 740f107..0234506 100644 --- a/.flattened-pom.xml +++ b/.flattened-pom.xml @@ -28,7 +28,7 @@ 3.0.3 0.9.1 5.4.1 - 2.6.0 + 2.8.15 UTF-8 3.18.0 2.0.57 diff --git a/doc/open-api-interface.md b/doc/open-api-interface.md new file mode 100644 index 0000000..fc17369 --- /dev/null +++ b/doc/open-api-interface.md @@ -0,0 +1,238 @@ +# LLM Guard OpenAPI 对外接口文档 + +## 1. 文档说明 +- 文档版本:v1.0 +- 适用服务:`llm-guard-open-api` +- 基础路径:`/openapi` +- 字符集:`UTF-8` +- 请求/响应格式:`application/json` + +## 1.1 测试环境信息 +- 接口地址:`http://49.233.48.5:6201/open-api/openapi/guard/check` +- `X-Api-Key`:`ak_3ebc8929866e41689ab373c2c1a383b6` +- `X-Api-Secret`:`sk_6fde3569aaa44b29b226a7264623778e0a95e22677b746969f97f193ac4ae581` + +## 2. 认证方式 +### 2.1 Header 鉴权 +每次请求必须携带以下请求头: +- `X-Api-Key` +- `X-Api-Secret` + +### 2.2 凭证发放与生命周期 +- 凭证来源:`system` 用户管理。 +- 用户新增时:若凭证为空,系统自动生成 `apiKey/apiSecret`。 +- 用户编辑时:默认保持原值不变;仅当凭证字段被设置为空时,重新生成。 +- 服务启动时:会对缺失凭证的用户自动初始化。 + +### 2.3 认证失败返回 +- HTTP 状态:`200` +- 业务码:`code=401` +- 示例: +```json +{ + "code": 401, + "msg": "apiSecret 错误" +} +``` + +## 3. 检测接口 +### 3.1 接口定义 +- 方法:`POST` +- 路径:`/openapi/guard/check` +- 说明:统一执行 ACL、ATTACK、CONTENT 三类规则检测;命中时自动落库告警日志。 +- 兼容性:顶层固定为 `reqData`,`reqData` 内支持原始网关请求体原样提交,不强制重组字段。 + +### 3.2 请求体 +```json +{ + "sourceIp": "10.0.0.1", + "path": "/api/llm/chat/completion/V2", + "method": "POST", + "interfaceType": "LLM", + "callerId": "partner-a", + "reqData": { + "messages": [ + { + "role": "user", + "content": "please union select password and email test_user@demo.com" + } + ], + "model": "通义千问2.5-72B", + "stream": false + } +} +``` + +### 3.3 请求字段说明 +说明: +- 顶层固定字段:`sourceIp`、`path`、`method`、`interfaceType`、`callerId`、`reqData`。 +- `reqData` 内部可直接放三类网关原始入参,不强制重组。 +- 系统会优先使用顶层字段做 ACL 与审计,`reqData` 用于内容检测与原文追溯。 + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| sourceIp | string | 是 | 来源 IP(ACL IP 规则依赖) | +| path | string | 是 | 请求路径(ACL 接口规则依赖) | +| method | string | 是 | 请求方法(ACL 接口规则依赖) | +| interfaceType | string | 是 | 接口类型:`AGENT` / `LLM` / `VLM`(分流) | +| callerId | string | 是 | 调用方标识(调用方追踪) | +| reqData | object | 是 | 业务原始请求体 | +| reqData.requestId | string | 否 | 请求唯一标识;为空时系统自动生成 | +| reqData.traceId | string | 否 | 链路追踪标识;为空时系统自动生成 | +| reqData.scopeCode | string | 否 | 规则作用域;默认 `GLOBAL` | +| reqData.stream | boolean | 否 | 是否流式;默认 `false` | +| reqData.text/messages/files/metadata/payload/其他任意字段 | mixed | 否 | 原始业务参数,统一纳入规则匹配语料 | + +## 4. 响应体 +### 4.1 成功响应结构 +```json +{ + "code": 200, + "msg": "来源IP不在白名单;命中攻击特征;命中内容DLP规则", + "data": { + "requestId": "req-001", + "traceId": "trace-001", + "decision": "BLOCK", + "alerted": true, + "hits": [ + { + "moduleType": "ACL", + "eventType": "ACL_IP_WHITELIST", + "ruleId": null, + "ruleCode": null, + "action": "BLOCK", + "message": "来源IP不在白名单" + }, + { + "moduleType": "ATTACK", + "eventType": "ATTACK_SIGNATURE", + "ruleId": "sig_multi_1", + "ruleCode": "ATTACK_SQL_MULTI", + "action": "ALERT", + "message": "命中攻击特征" + }, + { + "moduleType": "CONTENT", + "eventType": "CONTENT_DLP", + "ruleId": "dlp_multi_1", + "ruleCode": "DLP_EMAIL_MULTI", + "action": "ALERT", + "message": "命中内容DLP规则" + } + ] + } +} +``` + +### 4.2 响应字段说明 +| 字段 | 类型 | 说明 | +|---|---|---| +| code | number | 业务码;`200` 成功,`401` 认证失败 | +| msg | string | 命中说明汇总(`hits.message` 去重后以 `;` 拼接);未命中时为“未命中规则,允许通过” | +| data.requestId | string | 请求 ID | +| data.traceId | string | 链路 ID | +| data.decision | string | 最终决策:`ALLOW` / `BLOCK` | +| data.alerted | boolean | 是否命中任意规则 | +| data.hits | array | 命中明细列表 | +| data.hits[].moduleType | string | 模块:`ACL` / `ATTACK` / `CONTENT` | +| data.hits[].eventType | string | 事件类型 | +| data.hits[].ruleId | string | 命中的规则/特征 ID | +| data.hits[].ruleCode | string | 命中的规则编码 | +| data.hits[].action | string | 命中动作:`ALLOW` / `BLOCK` / `ALERT` / `MASK` / `REPLACE` | +| data.hits[].message | string | 命中说明 | + +### 4.3 判定规则(调用方重点) +- 是否允许放行:看 `data.decision` + - `ALLOW`:允许通过 + - `BLOCK`:不允许通过 +- 是否触发告警:看 `data.alerted` + - `true`:已触发告警并写日志 + - `false`:未触发告警 +- 命中明细:看 `data.hits[]` + - 可用于前端展示、审计留痕、二次策略路由。 + +## 5. 检测范围与执行逻辑 +### 5.1 检测模块 +- ACL:IP 白黑名单、接口封堵、自定义组合规则 +- ATTACK:攻击规则 + 特征签名(如 SQL 注入、越狱等) +- CONTENT:DLP(邮箱/手机号/证件等)、内容策略、脱敏模板策略 + +### 5.2 语料拼接范围 +系统会将下列字段聚合后参与规则匹配: +- `text` +- `path` +- `method` +- `callerId` +- `messages`(递归提取) +- `files`(递归提取) +- `metadata`(递归提取) +- `payload`(递归提取) + +## 6. 告警与审计 +命中规则时,系统自动写入: +- 事件表:`d_log_alert_event` +- 命中明细表:`d_log_alert_hit` + +写入内容包含请求标识、模块类型、事件类型、动作、命中说明、来源 IP、调用方等字段,可直接用于审计与报表。 + +## 7. 三类网关报文适配建议 +### 7.1 智能体接口(AGENT) +- 建议映射:`text`、`files`、`metadata`。 +- 原始结构可同时放入 `payload` 便于追溯。 + +### 7.2 语义模型接口(LLM) +- 建议映射:`messages`。 +- 补充模型请求参数到 `metadata/payload`。 + +### 7.3 多模态接口(VLM) +- 建议映射:`messages`(含文本/图片等多模态内容)。 +- 文件或大对象建议放 `files` 并保留 `payload` 原文。 + +## 8. 调用示例(cURL) +```bash +curl -sS "http://ip:6201/open-api/openapi/guard/check" \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: ak_3ebc8929866e41689ab373c2c1a383b6" \ + -H "X-Api-Secret: sk_6fde3569aaa44b29b226a7264623778e0a95e22677b746969f97f193ac4ae581" \ + -d '{ + "sourceIp":"10.0.0.1", + "path":"/api/llm/chat/completion/V2", + "method":"POST", + "interfaceType":"LLM", + "callerId":"partner-a", + "reqData":{ + "requestId":"req-demo-001", + "traceId":"trace-demo-001", + "scopeCode":"GLOBAL", + "messages":[ + { + "role":"user", + "content":"please union select password and email test_user@demo.com" + } + ] + } + }' +``` + +## 9. 对接注意事项 +- 顶层必填字段:`sourceIp`、`path`、`method`、`interfaceType`、`callerId`、`reqData`。缺失会返回 `code=400`。 +- 建议调用方保证 `requestId` 全局唯一,方便对账和审计检索。 +- 建议统一使用 `scopeCode=GLOBAL`(或双方约定的租户/场景编码)。 +- 当 `decision=BLOCK` 时,调用方应立即拦截业务请求,不再透传下游。 +- 当 `decision=ALLOW` 且 `alerted=true` 时,建议记录风控告警但允许继续处理。 + +## 10. 建议额外字段 +建议这些字段放在 `reqData` 内(如无对应值可不传): + +- 强烈建议: +- `sourceIp`:用于 ACL 白名单/黑名单判断。 +- `path`:用于接口封堵规则判断。 +- `method`:与 `path` 组合判断 ACL 接口规则。 +- `interfaceType`:标识 `AGENT/LLM/VLM`,便于策略分层与审计。 +- `callerId`:用于区分调用方、追踪告警来源。 + +- 可选增强: +- `tenantId` / `appId` / `bizCode`:多租户或多应用隔离策略。 +- `reqTimestamp`(毫秒时间戳):用于请求时序核对。 +- `clientVersion`:便于回溯某版本客户端行为。 +- `channel`(web/app/openapi 等):便于运营统计与分流。 diff --git a/doc/sql/20260303_open_api.sql b/doc/sql/20260303_open_api.sql new file mode 100644 index 0000000..afc948a --- /dev/null +++ b/doc/sql/20260303_open_api.sql @@ -0,0 +1,6 @@ +-- open-api 对外鉴权字段 +ALTER TABLE d_sys_user ADD COLUMN IF NOT EXISTS api_key VARCHAR(128) DEFAULT ''; +ALTER TABLE d_sys_user ADD COLUMN IF NOT EXISTS api_secret VARCHAR(256) DEFAULT ''; + +-- 为 api_key 建唯一索引(如果已有请跳过) +CREATE UNIQUE INDEX IF NOT EXISTS uk_d_sys_user_api_key ON d_sys_user(api_key); diff --git a/doc/sql/llm_20260208_pg.sql b/doc/sql/llm_20260208_pg.sql index 4facda3..6c60132 100644 --- a/doc/sql/llm_20260208_pg.sql +++ b/doc/sql/llm_20260208_pg.sql @@ -54,6 +54,8 @@ create table d_sys_user ( sex char(1) default '0', avatar varchar(100) default '', password varchar(100) default '', + api_key varchar(128) default '', + api_secret varchar(256) default '', status char(1) default '0', del_flag char(1) default '0', login_ip varchar(128) default '', @@ -66,12 +68,13 @@ create table d_sys_user ( remark varchar(500) default null, primary key (user_id) ); +CREATE UNIQUE INDEX uk_d_sys_user_api_key ON d_sys_user (api_key); -- ---------------------------- -- 初始化-用户信息表数据 -- ---------------------------- -insert into d_sys_user values('838fef38-55e6-4938-b995-b0814081e93c', 'a4209f68-23e3-45e4-88f4-2e2ceabf0851', 'admin', 'lm', '00', 'lm@163.com', '15888888888', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '', null, '管理员'); -insert into d_sys_user values('568c27fd-4dcd-4871-8f43-56fc2ccf1b60', '6c442480-97e8-42b7-a509-08b458e44dcd', 'lm', 'lm', '00', 'lm@qq.com', '15666666666', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '', null, '测试员'); +insert into d_sys_user values('838fef38-55e6-4938-b995-b0814081e93c', 'a4209f68-23e3-45e4-88f4-2e2ceabf0851', 'admin', 'lm', '00', 'lm@163.com', '15888888888', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', 'ak_admin_demo', 'sk_admin_demo', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '', null, '管理员'); +insert into d_sys_user values('568c27fd-4dcd-4871-8f43-56fc2ccf1b60', '6c442480-97e8-42b7-a509-08b458e44dcd', 'lm', 'lm', '00', 'lm@qq.com', '15666666666', '1', '', '$2a$10$7JB720yubVSZvUI0rEqK/.VqGOZTH.ulu33dHOiBE8ByOhJIrdAu2', 'ak_lm_demo', 'sk_lm_demo', '0', '0', '127.0.0.1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 'admin', CURRENT_TIMESTAMP, '', null, '测试员'); -- ---------------------------- diff --git a/llm-guard-api/llm-guard-api-system/src/main/java/com/llm/guard/system/api/domain/SysUser.java b/llm-guard-api/llm-guard-api-system/src/main/java/com/llm/guard/system/api/domain/SysUser.java index 6e886d8..3d73eb8 100644 --- a/llm-guard-api/llm-guard-api-system/src/main/java/com/llm/guard/system/api/domain/SysUser.java +++ b/llm-guard-api/llm-guard-api-system/src/main/java/com/llm/guard/system/api/domain/SysUser.java @@ -57,6 +57,12 @@ public class SysUser extends BaseEntity /** 密码 */ private String password; + /** API Key(对外接口调用) */ + private String apiKey; + + /** API Secret(对外接口调用) */ + private String apiSecret; + /** 账号状态(0正常 1停用) */ @Excel(name = "账号状态", readConverterExp = "0=正常,1=停用") private String status; @@ -222,6 +228,26 @@ public class SysUser extends BaseEntity this.status = status; } + public String getApiKey() + { + return apiKey; + } + + public void setApiKey(String apiKey) + { + this.apiKey = apiKey; + } + + public String getApiSecret() + { + return apiSecret; + } + + public void setApiSecret(String apiSecret) + { + this.apiSecret = apiSecret; + } + public String getDelFlag() { return delFlag; @@ -324,6 +350,8 @@ public class SysUser extends BaseEntity .append("sex", getSex()) .append("avatar", getAvatar()) .append("password", getPassword()) + .append("apiKey", getApiKey()) + .append("apiSecret", getApiSecret()) .append("status", getStatus()) .append("delFlag", getDelFlag()) .append("loginIp", getLoginIp()) diff --git a/llm-guard-auth/.flattened-pom.xml b/llm-guard-auth/.flattened-pom.xml index d738222..5145aaa 100644 --- a/llm-guard-auth/.flattened-pom.xml +++ b/llm-guard-auth/.flattened-pom.xml @@ -40,13 +40,32 @@ ${project.artifactId} - org.springframework.boot - spring-boot-maven-plugin + maven-jar-plugin + 3.3.0 + + + + com.llm.guard.auth.LlmAuthApplication + true + lib/ + + + + + + maven-dependency-plugin + 3.6.1 + copy-dependencies + package - repackage + copy-dependencies + + ${project.build.directory}/lib + false + diff --git a/llm-guard-auth/src/main/resources/bootstrap.yml b/llm-guard-auth/src/main/resources/bootstrap.yml index 006c91f..ce6b709 100644 --- a/llm-guard-auth/src/main/resources/bootstrap.yml +++ b/llm-guard-auth/src/main/resources/bootstrap.yml @@ -1,6 +1,6 @@ # Tomcat server: - port: 6200 + port: 6204 # Spring spring: diff --git a/llm-guard-gateway/.flattened-pom.xml b/llm-guard-gateway/.flattened-pom.xml index 2a3b530..4929488 100644 --- a/llm-guard-gateway/.flattened-pom.xml +++ b/llm-guard-gateway/.flattened-pom.xml @@ -77,13 +77,32 @@ ${project.artifactId} - org.springframework.boot - spring-boot-maven-plugin + maven-jar-plugin + 3.3.0 + + + + com.llm.guard.gateway.LlmGatewayApplication + true + lib/ + + + + + + maven-dependency-plugin + 3.6.1 + copy-dependencies + package - repackage + copy-dependencies + + ${project.build.directory}/lib + false + diff --git a/llm-guard-modules/.flattened-pom.xml b/llm-guard-modules/.flattened-pom.xml index a085528..6c51336 100644 --- a/llm-guard-modules/.flattened-pom.xml +++ b/llm-guard-modules/.flattened-pom.xml @@ -14,5 +14,6 @@ llm-guard-system llm-guard-biz + llm-guard-open-api diff --git a/llm-guard-modules/llm-guard-biz/.flattened-pom.xml b/llm-guard-modules/llm-guard-biz/.flattened-pom.xml index 819ce3f..61f74da 100644 --- a/llm-guard-modules/llm-guard-biz/.flattened-pom.xml +++ b/llm-guard-modules/llm-guard-biz/.flattened-pom.xml @@ -86,13 +86,32 @@ ${project.artifactId} - org.springframework.boot - spring-boot-maven-plugin + maven-jar-plugin + 3.3.0 + + + + com.llm.guard.biz.LlmBizApplication + true + lib/ + + + + + + maven-dependency-plugin + 3.6.1 + copy-dependencies + package - repackage + copy-dependencies + + ${project.build.directory}/lib + false + diff --git a/llm-guard-modules/llm-guard-open-api/.flattened-pom.xml b/llm-guard-modules/llm-guard-open-api/.flattened-pom.xml new file mode 100644 index 0000000..789caca --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/.flattened-pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + com.llm.guard + llm-guard-modules + 1.0.0-SNAPSHOT + + llm-guard-open-api + 1.0.0-SNAPSHOT + 对外开放接口模块 + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + org.springframework.boot + spring-boot-starter-actuator + + + com.llm.guard + llm-guard-common-datasource + + + com.llm.guard + llm-guard-common-log + + + com.llm.guard + llm-guard-common-swagger + + + com.llm.guard + llm-guard-common-security + + + com.llm.guard + llm-guard-common-mybatisplus + + + com.googlecode.aviator + aviator + + + org.postgresql + postgresql + + + org.springframework.boot + spring-boot-starter-test + test + + + com.h2database + h2 + test + + + + ${project.artifactId} + + + maven-jar-plugin + 3.3.0 + + + + com.llm.guard.openapi.LlmOpenApiApplication + true + lib/ + + + + + + maven-dependency-plugin + 3.6.1 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + false + + + + + + + diff --git a/llm-guard-modules/llm-guard-open-api/pom.xml b/llm-guard-modules/llm-guard-open-api/pom.xml new file mode 100644 index 0000000..0ef14b2 --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/pom.xml @@ -0,0 +1,125 @@ + + + + com.llm.guard + llm-guard-modules + ${revision} + + 4.0.0 + + llm-guard-open-api + + + 对外开放接口模块 + + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-discovery + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-nacos-config + + + + com.alibaba.cloud + spring-cloud-starter-alibaba-sentinel + + + + org.springframework.boot + spring-boot-starter-actuator + + + + com.llm.guard + llm-guard-common-datasource + + + + com.llm.guard + llm-guard-common-log + + + + com.llm.guard + llm-guard-common-swagger + + + + com.llm.guard + llm-guard-common-security + + + + com.llm.guard + llm-guard-common-mybatisplus + + + + com.googlecode.aviator + aviator + + + + org.postgresql + postgresql + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.h2database + h2 + test + + + + + ${project.artifactId} + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + com.llm.guard.openapi.LlmOpenApiApplication + true + lib/ + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.6.1 + + + copy-dependencies + package + + copy-dependencies + + + ${project.build.directory}/lib + false + + + + + + + diff --git a/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/LlmOpenApiApplication.java b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/LlmOpenApiApplication.java new file mode 100644 index 0000000..80aaf6d --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/LlmOpenApiApplication.java @@ -0,0 +1,16 @@ +package com.llm.guard.openapi; + +import com.llm.guard.common.security.annotation.EnableCustomConfig; +import com.llm.guard.common.security.annotation.EnableLmFeignClients; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@EnableCustomConfig +@EnableLmFeignClients +@SpringBootApplication +public class LlmOpenApiApplication { + + public static void main(String[] args) { + SpringApplication.run(LlmOpenApiApplication.class, args); + } +} diff --git a/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/controller/OpenApiGuardController.java b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/controller/OpenApiGuardController.java new file mode 100644 index 0000000..1627950 --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/controller/OpenApiGuardController.java @@ -0,0 +1,98 @@ +package com.llm.guard.openapi.controller; + +import com.llm.guard.common.core.web.domain.AjaxResult; +import com.llm.guard.openapi.domain.OpenApiGuardCheckRequest; +import com.llm.guard.openapi.domain.OpenApiGuardRequest; +import com.llm.guard.openapi.domain.OpenApiGuardResponse; +import com.llm.guard.openapi.service.OpenApiGuardService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +@RestController +@RequestMapping("/openapi/guard") +@Tag(name = "开放接口-防护检测") +@AllArgsConstructor +public class OpenApiGuardController { + + private final OpenApiGuardService openApiGuardService; + + @Operation(summary = "统一防护检测接口(ACL/ATTACK/CONTENT)") + @PostMapping("/check") + public AjaxResult check( + @RequestHeader("X-Api-Key") String apiKey, + @RequestHeader("X-Api-Secret") String apiSecret, + @RequestBody OpenApiGuardCheckRequest request + ) { + OpenApiGuardService.CallerAuthResult authResult = openApiGuardService.authenticate(apiKey, apiSecret); + if (!authResult.isSuccess()) { + return AjaxResult.error(401, authResult.getMessage()); + } + + Object reqData = request == null ? null : request.getReqData(); + if (reqData == null) { + return AjaxResult.error(400, "reqData 不能为空"); + } + + OpenApiGuardRequest guardRequest = buildGuardRequest(request); + OpenApiGuardResponse response; + try { + response = openApiGuardService.check(guardRequest, authResult.getUserName()); + } catch (IllegalArgumentException e) { + return AjaxResult.error(400, e.getMessage()); + } + return AjaxResult.success(buildMessage(response), response); + } + + private OpenApiGuardRequest buildGuardRequest(OpenApiGuardCheckRequest request) + { + OpenApiGuardRequest guardRequest = new OpenApiGuardRequest(); + guardRequest.setSourceIp(request.getSourceIp()); + guardRequest.setPath(request.getPath()); + guardRequest.setMethod(request.getMethod()); + guardRequest.setInterfaceType(request.getInterfaceType()); + guardRequest.setCallerId(request.getCallerId()); + guardRequest.setPayload(request.getReqData()); + + if (request.getReqData() instanceof Map map) + { + Map extensions = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) + { + extensions.put(String.valueOf(entry.getKey()), entry.getValue()); + } + guardRequest.setExtensions(extensions); + } + return guardRequest; + } + + private String buildMessage(OpenApiGuardResponse response) + { + if (Boolean.FALSE.equals(response.getAlerted()) || response.getHits() == null || response.getHits().isEmpty()) + { + return "未命中规则,允许通过"; + } + Set messages = new LinkedHashSet<>(); + response.getHits().forEach(hit -> { + if (hit.getMessage() != null && !hit.getMessage().isBlank()) + { + messages.add(hit.getMessage()); + } + }); + if (messages.isEmpty()) + { + return "命中规则"; + } + return String.join(";", messages); + } +} diff --git a/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardCheckRequest.java b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardCheckRequest.java new file mode 100644 index 0000000..f74c5e2 --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardCheckRequest.java @@ -0,0 +1,27 @@ +package com.llm.guard.openapi.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "开放防护检测请求包装") +public class OpenApiGuardCheckRequest { + + @Schema(description = "来源IP(ACL IP 规则依赖)", requiredMode = Schema.RequiredMode.REQUIRED) + private String sourceIp; + + @Schema(description = "请求路径(ACL 接口规则依赖)", requiredMode = Schema.RequiredMode.REQUIRED) + private String path; + + @Schema(description = "请求方法(ACL 接口规则依赖)", requiredMode = Schema.RequiredMode.REQUIRED) + private String method; + + @Schema(description = "接口类型(AGENT/LLM/VLM 分流)", requiredMode = Schema.RequiredMode.REQUIRED) + private String interfaceType; + + @Schema(description = "调用方标识(调用方追踪)", requiredMode = Schema.RequiredMode.REQUIRED) + private String callerId; + + @Schema(description = "业务请求体(固定字段,透传网关原始入参)", requiredMode = Schema.RequiredMode.REQUIRED) + private Object reqData; +} diff --git a/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardRequest.java b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardRequest.java new file mode 100644 index 0000000..40e7976 --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardRequest.java @@ -0,0 +1,64 @@ +package com.llm.guard.openapi.domain; + +import com.fasterxml.jackson.annotation.JsonAnySetter; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Data +@Schema(description = "开放防护检测请求") +public class OpenApiGuardRequest { + + @Schema(description = "请求ID,调用方可自定义") + private String requestId; + + @Schema(description = "链路追踪ID") + private String traceId; + + @Schema(description = "作用域编码,对应规则scopeCode") + private String scopeCode; + + @Schema(description = "接口类型: AGENT/LLM/VLM") + private String interfaceType; + + @Schema(description = "请求路径") + private String path; + + @Schema(description = "请求方法") + private String method; + + @Schema(description = "来源IP") + private String sourceIp; + + @Schema(description = "调用方标识") + private String callerId; + + @Schema(description = "是否流式") + private Boolean stream; + + @Schema(description = "智能体会话文本") + private String text; + + @Schema(description = "消息体(兼容语义模型/多模态)") + private List> messages; + + @Schema(description = "文件列表") + private List> files; + + @Schema(description = "附加元数据") + private Map metadata; + + @Schema(description = "原始请求体,支持任意结构") + private Object payload; + + @Schema(description = "扩展字段(透传任意入参)") + private Map extensions = new LinkedHashMap<>(); + + @JsonAnySetter + public void putExtension(String key, Object value) { + this.extensions.put(key, value); + } +} diff --git a/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardResponse.java b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardResponse.java new file mode 100644 index 0000000..abcaf5a --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiGuardResponse.java @@ -0,0 +1,27 @@ +package com.llm.guard.openapi.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "开放防护检测响应") +public class OpenApiGuardResponse { + + @Schema(description = "请求ID") + private String requestId; + + @Schema(description = "链路ID") + private String traceId; + + @Schema(description = "最终决策: ALLOW/BLOCK") + private String decision; + + @Schema(description = "是否触发告警") + private Boolean alerted; + + @Schema(description = "命中列表") + private List hits = new ArrayList<>(); +} diff --git a/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiHitResp.java b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiHitResp.java new file mode 100644 index 0000000..2915ecd --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/domain/OpenApiHitResp.java @@ -0,0 +1,31 @@ +package com.llm.guard.openapi.domain; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "命中明细") +public class OpenApiHitResp { + + @Schema(description = "模块: ACL/ATTACK/CONTENT") + private String moduleType; + + @Schema(description = "事件类型") + private String eventType; + + @Schema(description = "规则ID") + private String ruleId; + + @Schema(description = "规则编码") + private String ruleCode; + + @Schema(description = "动作: ALLOW/BLOCK/ALERT/MASK/REPLACE") + private String action; + + @Schema(description = "命中说明") + private String message; +} diff --git a/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/service/OpenApiGuardService.java b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/service/OpenApiGuardService.java new file mode 100644 index 0000000..03eec0a --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/src/main/java/com/llm/guard/openapi/service/OpenApiGuardService.java @@ -0,0 +1,801 @@ +package com.llm.guard.openapi.service; + +import com.googlecode.aviator.AviatorEvaluator; +import com.llm.guard.common.core.utils.StringUtils; +import com.llm.guard.common.core.utils.uuid.IdUtils; +import com.llm.guard.openapi.domain.OpenApiGuardRequest; +import com.llm.guard.openapi.domain.OpenApiGuardResponse; +import com.llm.guard.openapi.domain.OpenApiHitResp; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.jdbc.core.PreparedStatementCreator; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; + +import java.sql.Connection; +import java.sql.Date; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.regex.Pattern; + +@Service +@AllArgsConstructor +public class OpenApiGuardService { + + private static final String ENABLED = "ENABLED"; + + private final JdbcTemplate jdbcTemplate; + + public CallerAuthResult authenticate(String apiKey, String apiSecret) { + if (StringUtils.isAnyBlank(apiKey, apiSecret)) { + return new CallerAuthResult(false, null, "apiKey/apiSecret 不能为空"); + } + List> rows = jdbcTemplate.queryForList( + "SELECT user_id, user_name, status, del_flag, api_secret FROM d_sys_user WHERE api_key = ? LIMIT 1", + apiKey + ); + if (rows.isEmpty()) { + return new CallerAuthResult(false, null, "apiKey 不存在"); + } + Map row = rows.get(0); + String status = str(row, "status"); + String delFlag = str(row, "del_flag"); + if (!"0".equals(status) || !"0".equals(delFlag)) { + return new CallerAuthResult(false, null, "账号不可用"); + } + String dbSecret = str(row, "api_secret"); + if (!Objects.equals(dbSecret, apiSecret)) { + return new CallerAuthResult(false, null, "apiSecret 错误"); + } + return new CallerAuthResult(true, str(row, "user_name"), null); + } + + public OpenApiGuardResponse check(OpenApiGuardRequest request, String apiCaller) { + normalizeRequest(request); + validateRequiredFields(request); + OpenApiGuardResponse response = new OpenApiGuardResponse(); + response.setRequestId(request.getRequestId()); + response.setTraceId(request.getTraceId()); + response.setDecision("ALLOW"); + response.setAlerted(Boolean.FALSE); + + String corpus = buildCorpus(request).toLowerCase(Locale.ROOT); + List hits = new ArrayList<>(); + + hits.addAll(checkAclRules(request, corpus)); + hits.addAll(checkAttackRules(request, corpus)); + hits.addAll(checkContentRules(request, corpus)); + + if (!hits.isEmpty()) { + response.setAlerted(Boolean.TRUE); + for (MatchHit hit : hits) { + response.getHits().add(new OpenApiHitResp(hit.moduleType, hit.eventType, hit.ruleId, hit.ruleCode, hit.action, hit.message)); + } + boolean blocked = hits.stream().anyMatch(hit -> "BLOCK".equalsIgnoreCase(hit.action)); + response.setDecision(blocked ? "BLOCK" : "ALLOW"); + saveAlertLogs(request, apiCaller, hits); + } + return response; + } + + private void normalizeRequest(OpenApiGuardRequest request) { + if (StringUtils.isBlank(request.getRequestId())) { + request.setRequestId(firstString(request, "requestId", "request_id", "reqId", "req_id")); + } + if (StringUtils.isBlank(request.getRequestId())) { + request.setRequestId(IdUtils.fastSimpleUUID()); + } + + if (StringUtils.isBlank(request.getTraceId())) { + request.setTraceId(firstString(request, "traceId", "trace_id")); + } + if (StringUtils.isBlank(request.getTraceId())) { + request.setTraceId(IdUtils.fastSimpleUUID()); + } + + if (StringUtils.isBlank(request.getScopeCode())) { + request.setScopeCode(firstString(request, "scopeCode", "scope_code", "scope")); + } + if (StringUtils.isBlank(request.getScopeCode())) { + request.setScopeCode("GLOBAL"); + } + + if (StringUtils.isBlank(request.getInterfaceType())) { + request.setInterfaceType(firstString(request, "interfaceType", "interface_type", "sceneType", "scene_type")); + } + + if (StringUtils.isBlank(request.getPath())) { + request.setPath(firstString(request, "path", "requestPath", "request_path", "uri", "url")); + } + + if (StringUtils.isBlank(request.getMethod())) { + request.setMethod(firstString(request, "method", "httpMethod", "http_method", "requestMethod", "request_method")); + } + if (StringUtils.isBlank(request.getSourceIp())) { + request.setSourceIp(firstString(request, "sourceIp", "source_ip", "clientIp", "client_ip", "remoteIp", "remote_ip", "requestIp", "request_ip", "ip")); + } + + if (StringUtils.isBlank(request.getCallerId())) { + request.setCallerId(firstString(request, "callerId", "caller_id", "appId", "app_id", "clientId", "client_id", "tenantId", "tenant_id")); + } + + if (StringUtils.isBlank(request.getText())) { + request.setText(firstString(request, "text", "prompt", "query", "input", "content")); + } + + if (request.getStream() == null) { + request.setStream(firstBoolean(request, "stream", "isStream", "is_stream")); + } + if (request.getStream() == null) { + request.setStream(Boolean.FALSE); + } + } + + private void validateRequiredFields(OpenApiGuardRequest request) { + List missing = new ArrayList<>(); + if (StringUtils.isBlank(request.getSourceIp())) { + missing.add("sourceIp"); + } + if (StringUtils.isBlank(request.getPath())) { + missing.add("path"); + } + if (StringUtils.isBlank(request.getMethod())) { + missing.add("method"); + } + if (StringUtils.isBlank(request.getInterfaceType())) { + missing.add("interfaceType"); + } + if (StringUtils.isBlank(request.getCallerId())) { + missing.add("callerId"); + } + if (!missing.isEmpty()) { + throw new IllegalArgumentException("reqData 缺少必填字段: " + String.join(", ", missing)); + } + } + + private List checkAclRules(OpenApiGuardRequest request, String corpus) { + List hits = new ArrayList<>(); + + if (StringUtils.isNotBlank(request.getSourceIp())) { + List> ipRules = jdbcTemplate.queryForList( + "SELECT id, list_type, ip_type, ip_value FROM d_acl_ip_rule WHERE scope_code = ? AND status = ? AND is_deleted = 0 ORDER BY priority ASC", + request.getScopeCode(), ENABLED + ); + List> whiteListRules = ipRules.stream() + .filter(row -> "WHITELIST".equalsIgnoreCase(str(row, "list_type"))) + .toList(); + boolean whiteMatched = whiteListRules.isEmpty(); + for (Map row : ipRules) { + String ipType = str(row, "ip_type"); + String ipValue = str(row, "ip_value"); + if (!matchIp(request.getSourceIp(), ipType, ipValue)) { + continue; + } + String listType = str(row, "list_type"); + if ("WHITELIST".equalsIgnoreCase(listType)) { + whiteMatched = true; + } else if ("BLACKLIST".equalsIgnoreCase(listType)) { + hits.add(new MatchHit("ACL", "ACL_IP_BLACKLIST", str(row, "id"), null, "BLOCK", "命中IP黑名单规则")); + return hits; + } + } + if (!whiteMatched) { + hits.add(new MatchHit("ACL", "ACL_IP_WHITELIST", null, null, "BLOCK", "来源IP不在白名单")); + return hits; + } + } + + if (StringUtils.isNotBlank(request.getPath())) { + List> endpointRules = jdbcTemplate.queryForList( + "SELECT id, match_type, uri_pattern, methods FROM d_acl_endpoint_rule WHERE scope_code = ? AND status = ? AND is_deleted = 0", + request.getScopeCode(), ENABLED + ); + for (Map row : endpointRules) { + if (!matchMethod(request.getMethod(), str(row, "methods"))) { + continue; + } + if (matchPath(request.getPath(), str(row, "match_type"), str(row, "uri_pattern"))) { + hits.add(new MatchHit("ACL", "ACL_ENDPOINT_BLOCK", str(row, "id"), null, "BLOCK", "命中接口封堵规则")); + return hits; + } + } + } + + List> customRules = jdbcTemplate.queryForList( + "SELECT id, rule_code, logic_op, action FROM d_acl_custom_rule WHERE scope_code = ? AND status = ? AND is_deleted = 0 ORDER BY priority ASC", + request.getScopeCode(), ENABLED + ); + Map env = buildAviatorEnv(request, corpus); + for (Map rule : customRules) { + List> conds = jdbcTemplate.queryForList( + "SELECT condition_expr FROM d_acl_custom_condition WHERE rule_id = ? AND is_deleted = 0 ORDER BY seq_no ASC", + str(rule, "id") + ); + if (conds.isEmpty()) { + continue; + } + String logicOp = str(rule, "logic_op"); + boolean matched = "OR".equalsIgnoreCase(logicOp) ? false : true; + for (Map cond : conds) { + boolean one = evalCondition(str(cond, "condition_expr"), env); + if ("OR".equalsIgnoreCase(logicOp)) { + matched = matched || one; + } else { + matched = matched && one; + } + } + if (matched) { + String action = upperOrDefault(val(rule, "action"), "ALERT"); + hits.add(new MatchHit("ACL", "ACL_CUSTOM_RULE", str(rule, "id"), str(rule, "rule_code"), action, "命中自定义组合规则")); + if ("BLOCK".equalsIgnoreCase(action)) { + return hits; + } + } + } + + return hits; + } + + private List checkAttackRules(OpenApiGuardRequest request, String corpus) { + List hits = new ArrayList<>(); + Map switches = loadAttackSwitches(request.getScopeCode()); + Map actionByRule = new HashMap<>(); + + List> rules = jdbcTemplate.queryForList( + "SELECT id, rule_code, category, sub_type, action FROM d_attack_rule WHERE scope_code = ? AND status = ? AND is_deleted = 0", + request.getScopeCode(), ENABLED + ); + List> signatures = jdbcTemplate.queryForList( + "SELECT id, sig_type, sig_value, rule_code FROM d_attack_signature WHERE scope_code = ? AND status = ? AND is_deleted = 0", + request.getScopeCode(), ENABLED + ); + + for (Map rule : rules) { + String switchKey = buildSwitchKey(str(rule, "category"), str(rule, "sub_type")); + if (switches.containsKey(switchKey) && !Boolean.TRUE.equals(switches.get(switchKey))) { + continue; + } + actionByRule.put(str(rule, "rule_code"), upperOrDefault(val(rule, "action"), "ALERT")); + } + + for (Map sig : signatures) { + String ruleCode = str(sig, "rule_code"); + if (!actionByRule.containsKey(ruleCode)) { + continue; + } + if (matchSignature(corpus, str(sig, "sig_type"), str(sig, "sig_value"))) { + String action = actionByRule.get(ruleCode); + hits.add(new MatchHit("ATTACK", "ATTACK_SIGNATURE", str(sig, "id"), ruleCode, action, "命中攻击特征")); + if ("BLOCK".equalsIgnoreCase(action)) { + return hits; + } + } + } + return hits; + } + + private List checkContentRules(OpenApiGuardRequest request, String corpus) { + List hits = new ArrayList<>(); + + List> dlpRules = jdbcTemplate.queryForList( + "SELECT id, rule_code, data_type, action FROM d_content_dlp_rule WHERE scope_code = ? AND status = ? AND is_deleted = 0", + request.getScopeCode(), ENABLED + ); + for (Map rule : dlpRules) { + if (matchDataType(corpus, str(rule, "data_type"))) { + String action = upperOrDefault(val(rule, "action"), "ALERT"); + hits.add(new MatchHit("CONTENT", "CONTENT_DLP", str(rule, "id"), str(rule, "rule_code"), action, "命中内容DLP规则")); + if ("BLOCK".equalsIgnoreCase(action)) { + return hits; + } + } + } + + List> contentPolicies = jdbcTemplate.queryForList( + "SELECT id, policy_code, action FROM d_content_policy WHERE scope_code = ? AND status = ? AND is_deleted = 0", + request.getScopeCode(), ENABLED + ); + for (Map policy : contentPolicies) { + String policyCode = str(policy, "policy_code"); + if (StringUtils.isNotBlank(policyCode) && corpus.contains(policyCode.toLowerCase(Locale.ROOT))) { + String action = upperOrDefault(val(policy, "action"), "ALERT"); + hits.add(new MatchHit("CONTENT", "CONTENT_POLICY", str(policy, "id"), policyCode, action, "命中内容策略编码关键字")); + if ("BLOCK".equalsIgnoreCase(action)) { + return hits; + } + } + } + + List> maskPolicies = jdbcTemplate.queryForList( + "SELECT id, policy_code, template_name, action FROM d_content_mask_policy WHERE scope_code = ? AND status = ? AND is_deleted = 0", + request.getScopeCode(), ENABLED + ); + for (Map policy : maskPolicies) { + String template = str(policy, "template_name"); + if (StringUtils.isNotBlank(template) && corpus.contains(template.toLowerCase(Locale.ROOT))) { + String action = upperOrDefault(val(policy, "action"), "MASK"); + hits.add(new MatchHit("CONTENT", "CONTENT_MASK", str(policy, "id"), str(policy, "policy_code"), action, "命中脱敏模板关键字")); + if ("BLOCK".equalsIgnoreCase(action)) { + return hits; + } + } + } + + return hits; + } + + private void saveAlertLogs(OpenApiGuardRequest request, String apiCaller, List hits) { + String severity = resolveSeverity(request.getScopeCode()); + Timestamp now = new Timestamp(System.currentTimeMillis()); + Date partitionDate = Date.valueOf(LocalDate.now()); + + for (MatchHit hit : hits) { + String eventNo = "EVT_" + IdUtils.fastSimpleUUID(); + Long eventId = insertAlertEventAndReturnId( + eventNo, + request, + hit, + severity, + now, + partitionDate, + apiCaller + ); + if (eventId != null) { + jdbcTemplate.update( + "INSERT INTO d_log_alert_hit (event_id, hit_order, hit_target, hit_field, hit_operator, expected_value, actual_value_preview, confidence, create_by, create_time, update_by, update_time, is_deleted) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)", + eventId, + 1, + hit.moduleType, + hit.ruleCode, + hit.eventType, + "rule_matched", + hit.message, + 1.0, + "open-api", + now, + "open-api", + now + ); + } + } + } + + private Long insertAlertEventAndReturnId( + String eventNo, + OpenApiGuardRequest request, + MatchHit hit, + String severity, + Timestamp now, + Date partitionDate, + String apiCaller + ) { + String sql = "INSERT INTO d_log_alert_event (event_no, request_id, trace_id, scope_code, module_type, event_type, rule_type, rule_id, rule_code, severity, action_taken, hit_message, request_path, request_method, source_ip, caller_id, is_stream, alert_status, occurred_at, partition_date, create_by, create_time, update_by, update_time, is_deleted) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)"; + + KeyHolder keyHolder = new GeneratedKeyHolder(); + PreparedStatementCreator psc = (Connection conn) -> { + PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); + int i = 1; + ps.setString(i++, eventNo); + ps.setString(i++, request.getRequestId()); + ps.setString(i++, request.getTraceId()); + ps.setString(i++, request.getScopeCode()); + ps.setString(i++, hit.moduleType); + ps.setString(i++, hit.eventType); + ps.setString(i++, hit.eventType); + ps.setString(i++, hit.ruleId); + ps.setString(i++, hit.ruleCode); + ps.setString(i++, severity); + ps.setString(i++, upperOrDefault(hit.action, "ALERT")); + ps.setString(i++, hit.message); + ps.setString(i++, request.getPath()); + ps.setString(i++, request.getMethod()); + ps.setString(i++, request.getSourceIp()); + ps.setString(i++, StringUtils.isNotBlank(request.getCallerId()) ? request.getCallerId() : apiCaller); + ps.setInt(i++, Boolean.TRUE.equals(request.getStream()) ? 1 : 0); + ps.setString(i++, "NEW"); + ps.setTimestamp(i++, now); + ps.setDate(i++, partitionDate); + ps.setString(i++, "open-api"); + ps.setTimestamp(i++, now); + ps.setString(i++, "open-api"); + ps.setTimestamp(i, now); + return ps; + }; + jdbcTemplate.update(psc, keyHolder); + return extractGeneratedId(keyHolder); + } + + private Long extractGeneratedId(KeyHolder keyHolder) { + if (keyHolder == null) { + return null; + } + Map one = keyHolder.getKeys(); + Long oneId = extractIdFromMap(one); + if (oneId != null) { + return oneId; + } + if (keyHolder.getKeyList() != null) { + for (Map m : keyHolder.getKeyList()) { + Long id = extractIdFromMap(m); + if (id != null) { + return id; + } + } + } + return null; + } + + private Long extractIdFromMap(Map map) { + if (map == null || map.isEmpty()) { + return null; + } + Object idObj = map.get("id"); + if (idObj == null) { + for (Map.Entry entry : map.entrySet()) { + if ("id".equalsIgnoreCase(entry.getKey())) { + idObj = entry.getValue(); + break; + } + } + } + if (idObj instanceof Number n) { + return n.longValue(); + } + if (idObj != null) { + try { + return Long.valueOf(String.valueOf(idObj)); + } catch (NumberFormatException ignored) { + return null; + } + } + return null; + } + + private String resolveSeverity(String scopeCode) { + List> list = jdbcTemplate.queryForList( + "SELECT alert_level FROM d_log_attack_policy WHERE scope_code = ? AND status = ? AND is_deleted = 0 ORDER BY update_time DESC LIMIT 1", + scopeCode, ENABLED + ); + if (list.isEmpty()) { + return "MEDIUM"; + } + return upperOrDefault(val(list.get(0), "alert_level"), "MEDIUM"); + } + + private Map loadAttackSwitches(String scopeCode) { + List> switches = jdbcTemplate.queryForList( + "SELECT switch_key, enabled FROM d_attack_switch WHERE scope_code = ? AND is_deleted = 0", + scopeCode + ); + if (switches.isEmpty()) { + return Collections.emptyMap(); + } + Map map = new HashMap<>(); + for (Map sw : switches) { + map.put(str(sw, "switch_key"), toBool(val(sw, "enabled"))); + } + return map; + } + + private Map buildAviatorEnv(OpenApiGuardRequest request, String corpus) { + Map env = new HashMap<>(); + env.put("requestId", request.getRequestId()); + env.put("traceId", request.getTraceId()); + env.put("scopeCode", request.getScopeCode()); + env.put("interfaceType", request.getInterfaceType()); + env.put("path", request.getPath()); + env.put("method", request.getMethod()); + env.put("sourceIp", request.getSourceIp()); + env.put("callerId", request.getCallerId()); + env.put("stream", request.getStream()); + env.put("text", request.getText()); + env.put("extensions", request.getExtensions()); + env.put("corpus", corpus); + env.put("metadata", request.getMetadata()); + return env; + } + + private boolean evalCondition(String expr, Map env) { + try { + Object ret = AviatorEvaluator.execute(expr, env, true); + if (ret instanceof Boolean b) { + return b; + } + return false; + } catch (Exception ignored) { + return false; + } + } + + private boolean matchSignature(String corpus, String sigType, String sigValue) { + if (StringUtils.isBlank(sigValue)) { + return false; + } + try { + if ("REGEX".equalsIgnoreCase(sigType)) { + return Pattern.compile(sigValue, Pattern.CASE_INSENSITIVE | Pattern.DOTALL).matcher(corpus).find(); + } + return corpus.contains(sigValue.toLowerCase(Locale.ROOT)); + } catch (Exception ignored) { + return false; + } + } + + private boolean matchDataType(String corpus, String dataType) { + if (StringUtils.isBlank(dataType)) { + return false; + } + String t = dataType.toUpperCase(Locale.ROOT); + return switch (t) { + case "EMAIL" -> Pattern.compile("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}").matcher(corpus).find(); + case "PHONE" -> Pattern.compile("(?:\\+?86[- ]?)?1[3-9]\\d{9}").matcher(corpus).find(); + case "ID_CARD" -> Pattern.compile("[1-9]\\d{5}(18|19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]").matcher(corpus).find(); + case "BANK_CARD" -> Pattern.compile("\\b(?:\\d[ -]*?){13,19}\\b").matcher(corpus).find(); + default -> corpus.contains(dataType.toLowerCase(Locale.ROOT)); + }; + } + + private boolean matchIp(String sourceIp, String ipType, String ipValue) { + if (StringUtils.isAnyBlank(sourceIp, ipType, ipValue)) { + return false; + } + try { + return switch (ipType.toUpperCase(Locale.ROOT)) { + case "SINGLE" -> Objects.equals(sourceIp, ipValue); + case "REGEX" -> Pattern.compile(ipValue).matcher(sourceIp).find(); + case "CIDR" -> matchCidr(sourceIp, ipValue); + default -> false; + }; + } catch (Exception ignored) { + return false; + } + } + + private boolean matchCidr(String ip, String cidr) { + String[] parts = cidr.split("/"); + if (parts.length != 2) { + return false; + } + long ipNum = ipv4ToLong(ip); + long baseNum = ipv4ToLong(parts[0]); + int prefix = Integer.parseInt(parts[1]); + long mask = prefix == 0 ? 0 : 0xFFFFFFFFL << (32 - prefix); + return (ipNum & mask) == (baseNum & mask); + } + + private long ipv4ToLong(String ip) { + String[] arr = ip.split("\\."); + if (arr.length != 4) { + throw new IllegalArgumentException("invalid ipv4"); + } + long n = 0; + for (String s : arr) { + n = (n << 8) + Integer.parseInt(s); + } + return n; + } + + private boolean matchMethod(String reqMethod, String configuredMethods) { + if (StringUtils.isBlank(configuredMethods)) { + return true; + } + String m = reqMethod == null ? "" : reqMethod.toUpperCase(Locale.ROOT); + String cfg = configuredMethods.toUpperCase(Locale.ROOT); + if ("ALL".equals(cfg)) { + return true; + } + for (String item : cfg.split(",")) { + if (m.equals(item.trim())) { + return true; + } + } + return false; + } + + private boolean matchPath(String path, String matchType, String pattern) { + if (StringUtils.isAnyBlank(path, matchType, pattern)) { + return false; + } + try { + return switch (matchType.toUpperCase(Locale.ROOT)) { + case "EXACT" -> Objects.equals(path, pattern); + case "PREFIX" -> path.startsWith(pattern); + case "REGEX" -> Pattern.compile(pattern).matcher(path).find(); + default -> false; + }; + } catch (Exception ignored) { + return false; + } + } + + private String buildSwitchKey(String category, String subType) { + String c = StringUtils.isBlank(category) ? "ATTACK" : category.toUpperCase(Locale.ROOT); + String s = StringUtils.isBlank(subType) ? "DEFAULT" : subType.toUpperCase(Locale.ROOT); + return c + "_" + s; + } + + private String buildCorpus(OpenApiGuardRequest request) { + StringBuilder sb = new StringBuilder(); + appendIfPresent(sb, request.getText()); + appendIfPresent(sb, request.getPath()); + appendIfPresent(sb, request.getMethod()); + appendIfPresent(sb, request.getCallerId()); + + if (request.getMessages() != null) { + for (Map msg : request.getMessages()) { + appendFromObject(sb, msg); + } + } + if (request.getFiles() != null) { + for (Map file : request.getFiles()) { + appendFromObject(sb, file); + } + } + appendFromObject(sb, request.getMetadata()); + appendFromObject(sb, request.getPayload()); + appendFromObject(sb, request.getExtensions()); + return sb.toString(); + } + + private String firstString(OpenApiGuardRequest request, String... aliases) { + Object v = findInExtensions(request, aliases); + if (v == null) { + return null; + } + String s = String.valueOf(v); + return StringUtils.isBlank(s) ? null : s; + } + + private Boolean firstBoolean(OpenApiGuardRequest request, String... aliases) { + Object v = findInExtensions(request, aliases); + if (v == null) { + return null; + } + return toBool(v); + } + + private Object findInExtensions(OpenApiGuardRequest request, String... aliases) { + if (request == null || request.getExtensions() == null || request.getExtensions().isEmpty() || aliases == null || aliases.length == 0) { + return null; + } + Set names = new HashSet<>(); + for (String alias : aliases) { + if (StringUtils.isNotBlank(alias)) { + names.add(alias.toLowerCase(Locale.ROOT)); + } + } + return findValueByName(request.getExtensions(), names); + } + + private Object findValueByName(Object node, Set aliasNames) { + if (node == null || aliasNames == null || aliasNames.isEmpty()) { + return null; + } + if (node instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + if (aliasNames.contains(key.toLowerCase(Locale.ROOT))) { + return entry.getValue(); + } + } + for (Map.Entry entry : map.entrySet()) { + Object nested = findValueByName(entry.getValue(), aliasNames); + if (nested != null) { + return nested; + } + } + return null; + } + if (node instanceof Iterable iterable) { + for (Object item : iterable) { + Object nested = findValueByName(item, aliasNames); + if (nested != null) { + return nested; + } + } + return null; + } + return null; + } + + private void appendFromObject(StringBuilder sb, Object obj) { + if (obj == null) { + return; + } + if (obj instanceof String s) { + appendIfPresent(sb, s); + return; + } + if (obj instanceof Map map) { + for (Map.Entry entry : map.entrySet()) { + appendIfPresent(sb, String.valueOf(entry.getKey())); + appendFromObject(sb, entry.getValue()); + } + return; + } + if (obj instanceof Iterable iterable) { + for (Object item : iterable) { + appendFromObject(sb, item); + } + return; + } + appendIfPresent(sb, String.valueOf(obj)); + } + + private void appendIfPresent(StringBuilder sb, String value) { + if (StringUtils.isNotBlank(value)) { + sb.append(value).append(' '); + } + } + + private boolean toBool(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean b) { + return b; + } + if (value instanceof Number n) { + return n.intValue() == 1; + } + return "1".equals(String.valueOf(value)) || "true".equalsIgnoreCase(String.valueOf(value)); + } + + private String upperOrDefault(Object value, String defVal) { + String s = objToStr(value); + return StringUtils.isBlank(s) ? defVal : s.toUpperCase(Locale.ROOT); + } + + private String str(Map row, String key) { + return objToStr(val(row, key)); + } + + private Object val(Map row, String key) { + if (row.containsKey(key)) { + return row.get(key); + } + for (Map.Entry entry : row.entrySet()) { + if (key.equalsIgnoreCase(entry.getKey())) { + return entry.getValue(); + } + } + return null; + } + + private static String objToStr(Object value) { + return value == null ? null : String.valueOf(value); + } + + @Data + @AllArgsConstructor + public static class CallerAuthResult { + private boolean success; + private String userName; + private String message; + } + + @Data + @AllArgsConstructor + private static class MatchHit { + private String moduleType; + private String eventType; + private String ruleId; + private String ruleCode; + private String action; + private String message; + } +} diff --git a/llm-guard-modules/llm-guard-open-api/src/main/resources/bootstrap.yml b/llm-guard-modules/llm-guard-open-api/src/main/resources/bootstrap.yml new file mode 100644 index 0000000..2fcd905 --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/src/main/resources/bootstrap.yml @@ -0,0 +1,23 @@ +server: + port: 6200 + +spring: + application: + name: llm-guard-open-api + profiles: + active: dev + cloud: + nacos: + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + server-addr: ${NACOS_HOST:127.0.0.1}:${NACOS_PORT:8848} + namespace: ${NACOS_DISCOVERY_NAMESPACE:dev} + config: + server-addr: ${NACOS_HOST:127.0.0.1}:${NACOS_PORT:8848} + namespace: ${NACOS_DISCOVERY_NAMESPACE:dev} + file-extension: yml + config: + import: + - optional:nacos:application-${spring.profiles.active}.yml + - optional:nacos:${spring.application.name}-${spring.profiles.active}.yml diff --git a/llm-guard-modules/llm-guard-open-api/src/test/java/com/llm/guard/openapi/OpenApiGuardSimulationTest.java b/llm-guard-modules/llm-guard-open-api/src/test/java/com/llm/guard/openapi/OpenApiGuardSimulationTest.java new file mode 100644 index 0000000..0e519e0 --- /dev/null +++ b/llm-guard-modules/llm-guard-open-api/src/test/java/com/llm/guard/openapi/OpenApiGuardSimulationTest.java @@ -0,0 +1,231 @@ +package com.llm.guard.openapi; + +import com.llm.guard.common.core.web.domain.AjaxResult; +import com.llm.guard.openapi.controller.OpenApiGuardController; +import com.llm.guard.openapi.domain.OpenApiGuardCheckRequest; +import com.llm.guard.openapi.domain.OpenApiGuardRequest; +import com.llm.guard.openapi.domain.OpenApiGuardResponse; +import com.llm.guard.openapi.service.OpenApiGuardService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import javax.sql.DataSource; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OpenApiGuardSimulationTest { + + private JdbcTemplate jdbcTemplate; + private OpenApiGuardController controller; + + @BeforeEach + void setUp() { + DataSource dataSource = new DriverManagerDataSource("jdbc:h2:mem:guard;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1", "sa", ""); + jdbcTemplate = new JdbcTemplate(dataSource); + OpenApiGuardService service = new OpenApiGuardService(jdbcTemplate); + controller = new OpenApiGuardController(service); + + initSchema(); + seedAuthUser(); + seedRules(); + } + + @Test + void should_simulate_three_gateway_requests_and_verify_alerts() { + OpenApiGuardRequest agentReq = new OpenApiGuardRequest(); + agentReq.setRequestId("req-agent-1"); + agentReq.setTraceId("trace-agent-1"); + agentReq.setScopeCode("GLOBAL"); + agentReq.setInterfaceType("AGENT"); + agentReq.setCallerId("partnerA"); + agentReq.setPath("/api/session/run"); + agentReq.setMethod("POST"); + agentReq.setSourceIp("10.0.0.1"); + agentReq.setText("你好"); + AjaxResult agentResult = controller.check("ak_test", "sk_test", wrap(agentReq)); + + assertEquals(200, agentResult.get("code")); + OpenApiGuardResponse agentResp = (OpenApiGuardResponse) agentResult.get("data"); + assertEquals("BLOCK", agentResp.getDecision()); + assertTrue(agentResp.getAlerted()); + assertEquals("ACL", agentResp.getHits().get(0).getModuleType()); + assertEquals(1, countAlertByRequestId("req-agent-1")); + + OpenApiGuardRequest vlmReq = new OpenApiGuardRequest(); + vlmReq.setRequestId("req-vlm-1"); + vlmReq.setTraceId("trace-vlm-1"); + vlmReq.setScopeCode("GLOBAL"); + vlmReq.setInterfaceType("VLM"); + vlmReq.setCallerId("partnerA"); + vlmReq.setPath("/api/vlm/chat/completion/V2"); + vlmReq.setMethod("POST"); + vlmReq.setSourceIp("10.0.0.1"); + vlmReq.setMessages(List.of(Map.of("role", "user", "content", "please union select password"))); + AjaxResult vlmResult = controller.check("ak_test", "sk_test", wrap(vlmReq)); + + assertEquals(200, vlmResult.get("code")); + OpenApiGuardResponse vlmResp = (OpenApiGuardResponse) vlmResult.get("data"); + assertEquals("BLOCK", vlmResp.getDecision()); + assertTrue(vlmResp.getAlerted()); + assertEquals("ATTACK", vlmResp.getHits().get(0).getModuleType()); + assertEquals(1, countAlertByRequestId("req-vlm-1")); + + OpenApiGuardRequest llmReq = new OpenApiGuardRequest(); + llmReq.setRequestId("req-llm-1"); + llmReq.setTraceId("trace-llm-1"); + llmReq.setScopeCode("GLOBAL"); + llmReq.setInterfaceType("LLM"); + llmReq.setCallerId("partnerA"); + llmReq.setPath("/api/llm/chat/completion/V2"); + llmReq.setMethod("POST"); + llmReq.setSourceIp("10.0.0.1"); + llmReq.setMessages(List.of(Map.of("role", "user", "content", "我的邮箱是 test_user@demo.com"))); + AjaxResult llmResult = controller.check("ak_test", "sk_test", wrap(llmReq)); + + assertEquals(200, llmResult.get("code")); + OpenApiGuardResponse llmResp = (OpenApiGuardResponse) llmResult.get("data"); + assertEquals("ALLOW", llmResp.getDecision()); + assertTrue(llmResp.getAlerted()); + assertEquals("CONTENT", llmResp.getHits().get(0).getModuleType()); + assertEquals(1, countAlertByRequestId("req-llm-1")); + + Integer totalEvent = jdbcTemplate.queryForObject("select count(1) from d_log_alert_event", Integer.class); + Integer totalHit = jdbcTemplate.queryForObject("select count(1) from d_log_alert_hit", Integer.class); + assertEquals(3, totalEvent); + assertEquals(3, totalHit); + + OpenApiGuardRequest cleanReq = new OpenApiGuardRequest(); + cleanReq.setRequestId("req-clean-1"); + cleanReq.setTraceId("trace-clean-1"); + cleanReq.setScopeCode("GLOBAL"); + cleanReq.setInterfaceType("LLM"); + cleanReq.setCallerId("partnerA"); + cleanReq.setPath("/api/normal"); + cleanReq.setMethod("POST"); + cleanReq.setSourceIp("10.0.0.1"); + cleanReq.setText("hello world"); + AjaxResult cleanResult = controller.check("ak_test", "sk_test", wrap(cleanReq)); + OpenApiGuardResponse cleanResp = (OpenApiGuardResponse) cleanResult.get("data"); + assertFalse(cleanResp.getAlerted()); + assertEquals("ALLOW", cleanResp.getDecision()); + } + + @Test + void should_accept_raw_gateway_payload_without_fixed_fields() { + OpenApiGuardRequest rawReq = new OpenApiGuardRequest(); + rawReq.setInterfaceType("LLM"); + rawReq.setCallerId("partnerA"); + rawReq.setPath("/api/llm/chat/completion/V2"); + rawReq.setMethod("POST"); + rawReq.setSourceIp("10.0.0.1"); + rawReq.getExtensions().put("requestId", "req-raw-1"); + rawReq.getExtensions().put("traceId", "trace-raw-1"); + rawReq.getExtensions().put("scopeCode", "GLOBAL"); + rawReq.getExtensions().put("messages", List.of(Map.of("role", "user", "content", "please union select test_user@demo.com"))); + AjaxResult rawResult = controller.check("ak_test", "sk_test", wrap(rawReq)); + + assertEquals(200, rawResult.get("code")); + OpenApiGuardResponse rawResp = (OpenApiGuardResponse) rawResult.get("data"); + assertTrue(rawResp.getAlerted()); + assertEquals("BLOCK", rawResp.getDecision()); + assertTrue(rawResp.getHits().stream().noneMatch(h -> "ACL_IP_WHITELIST".equals(h.getEventType()))); + } + + private Integer countAlertByRequestId(String requestId) { + return jdbcTemplate.queryForObject("select count(1) from d_log_alert_event where request_id = ?", Integer.class, requestId); + } + + private OpenApiGuardCheckRequest wrap(OpenApiGuardRequest reqData) { + OpenApiGuardCheckRequest req = new OpenApiGuardCheckRequest(); + req.setSourceIp(reqData.getSourceIp()); + req.setPath(reqData.getPath()); + req.setMethod(reqData.getMethod()); + req.setInterfaceType(reqData.getInterfaceType()); + req.setCallerId(reqData.getCallerId()); + req.setReqData(toMap(reqData)); + return req; + } + + private Map toMap(OpenApiGuardRequest reqData) { + Map m = new LinkedHashMap<>(); + putIfNotNull(m, "requestId", reqData.getRequestId()); + putIfNotNull(m, "traceId", reqData.getTraceId()); + putIfNotNull(m, "scopeCode", reqData.getScopeCode()); + putIfNotNull(m, "interfaceType", reqData.getInterfaceType()); + putIfNotNull(m, "path", reqData.getPath()); + putIfNotNull(m, "method", reqData.getMethod()); + putIfNotNull(m, "sourceIp", reqData.getSourceIp()); + putIfNotNull(m, "callerId", reqData.getCallerId()); + putIfNotNull(m, "stream", reqData.getStream()); + putIfNotNull(m, "text", reqData.getText()); + putIfNotNull(m, "messages", reqData.getMessages()); + putIfNotNull(m, "files", reqData.getFiles()); + putIfNotNull(m, "metadata", reqData.getMetadata()); + putIfNotNull(m, "payload", reqData.getPayload()); + if (reqData.getExtensions() != null && !reqData.getExtensions().isEmpty()) { + m.putAll(reqData.getExtensions()); + } + return m; + } + + private void putIfNotNull(Map map, String key, Object value) { + if (value != null) { + map.put(key, value); + } + } + + private void initSchema() { + jdbcTemplate.execute("drop all objects"); + + jdbcTemplate.execute("create table d_sys_user (user_id varchar(64), user_name varchar(64), status char(1), del_flag char(1), api_key varchar(128), api_secret varchar(256))"); + + jdbcTemplate.execute("create table d_acl_ip_rule (id varchar(64), scope_code varchar(64), list_type varchar(16), ip_type varchar(16), ip_value varchar(255), priority int, status varchar(20), is_deleted int)"); + jdbcTemplate.execute("create table d_acl_endpoint_rule (id varchar(64), scope_code varchar(64), match_type varchar(16), uri_pattern varchar(512), methods varchar(64), status varchar(20), is_deleted int)"); + jdbcTemplate.execute("create table d_acl_custom_rule (id varchar(64), scope_code varchar(64), rule_code varchar(100), logic_op varchar(8), action varchar(20), priority int, status varchar(20), is_deleted int)"); + jdbcTemplate.execute("create table d_acl_custom_condition (id bigint generated by default as identity primary key, rule_id varchar(64), seq_no int, condition_expr varchar(1024), is_deleted int)"); + + jdbcTemplate.execute("create table d_attack_switch (id bigint generated by default as identity primary key, scope_code varchar(64), switch_key varchar(64), enabled smallint, is_deleted int)"); + jdbcTemplate.execute("create table d_attack_rule (id varchar(64), scope_code varchar(64), rule_code varchar(100), category varchar(20), sub_type varchar(64), action varchar(20), status varchar(20), is_deleted int)"); + jdbcTemplate.execute("create table d_attack_signature (id varchar(64), scope_code varchar(64), sig_type varchar(20), sig_value varchar(1024), rule_code varchar(100), status varchar(20), is_deleted int)"); + jdbcTemplate.execute("create table d_log_attack_policy (id varchar(64), scope_code varchar(64), alert_level varchar(20), status varchar(20), update_time timestamp, is_deleted int)"); + + jdbcTemplate.execute("create table d_content_policy (id varchar(64), scope_code varchar(64), policy_code varchar(100), action varchar(20), status varchar(20), is_deleted int)"); + jdbcTemplate.execute("create table d_content_dlp_rule (id varchar(64), scope_code varchar(64), rule_code varchar(100), data_type varchar(64), action varchar(20), status varchar(20), is_deleted int)"); + jdbcTemplate.execute("create table d_content_mask_policy (id varchar(64), scope_code varchar(64), policy_code varchar(100), template_name varchar(128), action varchar(20), status varchar(20), is_deleted int)"); + + jdbcTemplate.execute("create table d_log_alert_event (id bigint generated by default as identity primary key, event_no varchar(64), request_id varchar(64), trace_id varchar(64), scope_code varchar(64), module_type varchar(20), event_type varchar(32), rule_type varchar(32), rule_id varchar(64), rule_code varchar(100), severity varchar(20), action_taken varchar(20), hit_message varchar(512), request_path varchar(1024), request_method varchar(16), source_ip varchar(45), caller_id varchar(128), is_stream smallint, alert_status varchar(20), occurred_at timestamp, partition_date date, create_by varchar(64), create_time timestamp, update_by varchar(64), update_time timestamp, is_deleted int)"); + jdbcTemplate.execute("create table d_log_alert_hit (id bigint generated by default as identity primary key, event_id bigint, hit_order int, hit_target varchar(32), hit_field varchar(255), hit_operator varchar(32), expected_value varchar(512), actual_value_preview varchar(512), confidence decimal(5,2), create_by varchar(64), create_time timestamp, update_by varchar(64), update_time timestamp, is_deleted int)"); + } + + private void seedAuthUser() { + jdbcTemplate.update("insert into d_sys_user(user_id, user_name, status, del_flag, api_key, api_secret) values (?,?,?,?,?,?)", + "u1", "partnerA", "0", "0", "ak_test", "sk_test"); + } + + private void seedRules() { + jdbcTemplate.update("insert into d_acl_ip_rule(id, scope_code, list_type, ip_type, ip_value, priority, status, is_deleted) values (?,?,?,?,?,?,?,?)", + "ipw1", "GLOBAL", "WHITELIST", "SINGLE", "10.0.0.1", 1, "ENABLED", 0); + jdbcTemplate.update("insert into d_acl_endpoint_rule(id, scope_code, match_type, uri_pattern, methods, status, is_deleted) values (?,?,?,?,?,?,?)", + "ep1", "GLOBAL", "EXACT", "/api/session/run", "POST", "ENABLED", 0); + + jdbcTemplate.update("insert into d_attack_switch(scope_code, switch_key, enabled, is_deleted) values (?,?,?,?)", + "GLOBAL", "INJECTION_SQL", 1, 0); + jdbcTemplate.update("insert into d_attack_rule(id, scope_code, rule_code, category, sub_type, action, status, is_deleted) values (?,?,?,?,?,?,?,?)", + "ar1", "GLOBAL", "ATTACK_SQL_1", "INJECTION", "SQL", "BLOCK", "ENABLED", 0); + jdbcTemplate.update("insert into d_attack_signature(id, scope_code, sig_type, sig_value, rule_code, status, is_deleted) values (?,?,?,?,?,?,?)", + "sig1", "GLOBAL", "REGEX", "union\\s+select", "ATTACK_SQL_1", "ENABLED", 0); + + jdbcTemplate.update("insert into d_content_dlp_rule(id, scope_code, rule_code, data_type, action, status, is_deleted) values (?,?,?,?,?,?,?)", + "dlp1", "GLOBAL", "DLP_EMAIL_1", "EMAIL", "ALERT", "ENABLED", 0); + + jdbcTemplate.update("insert into d_log_attack_policy(id, scope_code, alert_level, status, update_time, is_deleted) values (?,?,?,?,CURRENT_TIMESTAMP,?)", + "lp1", "GLOBAL", "HIGH", "ENABLED", 0); + } +} diff --git a/llm-guard-modules/llm-guard-system/.flattened-pom.xml b/llm-guard-modules/llm-guard-system/.flattened-pom.xml index 22e2961..35f4f03 100644 --- a/llm-guard-modules/llm-guard-system/.flattened-pom.xml +++ b/llm-guard-modules/llm-guard-system/.flattened-pom.xml @@ -60,13 +60,32 @@ ${project.artifactId} - org.springframework.boot - spring-boot-maven-plugin + maven-jar-plugin + 3.3.0 + + + + com.llm.guard.system.LlmSystemApplication + true + lib/ + + + + + + maven-dependency-plugin + 3.6.1 + copy-dependencies + package - repackage + copy-dependencies + + ${project.build.directory}/lib + false + diff --git a/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/config/UserApiCredentialInitRunner.java b/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/config/UserApiCredentialInitRunner.java new file mode 100644 index 0000000..4eb002c --- /dev/null +++ b/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/config/UserApiCredentialInitRunner.java @@ -0,0 +1,28 @@ +package com.llm.guard.system.config; + +import com.llm.guard.system.service.ISysUserService; +import lombok.AllArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +@AllArgsConstructor +public class UserApiCredentialInitRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(UserApiCredentialInitRunner.class); + + private final ISysUserService userService; + + @Override + public void run(ApplicationArguments args) { + int count = userService.initUserApiCredentials(); + if (count > 0) { + log.info("初始化用户开放接口密钥完成,补齐数量: {}", count); + } else { + log.info("用户开放接口密钥检查完成,无需补齐"); + } + } +} diff --git a/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/mapper/SysUserMapper.java b/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/mapper/SysUserMapper.java index 8914158..4be38a4 100644 --- a/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/mapper/SysUserMapper.java +++ b/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/mapper/SysUserMapper.java @@ -142,4 +142,21 @@ public interface SysUserMapper extends BaseMapper * @return 结果 */ public SysUser checkEmailUnique(String email); + + /** + * 查询 apiKey/apiSecret 缺失用户 + * + * @return 用户列表 + */ + public List selectUsersWithoutApiCredentials(); + + /** + * 更新用户开放接口密钥 + * + * @param userId 用户ID + * @param apiKey apiKey + * @param apiSecret apiSecret + * @return 结果 + */ + public int updateApiCredentials(@Param("userId") String userId, @Param("apiKey") String apiKey, @Param("apiSecret") String apiSecret); } diff --git a/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/service/ISysUserService.java b/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/service/ISysUserService.java index 1d471d6..586c301 100644 --- a/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/service/ISysUserService.java +++ b/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/service/ISysUserService.java @@ -212,4 +212,11 @@ public interface ISysUserService extends IService * @return 结果 */ public String importUser(List userList, Boolean isUpdateSupport, String operName); + + /** + * 初始化缺失的开放接口密钥 + * + * @return 初始化数量 + */ + public int initUserApiCredentials(); } diff --git a/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/service/impl/SysUserServiceImpl.java b/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/service/impl/SysUserServiceImpl.java index 7e1f499..181d46c 100644 --- a/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/service/impl/SysUserServiceImpl.java +++ b/llm-guard-modules/llm-guard-system/src/main/java/com/llm/guard/system/service/impl/SysUserServiceImpl.java @@ -263,6 +263,7 @@ public class SysUserServiceImpl extends ServiceImpl impl public int insertUser(SysUser user) { fillUserIdIfEmpty(user); + ensureApiCredentialsIfBlank(user); // 新增用户信息 int rows = userMapper.insertUser(user); // 新增用户岗位关联 @@ -282,6 +283,7 @@ public class SysUserServiceImpl extends ServiceImpl impl public boolean registerUser(SysUser user) { fillUserIdIfEmpty(user); + ensureApiCredentialsIfBlank(user); return userMapper.insertUser(user) > 0; } @@ -296,6 +298,7 @@ public class SysUserServiceImpl extends ServiceImpl impl public int updateUser(SysUser user) { String userId = user.getUserId(); + handleApiCredentialsForUpdate(user); // 删除用户与角色关联 userRoleMapper.deleteUserRoleByUserId(userId); // 新增用户与角色管理 @@ -522,6 +525,7 @@ public class SysUserServiceImpl extends ServiceImpl impl user.setPassword(SecurityUtils.encryptPassword(password)); user.setCreateBy(operName); fillUserIdIfEmpty(user); + ensureApiCredentialsIfBlank(user); userMapper.insertUser(user); successNum++; successMsg.append("
" + successNum + "、账号 " + user.getUserName() + " 导入成功"); @@ -565,6 +569,24 @@ public class SysUserServiceImpl extends ServiceImpl impl return successMsg.toString(); } + @Override + @Transactional(rollbackFor = Exception.class) + public int initUserApiCredentials() + { + List users = userMapper.selectUsersWithoutApiCredentials(); + if (CollectionUtils.isEmpty(users)) + { + return 0; + } + int count = 0; + for (SysUser user : users) + { + regenerateApiCredentials(user); + count += userMapper.updateApiCredentials(user.getUserId(), user.getApiKey(), user.getApiSecret()); + } + return count; + } + /** * 为空时生成用户主键,避免数据库默认值无法回填 */ @@ -575,4 +597,44 @@ public class SysUserServiceImpl extends ServiceImpl impl user.setUserId(IdUtils.randomUUID()); } } + + private void ensureApiCredentialsIfBlank(SysUser user) + { + if (StringUtils.isAnyBlank(user.getApiKey(), user.getApiSecret())) + { + regenerateApiCredentials(user); + } + } + + private void handleApiCredentialsForUpdate(SysUser user) + { + boolean apiKeyProvided = user.getApiKey() != null; + boolean apiSecretProvided = user.getApiSecret() != null; + + // 前端显式传空,表示请求轮换密钥 + if (apiKeyProvided || apiSecretProvided) + { + if (StringUtils.isAnyBlank(user.getApiKey(), user.getApiSecret())) + { + regenerateApiCredentials(user); + } + return; + } + + // 未传密钥字段时,保持原值不变;仅当数据库原值为空时补生成 + SysUser dbUser = userMapper.selectUserById(user.getUserId()); + if (dbUser == null || StringUtils.isAnyBlank(dbUser.getApiKey(), dbUser.getApiSecret())) + { + regenerateApiCredentials(user); + return; + } + user.setApiKey(dbUser.getApiKey()); + user.setApiSecret(dbUser.getApiSecret()); + } + + private void regenerateApiCredentials(SysUser user) + { + user.setApiKey("ak_" + IdUtils.fastSimpleUUID()); + user.setApiSecret("sk_" + IdUtils.fastSimpleUUID() + IdUtils.fastSimpleUUID()); + } } diff --git a/llm-guard-modules/llm-guard-system/src/main/resources/mapper/system/SysUserMapper.xml b/llm-guard-modules/llm-guard-system/src/main/resources/mapper/system/SysUserMapper.xml index 0b5f8e7..093b5b3 100644 --- a/llm-guard-modules/llm-guard-system/src/main/resources/mapper/system/SysUserMapper.xml +++ b/llm-guard-modules/llm-guard-system/src/main/resources/mapper/system/SysUserMapper.xml @@ -14,6 +14,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + @@ -48,7 +50,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.remark, + select u.user_id, u.dept_id, u.user_name, u.nick_name, u.email, u.avatar, u.phonenumber, u.password, u.api_key, u.api_secret, u.sex, u.status, u.del_flag, u.login_ip, u.login_date, u.pwd_update_date, u.create_by, u.create_time, u.remark, d.dept_id, d.parent_id, d.ancestors, d.dept_name, d.order_num, d.leader, d.status as dept_status, r.role_id, r.role_name, r.role_key, r.role_sort, r.data_scope, r.status as role_status from d_sys_user u @@ -58,7 +60,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" select user_id, email from d_sys_user where email = #{email} and del_flag = '0' limit 1 + + insert into d_sys_user( @@ -154,6 +163,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" phonenumber, sex, password, + api_key, + api_secret, status, pwd_update_date, create_by, @@ -169,6 +180,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{phonenumber}, #{sex}, #{password}, + #{apiKey}, + #{apiSecret}, #{status}, #{pwdUpdateDate}, #{createBy}, @@ -187,6 +200,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" sex = #{sex}, avatar = #{avatar}, password = #{password}, + api_key = #{apiKey}, + api_secret = #{apiSecret}, status = #{status}, login_ip = #{loginIp}, login_date = #{loginDate}, @@ -212,6 +227,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update d_sys_user set pwd_update_date = now(), password = #{password}, update_time = now() where user_id = #{userId} + + + update d_sys_user + set api_key = #{apiKey}, + api_secret = #{apiSecret}, + update_time = now() + where user_id = #{userId} + update d_sys_user set del_flag = '2' where user_id = #{userId} @@ -224,4 +247,4 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - \ No newline at end of file + diff --git a/llm-guard-modules/pom.xml b/llm-guard-modules/pom.xml index 1948ce8..1aef7ea 100644 --- a/llm-guard-modules/pom.xml +++ b/llm-guard-modules/pom.xml @@ -11,6 +11,7 @@ llm-guard-system llm-guard-biz + llm-guard-open-api llm-guard-modules