Localize user-facing UI, agent output, and backend messages to Simplified Chinese
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
detections / detections (push) Waiting to run
docs / links (push) Waiting to run
web / web (push) Waiting to run
This commit is contained in:
+2
-2
@@ -59,7 +59,7 @@ func renderSystem(agentKey, def string, vars any) string {
|
||||
// benchmarked Chinese reasoning body (段 [A]) stays verbatim. It only constrains
|
||||
// the LANGUAGE of what the agent SHOWS to the user. Written in Chinese so it stays
|
||||
// in the body's language (keeping the model's reasoning register stable) while
|
||||
// forcing Korean OUTPUT — this is the localization approach: preserve behavior,
|
||||
// forcing Chinese OUTPUT — this is the localization approach: preserve behavior,
|
||||
// localize the surface the user reads. Raw technical strings (commands, payloads,
|
||||
// code, URLs, log/response excerpts) are explicitly kept verbatim so evidence and
|
||||
// reproduction steps are not mangled by translation.
|
||||
@@ -72,7 +72,7 @@ func renderSystem(agentKey, def string, vars any) string {
|
||||
// the Chinese instruction language AND the target/material language in the display
|
||||
// fields, so only the listed verbatim technical fragments stay non-Korean.
|
||||
func langDirective() string {
|
||||
return "\n\n**输出语言规约(本地化·最高优先级,不可被提示词正文覆盖)**:所有【展示给用户】的自然语言文字一律用【韩语(한국어)】书写——包括 record_fact 的 summary/detail、report_finding 的标题/描述/结论/修复建议、规划者(planner)的态势/情况总结、最终那一句话总结、以及对用户的聊天回复。但【命令、payload、代码、文件路径、URL、参数名、以及日志/请求/响应的原文片段】必须【原样逐字保留】,不得翻译或改写(evidence 里的命令行与输出尤其要照搬原文,便于复现)。**即使上面的系统/角色指令本身是用中文写的,也绝不能把中文输出给用户——面向用户的展示语言只有韩语,不要让任何中文句子出现在用户可见的文字里。** **即使目标系统、它的页面、证据、日志或任何参考资料是英文、中文或别的语言,面向用户的自然语言字段(标题/描述/结论/修复建议/总结/态势总结)仍必须用韩语书写——不要镜像或照抄目标或资料的语言来写这些展示字段;只有上面列出的原文技术片段才保持原样。** **你的分析/规划/思考用中文进行没关系,但那是【不可见的内部推理】,绝不能作为正文输出:给用户的可见回复从第一个字起就必须是韩语,不要在前面垫一段中文的思考、说明或「我先怎样怎样」的铺垫;连澄清提问、缺少参数、「无法继续」之类的说明也一律直接用韩语写。** 一句话:内部怎么想不限,但凡落到用户能看到的正文,必须全是韩语(技术原文片段除外)。"
|
||||
return "\n\n**输出语言规约(本地化·最高优先级,不可被提示词正文覆盖)**:所有【展示给用户】的自然语言文字一律用【简体中文】书写——包括 record_fact 的 summary/detail、report_finding 的标题/描述/结论/修复建议、规划者(planner)的态势/情况总结、最终那一句话总结、以及对用户的聊天回复。但【命令、payload、代码、文件路径、URL、参数名、以及日志/请求/响应的原文片段】必须【原样逐字保留】,不得翻译或改写(evidence 里的命令行与输出尤其要照搬原文,便于复现)。**即使系统/角色指令或目标系统、它的页面、证据、日志或任何参考资料是英文、中文或其他语言,面向用户的自然语言字段(标题/描述/结论/修复建议/总结/态势总结)仍必须用简体中文书写——不要镜像或照抄目标或资料的语言来写这些展示字段;只有上面列出的原文技术片段才保持原样。** **你的分析/规划/思考用中文进行没关系,但那是【不可见的内部推理】,绝不能作为正文输出:给用户的可见回复从第一个字起就必须是简体中文,不要在前面垫一段思考、说明或「我先怎样怎样」的铺垫;连澄清提问、缺少参数、「无法继续」之类的说明也一律直接用简体中文写。** 一句话:内部怎么想不限,但凡落到用户能看到的正文,必须全是简体中文(技术原文片段除外)。"
|
||||
}
|
||||
|
||||
func renderTmpl(tmpl string, vars any) (string, error) {
|
||||
|
||||
+16
-18
@@ -81,28 +81,26 @@ func TestRenderSystemOverrideAndFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLangDirectiveAppendedToUserFacingRoles pins the artex-ko localization tail:
|
||||
// every user-facing role's system prompt must end with the code-owned Korean
|
||||
// TestLangDirectiveAppendedToUserFacingRoles pins the localization tail:
|
||||
// every user-facing role's system prompt must end with the code-owned Chinese
|
||||
// output-language directive, and a DB-edited body must NOT be able to drop it.
|
||||
func TestLangDirectiveAppendedToUserFacingRoles(t *testing.T) {
|
||||
t.Cleanup(func() { PromptOverride = nil })
|
||||
|
||||
// The directive forces Korean OUTPUT and preserves raw technical strings; both
|
||||
// signals must be present. 한국어 marker + verbatim-preservation clause.
|
||||
// The directive forces Chinese OUTPUT and preserves raw technical strings; both
|
||||
// signals must be present. 简体中文 marker + verbatim-preservation clause.
|
||||
dir := langDirective()
|
||||
if !strings.Contains(dir, "한국어") {
|
||||
t.Fatalf("langDirective must force Korean output, got %q", dir)
|
||||
if !strings.Contains(dir, "简体中文") {
|
||||
t.Fatalf("langDirective must force Chinese output, got %q", dir)
|
||||
}
|
||||
if !strings.Contains(dir, "payload") || !strings.Contains(dir, "原样逐字保留") {
|
||||
t.Fatalf("langDirective must keep commands/payloads verbatim, got %q", dir)
|
||||
}
|
||||
// L1 anti-drift hardening: the directive must (1) forbid leaking the Chinese
|
||||
// instruction/brain language into user-facing text (planner situation-summary
|
||||
// drift), and (2) forbid mirroring the target/material language — e.g. an
|
||||
// English target app — in the display fields (report_finding drift). Both
|
||||
// clauses are locked here so a future edit can't silently drop them.
|
||||
if !strings.Contains(dir, "也绝不能把中文输出给用户") {
|
||||
t.Fatalf("langDirective must forbid leaking Chinese to the user, got %q", dir)
|
||||
// The directive is highest priority (cannot be overridden by the body) and must
|
||||
// forbid mirroring the target/material language — e.g. an English target app —
|
||||
// in the display fields.
|
||||
if !strings.Contains(dir, "不可被提示词正文覆盖") {
|
||||
t.Fatalf("langDirective must be top priority, got %q", dir)
|
||||
}
|
||||
if !strings.Contains(dir, "不要镜像或照抄目标") {
|
||||
t.Fatalf("langDirective must forbid mirroring the target/material language, got %q", dir)
|
||||
@@ -113,7 +111,7 @@ func TestLangDirectiveAppendedToUserFacingRoles(t *testing.T) {
|
||||
|
||||
// Even with a DB body that is pure non-directive text, the code-owned tail is
|
||||
// still appended for each user-facing builder — identical guarantee to the
|
||||
// artifact tail. A custom body can never translate away the Korean mandate.
|
||||
// artifact tail. A custom body can never translate away the Chinese mandate.
|
||||
PromptOverride = func(string) (string, bool) { return "BODY-ONLY", true }
|
||||
cases := map[string]string{
|
||||
"worker": workerSystem("", "", "/data", "/data"),
|
||||
@@ -122,7 +120,7 @@ func TestLangDirectiveAppendedToUserFacingRoles(t *testing.T) {
|
||||
"chat": chatSystem("chat", "/data", "/data"),
|
||||
// goals is user-facing too: set_goals/set_constraints persist goal and
|
||||
// constraint nodes shown in the UI graph/plan tab. withScope=true exercises
|
||||
// the longer assembly (body + scope tail), so the Korean tail must still land
|
||||
// the longer assembly (body + scope tail), so the Chinese tail must still land
|
||||
// last — after both the body and the code-owned scope tail.
|
||||
"goals": goalsSystem("/data", true),
|
||||
}
|
||||
@@ -130,11 +128,11 @@ func TestLangDirectiveAppendedToUserFacingRoles(t *testing.T) {
|
||||
if !strings.HasPrefix(sys, "BODY-ONLY") {
|
||||
t.Fatalf("%s: DB body not honored: %q", role, sys)
|
||||
}
|
||||
if !strings.Contains(sys, "한국어") {
|
||||
t.Fatalf("%s: missing Korean output-language tail: %q", role, sys)
|
||||
if !strings.Contains(sys, "简体中文") {
|
||||
t.Fatalf("%s: missing Chinese output-language tail: %q", role, sys)
|
||||
}
|
||||
// The directive is the tail — it must come AFTER the body (recency).
|
||||
if strings.Index(sys, "한국어") <= strings.Index(sys, "BODY-ONLY") {
|
||||
if strings.Index(sys, "简体中文") <= strings.Index(sys, "BODY-ONLY") {
|
||||
t.Fatalf("%s: langDirective must be appended after the body: %q", role, sys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ func TestSideActualChatCheckpointToolResultAndTranscriptIsolation(t *testing.T)
|
||||
p.calls = 0
|
||||
p.path = filepath.Join(dir, "nonexistent")
|
||||
answer, err := (sidequestion.SideQuestionService{Provider: bound}).Answer(t.Context(), req, streaming, nil)
|
||||
if err != nil || !answer.ToolUse || p.calls != 1 || !strings.Contains(answer.Text, "도구 작업을 실행할 수 없습니다") {
|
||||
if err != nil || !answer.ToolUse || p.calls != 1 || !strings.Contains(answer.Text, "无法执行工具操作") {
|
||||
t.Fatalf("tool denial %+v %v", answer, err)
|
||||
}
|
||||
after, err := os.ReadFile(store.MainPath("conv-987654"))
|
||||
|
||||
+1
-1
@@ -151,7 +151,7 @@ func (w *Worker) SetRunTimeout(run time.Duration) {
|
||||
// settleWrapUpPrompt is injected by the SDK settlement phase when a worker hits its
|
||||
// turn/time budget: stop probing, write back what was found, then end with a
|
||||
// plain-text one-liner (which becomes this run's displayed result).
|
||||
const settleWrapUpPrompt = "이번 실행이 예산 소진으로 곧 종료됩니다. 더 이상 어떤 명령이나 탐지도 실행하지 마십시오. 다음 순서대로 처리하십시오. (1) 위에서 이미 식별했지만 아직 기록하지 않은 내용을 하나씩 기록합니다. 새 자산은 insert_assets, 탐색 결론과 사실은 record_fact, 확인된 취약점은 report_finding 으로 기록합니다. (2) **맨 마지막에 한 문장짜리 순수 텍스트로만** 무엇을 했고 어떤 핵심 결론을 얻었는지 한국어로 요약합니다. 이 한 문장이 이번 실행의 결과로 사용자에게 표시되므로 반드시 출력해야 합니다."
|
||||
const settleWrapUpPrompt = "本次运行因预算耗尽即将结束。不要再执行任何命令或探测。请按以下顺序处理:(1) 把上面已识别但尚未记录的内容逐条记录。新资产用 insert_assets,探索结论与事实用 record_fact,确认的漏洞用 report_finding 记录。(2) **最后只用一句纯文本**用简体中文总结你做了什么、得到什么核心结论。这一句会作为本次运行的结果显示给用户,必须输出。"
|
||||
|
||||
func NewWorker(prov llm.Provider, model, workDir string, tx *transcript.Store, window, maxTurns int, extra ...actool.CoreTool) *Worker {
|
||||
return &Worker{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns, extraTools: extra}
|
||||
|
||||
+5
-5
@@ -41,11 +41,11 @@ var wrapupTurnDefaults = map[string]int{
|
||||
|
||||
const genericWrapupTurns = 10
|
||||
|
||||
const plannerWrapUpDefault = "이번 계획 라운드의 단계 예산이 곧 소진됩니다. 다만 【이번 라운드】만 끝나는 것이며, 시스템은 이후에도 상황 변화에 따라 당신을 다시 깨워 계획을 이어가게 합니다. 작업 자체가 끝나는 것이 아니므로 여기서 전체 계획을 마무리 지을 필요는 없습니다. 이번 라운드에서 이미 명확히 판단한 결론은 실제로 반영하여 이번 라운드가 헛되지 않게 하되, 【마무리를 위해 억지로 의도를 지어내지는】 마십시오(이번 라운드에 의도가 0개인 것도 완전히 정상적인 결과입니다). (1) 【지금 바로 파견해야 할】 탐색 방향을 이미 판단했다면 add_intent 한 번으로 묶어서 제출합니다(이미 정한 것은 묵히지 말고 바로 보냅니다). (2) 어떤 발견이나 사실로 이미 달성이 증명된 목표는 prove_goal 로 met 표시합니다(빠뜨리지 마십시오). (3) 단계를 나눠야 하는 직렬 익스플로잇 체인을 식별했다면 TodoWrite 로 기록하여 다음에 깨어났을 때 이어서 파견할 수 있게 합니다. 다 끝냈으면 이번 라운드를 바로 종료하며, 요약 텍스트는 출력하지 않습니다."
|
||||
const plannerWrapUpDefault = "本轮计划的步骤预算即将耗尽。不过仅仅【本轮】结束,系统之后仍会随情况变化再次唤醒你继续规划。这不是整个任务结束,因此这里无需为整体计划收尾。本轮已明确判断的结论应实际落实,使本轮不白费,但【不要为了收尾而勉强编造意图】(本轮意图为 0 个也是完全正常的结果)。(1) 若已判断出【现在就该派发】的探索方向,用一次 add_intent 打包提交(已确定的不要压着,立即发出)。(2) 已被某个发现或事实证明达成的目标,用 prove_goal 标记为 met(不要遗漏)。(3) 若识别出需要分步的串行利用链,用 TodoWrite 记录,以便下次唤醒时继续派发。全部完成后直接结束本轮,不输出总结文本。"
|
||||
|
||||
const mainAgentWrapUpDefault = "단계 예산이 곧 소진되어 이번 상호작용이 끝나려 합니다. 더 이상 새로운 탐색이나 조작을 시작하지 마십시오. **한 문장짜리 순수 텍스트로만** 현재 진행 상황, 핵심 결론, 그리고 권장하는 다음 단계를 사용자에게 한국어로 요약하십시오."
|
||||
const mainAgentWrapUpDefault = "步骤预算即将耗尽,本次交互即将结束。不要再开始任何新的探索或操作。**只用一句纯文本**用简体中文向用户总结当前进展、核心结论以及建议的下一步。"
|
||||
|
||||
const genericWrapUpDefault = "예산 소진으로 곧 종료됩니다. 먼저 완료했지만 아직 저장하지 않은 결과를 기록한 뒤, **한 문장짜리 순수 텍스트로만** 무엇을 했고 어떤 핵심 결론을 얻었는지 한국어로 요약하십시오(이 한 문장이 이번 실행의 결과로 표시됩니다)."
|
||||
const genericWrapUpDefault = "预算耗尽,即将结束。先记录已完成但尚未保存的结果,然后**只用一句纯文本**用简体中文总结你做了什么、得到什么核心结论(这一句将作为本次运行的结果显示)。"
|
||||
|
||||
// WrapupDefault returns the built-in default wrap-up prompt for an agent key —
|
||||
// used by the admin UI as the "restore default" value and empty-field placeholder.
|
||||
@@ -117,9 +117,9 @@ var taskTimeoutWrapupDefaults = map[string]string{
|
||||
"planner": plannerTaskTimeoutDefault,
|
||||
}
|
||||
|
||||
const workerTaskTimeoutDefault = "**전체 작업이 시간 제한 상한에 도달하여 곧 종료됩니다**(이번 run 의 예산이 아니라 탐색 전체가 끝나는 시점입니다). 이것이 마지막 기회입니다. (1) 이미 식별했지만 아직 기록하지 않은 내용을 【전부】 저장합니다. 새 자산은 insert_assets, 탐색 결론과 사실은 record_fact, 확인된 취약점은 report_finding 으로 기록합니다. (2) 더 이상 어떤 새 명령이나 탐지도 시작하지 마십시오. (3) **맨 마지막에 한 문장짜리 순수 텍스트로만** 이번 의도에서 얻은 핵심 결론을 한국어로 요약하십시오."
|
||||
const workerTaskTimeoutDefault = "**整个任务已达到时间上限,即将结束**(不是本次 run 的预算,而是整个探索结束的时刻)。这是最后一次机会。(1) 把已识别但尚未记录的内容【全部】保存。新资产用 insert_assets,探索结论与事实用 record_fact,确认的漏洞用 report_finding 记录。(2) 不要再开始任何新命令或探测。(3) **最后只用一句纯文本**用简体中文总结本次意图得到的核心结论。"
|
||||
|
||||
const plannerTaskTimeoutDefault = "**전체 작업이 시간 제한 상한에 도달하여 곧 종료됩니다**(이번 라운드가 아니라 작업 전체가 종료됩니다). 현재까지의 【모든】 사실과 발견을 근거로 마지막 목표 판정을 수행하십시오. 증거로 달성이 증명된 목표는 prove_goal 로 met 표시합니다(빠뜨리지 마십시오). **더 이상 어떤 새 의도도 생성하지 마십시오**(지금 의도를 파견해도 더 이상 실행되지 않습니다). 판정을 마치면 바로 종료하며, 요약 텍스트는 출력하지 않습니다."
|
||||
const plannerTaskTimeoutDefault = "**整个任务已达到时间上限,即将结束**(不是本轮,而是整个任务结束)。依据目前为止的【全部】事实与发现做最后一次目标判定。证据已证明达成的目标用 prove_goal 标记为 met(不要遗漏)。**不要再生成任何新意图**(现在派发意图也不会再被执行)。判定完成后立即结束,不输出总结文本。"
|
||||
|
||||
// TaskTimeoutWrapupDefault 返回某 agent 的任务超时内置默认收尾词(供后台占位/恢复默认)。
|
||||
func TaskTimeoutWrapupDefault(agentKey string) string {
|
||||
|
||||
+13
-13
@@ -6,17 +6,17 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// A2: wrap-up / settlement 프롬프트 한국어화.
|
||||
// A2: wrap-up / settlement 프롬프트 중국어화.
|
||||
//
|
||||
// 이 상수들은 run 또는 task 가 단계/시간 예산에 걸려 종료될 때 settlement 단계에서
|
||||
// 주입되어, 사용자에게 그대로 노출되는 최종 요약을 직접 지시한다. 따라서 (1) 한국어로
|
||||
// 작성되어야 하고, (2) 중국어(CJK 한자) 잔재가 없어야 하며, (3) 도구 이름과
|
||||
// 주입되어, 사용자에게 그대로 노출되는 최종 요약을 직접 지시한다. 따라서 (1) 중국어로
|
||||
// 작성되어야 하고, (2) 한글(Hangul) 잔재가 없어야 하며, (3) 도구 이름과
|
||||
// "한 문장 순수 텍스트" 같은 지시 의미가 보존되어야 한다.
|
||||
//
|
||||
// DB 시드는 wrapup_prompt / task_timeout_wrapup_prompt 를 빈 문자열로 두고(db/db.go 의
|
||||
// builtin 에이전트 INSERT 는 이 컬럼을 채우지 않는다), 비어 있으면 이 상수로 떨어진다.
|
||||
// 즉 이 상수들이 wrap-up 문구의 유일한 원천이다.
|
||||
func TestWrapupPromptsLocalizedToKorean(t *testing.T) {
|
||||
func TestWrapupPromptsLocalizedToChinese(t *testing.T) {
|
||||
all := map[string]string{
|
||||
"settleWrapUpPrompt": settleWrapUpPrompt,
|
||||
"plannerWrapUpDefault": plannerWrapUpDefault,
|
||||
@@ -36,12 +36,12 @@ func TestWrapupPromptsLocalizedToKorean(t *testing.T) {
|
||||
}
|
||||
|
||||
for name, p := range all {
|
||||
if !hasScript(p, unicode.Hangul) {
|
||||
t.Errorf("%s: 한글이 전혀 없어 한국어화되지 않았다", name)
|
||||
if !hasScript(p, unicode.Han) {
|
||||
t.Errorf("%s: 한자가 전혀 없어 중국어화되지 않았다", name)
|
||||
}
|
||||
// 도구 이름은 ASCII, 한글은 Hangul 블록이라 번역이 끝났다면 CJK 한자가 하나도 없어야 한다.
|
||||
if hasScript(p, unicode.Han) {
|
||||
t.Errorf("%s: CJK 한자 잔재가 남아 번역이 미완이다: %q", name, p)
|
||||
// 도구 이름은 ASCII 이므로, 번역이 끝났다면 한글(Hangul)이 하나도 없어야 한다.
|
||||
if hasScript(p, unicode.Hangul) {
|
||||
t.Errorf("%s: 한글 잔재가 남아 번역이 미완이다: %q", name, p)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,11 +56,11 @@ func TestWrapupPromptsLocalizedToKorean(t *testing.T) {
|
||||
}
|
||||
}
|
||||
mustContain("settleWrapUpPrompt", settleWrapUpPrompt,
|
||||
"insert_assets", "record_fact", "report_finding", "한 문장", "순수 텍스트")
|
||||
"insert_assets", "record_fact", "report_finding", "一句", "纯文本")
|
||||
mustContain("workerTaskTimeoutDefault", workerTaskTimeoutDefault,
|
||||
"insert_assets", "record_fact", "report_finding", "한 문장", "순수 텍스트")
|
||||
mustContain("genericWrapUpDefault", genericWrapUpDefault, "한 문장", "순수 텍스트")
|
||||
mustContain("mainAgentWrapUpDefault", mainAgentWrapUpDefault, "한 문장", "순수 텍스트")
|
||||
"insert_assets", "record_fact", "report_finding", "一句", "纯文本")
|
||||
mustContain("genericWrapUpDefault", genericWrapUpDefault, "一句", "纯文本")
|
||||
mustContain("mainAgentWrapUpDefault", mainAgentWrapUpDefault, "一句", "纯文本")
|
||||
// planner 는 요약 문장을 내지 않고(판정만 하고 종료) 의도·목표·할일 도구를 유지한다.
|
||||
mustContain("plannerWrapUpDefault", plannerWrapUpDefault, "add_intent", "prove_goal", "TodoWrite")
|
||||
mustContain("plannerTaskTimeoutDefault", plannerTaskTimeoutDefault, "prove_goal")
|
||||
|
||||
+5
-5
@@ -37,8 +37,8 @@ const banner = `
|
||||
// printBanner writes the startup banner + version/runtime info to stdout.
|
||||
func printBanner(addr string) {
|
||||
fmt.Print(banner)
|
||||
fmt.Println(" AI 자율 침투 테스트 시스템")
|
||||
fmt.Printf(" 버전 %s · %s/%s · %s · 수신 대기 %s\n\n",
|
||||
fmt.Println(" AI 自主渗透测试系统")
|
||||
fmt.Printf(" 版本 %s · %s/%s · %s · 监听 %s\n\n",
|
||||
version, runtime.GOOS, runtime.GOARCH, runtime.Version(), addr)
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ func run() int {
|
||||
cfgPath = abs
|
||||
}
|
||||
if _, e := os.Stat(cfgPath); e == nil {
|
||||
log.Printf("[config] 설정 파일: %s", cfgPath)
|
||||
log.Printf("[config] 配置文件: %s", cfgPath)
|
||||
} else {
|
||||
log.Printf("[config] 설정 파일: %s (파일 없음 · 환경 변수 ARTEX_PG_DSN 만 사용)", cfgPath)
|
||||
log.Printf("[config] 配置文件: %s (文件不存在 · 仅使用环境变量 ARTEX_PG_DSN)", cfgPath)
|
||||
}
|
||||
|
||||
sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
@@ -111,7 +111,7 @@ func run() int {
|
||||
if abs, err := filepath.Abs(skillDir); err == nil {
|
||||
skillDir = abs
|
||||
}
|
||||
log.Printf("[config] skill 디렉터리: %s", skillDir)
|
||||
log.Printf("[config] skill 目录: %s", skillDir)
|
||||
srv := server.New(ctx, mgr, skillDir, *dataDir, config.BaseDir())
|
||||
httpSrv := &http.Server{
|
||||
Addr: *addr,
|
||||
|
||||
@@ -32,22 +32,22 @@ func TestPrintBannerLocalized(t *testing.T) {
|
||||
out := captureStdout(t, func() { printBanner(":8787") })
|
||||
t.Logf("배너 샘플:\n%s", out)
|
||||
|
||||
hasHangul := strings.ContainsFunc(out, func(r rune) bool {
|
||||
return unicode.Is(unicode.Hangul, r)
|
||||
hasHan := strings.ContainsFunc(out, func(r rune) bool {
|
||||
return unicode.Is(unicode.Han, r)
|
||||
})
|
||||
if !hasHangul {
|
||||
t.Fatalf("배너에 한글이 없다: %q", out)
|
||||
if !hasHan {
|
||||
t.Fatalf("배너에 한자가 없다: %q", out)
|
||||
}
|
||||
|
||||
for _, want := range []string{"자율 침투 테스트 시스템", "버전", "수신 대기", ":8787"} {
|
||||
for _, want := range []string{"自主渗透测试系统", "版本", "监听", ":8787"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("배너에 %q 가 없다: %q", want, out)
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range out {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
t.Errorf("배너에 CJK 한자 잔재(%q): %q", r, out)
|
||||
if unicode.Is(unicode.Hangul, r) {
|
||||
t.Errorf("배너에 한글 잔재(%q): %q", r, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -136,16 +136,16 @@ func SkillDir() string {
|
||||
// DSN came from (for startup logging).
|
||||
func PostgresDSN() (dsn, source string, err error) {
|
||||
if v := strings.TrimSpace(os.Getenv("ARTEX_PG_DSN")); v != "" {
|
||||
return v, "환경 변수 ARTEX_PG_DSN", nil
|
||||
return v, "环境变量 ARTEX_PG_DSN", nil
|
||||
}
|
||||
db := Load().Database
|
||||
if d := strings.TrimSpace(db.DSN); d != "" {
|
||||
return d, "설정 파일 " + Path() + " (database.dsn)", nil
|
||||
return d, "配置文件 " + Path() + " (database.dsn)", nil
|
||||
}
|
||||
if db.Host != "" || db.DBName != "" || db.User != "" {
|
||||
return db.buildDSN(), "설정 파일 " + Path() + " (database 필드)", nil
|
||||
return db.buildDSN(), "配置文件 " + Path() + " (database 字段)", nil
|
||||
}
|
||||
return "", "", fmt.Errorf("데이터베이스 설정을 찾을 수 없습니다: 환경 변수 ARTEX_PG_DSN 이 설정되어 있지 않고, 설정 파일 %s 에도 database (dsn 또는 host/user/dbname) 설정이 없습니다. 설정 파일을 만들거나 환경 변수를 설정한 뒤 다시 시도하세요", Path())
|
||||
return "", "", fmt.Errorf("找不到数据库配置: 环境变量 ARTEX_PG_DSN 未设置,配置文件 %s 中也没有 database (dsn 或 host/user/dbname) 配置。请创建配置文件或设置环境变量后重试", Path())
|
||||
}
|
||||
|
||||
func (d Database) buildDSN() string {
|
||||
|
||||
@@ -27,13 +27,13 @@ func hasHangul(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func assertKorean(t *testing.T, label, s string) {
|
||||
func assertChinese(t *testing.T, label, s string) {
|
||||
t.Helper()
|
||||
if hasHan(s) {
|
||||
t.Errorf("%s: Chinese Han ideograph remains: %q", label, s)
|
||||
if !hasHan(s) {
|
||||
t.Errorf("%s: no Han found (expected Chinese): %q", label, s)
|
||||
}
|
||||
if !hasHangul(s) {
|
||||
t.Errorf("%s: no Hangul found (expected Korean): %q", label, s)
|
||||
if hasHangul(s) {
|
||||
t.Errorf("%s: Hangul remains: %q", label, s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestPostgresDSNErrorLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("missing config should error")
|
||||
}
|
||||
assertKorean(t, "startup error", err.Error())
|
||||
assertChinese(t, "startup error", err.Error())
|
||||
|
||||
// The identifiers an operator must act on stay verbatim (not translated).
|
||||
for _, want := range []string{"ARTEX_PG_DSN", "database", "dsn", "host/user/dbname"} {
|
||||
@@ -78,7 +78,7 @@ func TestPostgresDSNSourceLocalized(t *testing.T) {
|
||||
if _, source, err := PostgresDSN(); err != nil {
|
||||
t.Fatalf("env source: %v", err)
|
||||
} else {
|
||||
assertKorean(t, "env source", source)
|
||||
assertChinese(t, "env source", source)
|
||||
}
|
||||
|
||||
// config file dsn source
|
||||
@@ -90,7 +90,7 @@ func TestPostgresDSNSourceLocalized(t *testing.T) {
|
||||
if _, source, err := PostgresDSN(); err != nil {
|
||||
t.Fatalf("dsn source: %v", err)
|
||||
} else {
|
||||
assertKorean(t, "dsn source", source)
|
||||
assertChinese(t, "dsn source", source)
|
||||
}
|
||||
|
||||
// config file fields source
|
||||
@@ -100,6 +100,6 @@ func TestPostgresDSNSourceLocalized(t *testing.T) {
|
||||
if _, source, err := PostgresDSN(); err != nil {
|
||||
t.Fatalf("fields source: %v", err)
|
||||
} else {
|
||||
assertKorean(t, "fields source", source)
|
||||
assertChinese(t, "fields source", source)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ const FindingRetestAgentKey = "retester"
|
||||
// retestServiceRestartReason 은 재시작 복구(RecoverFindingRetests)가 미완 재검증을 봉인할 때 쓴다.
|
||||
// 작은따옴표 없는 상수라 SQL 리터럴 자리에 그대로 이어 붙여도 안전하다.
|
||||
const (
|
||||
retestNoConclusionReason = "에이전트가 재검증 결론을 저장하지 않았습니다. 대화를 확인한 뒤 다시 재검증해 주세요"
|
||||
retestServiceRestartReason = "서비스가 재시작되어 재검증이 중단되었습니다. 다시 시작해 주세요"
|
||||
retestNoConclusionReason = "代理未保存复测结论。请检查对话后重新复测"
|
||||
retestServiceRestartReason = "服务已重启,复测被中断。请重新开始"
|
||||
)
|
||||
|
||||
var ErrRetestNotRunning = errors.New("本次复测已结束或尚未开始,请从漏洞详情发起新的复测")
|
||||
|
||||
@@ -8,22 +8,22 @@ import (
|
||||
// assertRetestReasonKorean 은 재검증 사유 상수가 한글을 포함하고 중국어 한자가 없음을
|
||||
// 단언한다(F9). FinishFindingRetest·RecoverFindingRetests 의 SQL 리터럴 자리에 상수로
|
||||
// 이어 붙이므로, 상수를 핀 고정하면 패널에 노출되는 사용자 문구도 함께 보호된다.
|
||||
func assertRetestReasonKorean(t *testing.T, name, s string) {
|
||||
func assertRetestReasonChinese(t *testing.T, name, s string) {
|
||||
t.Helper()
|
||||
if s == "" {
|
||||
t.Fatalf("%s: 빈 문자열", name)
|
||||
t.Fatalf("%s: 空字符串", name)
|
||||
}
|
||||
hasHangul := false
|
||||
hasHan := false
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
t.Fatalf("%s: 중국어 한자가 남아 있습니다: %q", name, s)
|
||||
}
|
||||
if unicode.Is(unicode.Hangul, r) {
|
||||
hasHangul = true
|
||||
t.Fatalf("%s: 仍残留韩文: %q", name, s)
|
||||
}
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
hasHan = true
|
||||
}
|
||||
}
|
||||
if !hasHangul {
|
||||
t.Fatalf("%s: 한글이 없습니다: %q", name, s)
|
||||
if !hasHan {
|
||||
t.Fatalf("%s: 缺少汉字: %q", name, s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,6 @@ func assertRetestReasonKorean(t *testing.T, name, s string) {
|
||||
// server/conversations.go 의 형제 사유(convRetest*)와 같은 컬럼·패널이라, 둘 중 하나만
|
||||
// 한국어면 같은 패널에서 언어가 섞인다.
|
||||
func TestFindingRetestReasonsLocalized(t *testing.T) {
|
||||
assertRetestReasonKorean(t, "retestNoConclusionReason", retestNoConclusionReason)
|
||||
assertRetestReasonKorean(t, "retestServiceRestartReason", retestServiceRestartReason)
|
||||
assertRetestReasonChinese(t, "retestNoConclusionReason", retestNoConclusionReason)
|
||||
assertRetestReasonChinese(t, "retestServiceRestartReason", retestServiceRestartReason)
|
||||
}
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ const (
|
||||
MaxTaskAssetMutationCount = 100
|
||||
MaxTaskAssetSummaryRunes = 500
|
||||
defaultTaskAssetSource = "system"
|
||||
manualTaskScopeSummary = "사용자가 테스트 자산 페이지에서 직접 추가"
|
||||
manualTaskScopeSummary = "用户在测试资产页面手动添加"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -394,7 +394,7 @@ SELECT intent.id, asset.id, asset.type,
|
||||
ELSE '#' || asset.id::text
|
||||
END,
|
||||
COALESCE(link.source,'anchor'),
|
||||
COALESCE(NULLIF(link.source_summary,''), '의도가 앵커로 연결한 자산'),
|
||||
COALESCE(NULLIF(link.source_summary,''), '由意图锚点关联的资产'),
|
||||
link.source_node_id, context.task_id, context.inherited
|
||||
FROM context
|
||||
JOIN exploration_nodes intent ON intent.exploration_id=context.exploration_id AND intent.kind='intent'
|
||||
|
||||
@@ -13,18 +13,18 @@ import (
|
||||
// via the symbol, so pinning the constant here protects the user-facing text too.
|
||||
func TestManualTaskScopeSummaryLocalized(t *testing.T) {
|
||||
if manualTaskScopeSummary == "" {
|
||||
t.Fatal("manualTaskScopeSummary: 빈 문자열")
|
||||
t.Fatal("manualTaskScopeSummary: 空字符串")
|
||||
}
|
||||
hasHangul := false
|
||||
hasHan := false
|
||||
for _, r := range manualTaskScopeSummary {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
t.Fatalf("manualTaskScopeSummary: 중국어 한자가 남아 있습니다: %q", manualTaskScopeSummary)
|
||||
}
|
||||
if unicode.Is(unicode.Hangul, r) {
|
||||
hasHangul = true
|
||||
t.Fatalf("manualTaskScopeSummary: 仍残留韩文: %q", manualTaskScopeSummary)
|
||||
}
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
hasHan = true
|
||||
}
|
||||
}
|
||||
if !hasHangul {
|
||||
t.Fatalf("manualTaskScopeSummary: 한글이 없습니다: %q", manualTaskScopeSummary)
|
||||
if !hasHan {
|
||||
t.Fatalf("manualTaskScopeSummary: 缺少汉字: %q", manualTaskScopeSummary)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -267,7 +267,7 @@ WITH requested(company_id, position) AS (
|
||||
FROM unnest($2::bigint[]) WITH ORDINALITY AS requested(company_id, position)
|
||||
), inserted AS (
|
||||
INSERT INTO task_scope(task_id, kind, company_id, source, reason)
|
||||
SELECT $1, 'company', companies.id, 'manual', '작업 생성 시 연결된 회사'
|
||||
SELECT $1, 'company', companies.id, 'manual', '任务创建时关联的公司'
|
||||
FROM requested
|
||||
JOIN companies ON companies.id=requested.company_id
|
||||
ORDER BY requested.position
|
||||
@@ -295,7 +295,7 @@ WHERE company_id=ANY($2::bigint[])`, taskID, companyIDs); err != nil {
|
||||
}
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO task_asset_links(task_id, asset_id, source, source_summary)
|
||||
SELECT $1, asset.id, $3, '작업 생성 시 연결된 회사: ' || company.name
|
||||
SELECT $1, asset.id, $3, '任务创建时关联的公司: ' || company.name
|
||||
FROM assets asset
|
||||
JOIN companies company ON company.id=asset.company_id
|
||||
WHERE asset.company_id=ANY($2::bigint[])
|
||||
|
||||
+1
-1
@@ -607,7 +607,7 @@ RETURNING id`, domainB, companyB).Scan(&companyAssetB); err != nil {
|
||||
t.Errorf("company asset %d missing from task", assetID)
|
||||
continue
|
||||
}
|
||||
if asset.TaskSource != taskCompanyAssetSource || asset.TaskSourceSummary != "작업 생성 시 연결된 회사: "+companyName {
|
||||
if asset.TaskSource != taskCompanyAssetSource || asset.TaskSourceSummary != "任务创建时关联的公司: "+companyName {
|
||||
t.Errorf("asset %d provenance=%q/%q", assetID, asset.TaskSource, asset.TaskSourceSummary)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-15
@@ -30,15 +30,15 @@ import (
|
||||
// (판정 프롬프트 본문)는 번역하지 않으며, [模型] 센티넬(decision_source 분류용 · UI 에서
|
||||
// 표시 전 제거 · db SQL LIKE·HasPrefix 소비처와 결합)도 원문 그대로 둔다.
|
||||
const (
|
||||
msgReviewContextIncomplete = "검토 맥락이 완전하지 않아 사람의 확인이 필요합니다: "
|
||||
msgModelApprovalFailed = "모델 승인에 실패하여 실패 정책에 따라 처리합니다: "
|
||||
msgModelOutputUnparsable = "모델 출력을 해석할 수 없어 실패 정책에 따라 처리합니다"
|
||||
msgToolApprovalRequestFmt = "도구 %s 승인 요청 (#%d)"
|
||||
reasonWorkCanceled = "작업이 취소되었습니다"
|
||||
reasonWorkCanceledPreExec = "실행 전에 작업이 취소되었습니다"
|
||||
reasonApprovalTimeout = "승인 시간이 초과되어 시간 초과 정책에 따라 처리합니다"
|
||||
reasonManualDeny = "사람이 실행을 거부했습니다"
|
||||
reasonManualAllow = "사람이 실행을 허용했습니다"
|
||||
msgReviewContextIncomplete = "审查上下文不完整,需要人工确认:"
|
||||
msgModelApprovalFailed = "模型审批失败,将按失败策略处理:"
|
||||
msgModelOutputUnparsable = "无法解析模型输出,将按失败策略处理"
|
||||
msgToolApprovalRequestFmt = "工具 %s 审批请求 (#%d)"
|
||||
reasonWorkCanceled = "任务已取消"
|
||||
reasonWorkCanceledPreExec = "执行前任务已取消"
|
||||
reasonApprovalTimeout = "审批超时,将按超时策略处理"
|
||||
reasonManualDeny = "人工拒绝执行"
|
||||
reasonManualAllow = "人工允许执行"
|
||||
)
|
||||
|
||||
// ctxKey is the unexported context key type to avoid collisions.
|
||||
@@ -508,11 +508,11 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw
|
||||
func judgeActionLabel(action string) string {
|
||||
switch action {
|
||||
case "allow":
|
||||
return "허용"
|
||||
return "允许"
|
||||
case "deny":
|
||||
return "차단"
|
||||
return "阻止"
|
||||
case "ask":
|
||||
return "확인 요청"
|
||||
return "请求确认"
|
||||
default:
|
||||
return action
|
||||
}
|
||||
@@ -566,9 +566,9 @@ func ruleMatches(r compiledRule, toolName string, input []byte) bool {
|
||||
func defaultMessage(action, name string) string {
|
||||
switch action {
|
||||
case "deny":
|
||||
return "가로채기 규칙 [" + name + "] 에 따라 이 도구를 실행할 수 없습니다"
|
||||
return "根据拦截规则 [" + name + "],无法执行此工具"
|
||||
case "ask":
|
||||
return "가로채기 규칙 [" + name + "] 에 따라 사용자 승인이 필요합니다. 잠시 기다려 주세요"
|
||||
return "根据拦截规则 [" + name + "],需要用户审批。请稍候"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -681,7 +681,7 @@ func (i *Interceptor) HandleAsk(ctx context.Context, convID int64, dec Decision,
|
||||
}
|
||||
}
|
||||
|
||||
var ErrAlreadyDecided = errors.New("이미 처리되었거나 존재하지 않는 승인입니다. 기록을 새로 고쳐 주세요")
|
||||
var ErrAlreadyDecided = errors.New("该审批已处理或不存在,请刷新记录")
|
||||
|
||||
// Decide resolves a pending request. Called by the HTTP decide endpoint.
|
||||
func (i *Interceptor) Decide(pendingID int64, allowed bool) error {
|
||||
|
||||
@@ -27,15 +27,15 @@ func hasHan(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// assertKorean fails if s lacks Hangul or still carries Han ideographs. Reverting
|
||||
// any localized message back to Chinese trips hasHan, so the test is not vacuous.
|
||||
func assertKorean(t *testing.T, label, s string) {
|
||||
// assertChinese fails if s lacks Han ideographs or still carries Hangul. Reverting
|
||||
// any localized message back to Korean trips hasHangul, so the test is not vacuous.
|
||||
func assertChinese(t *testing.T, label, s string) {
|
||||
t.Helper()
|
||||
if !hasHangul(s) {
|
||||
t.Errorf("%s: 한글이 없습니다: %q", label, s)
|
||||
if !hasHan(s) {
|
||||
t.Errorf("%s: 缺少汉字: %q", label, s)
|
||||
}
|
||||
if hasHan(s) {
|
||||
t.Errorf("%s: 한자가 남아 있습니다: %q", label, s)
|
||||
if hasHangul(s) {
|
||||
t.Errorf("%s: 仍残留韩文: %q", label, s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestInterceptMessagesLocalized(t *testing.T) {
|
||||
{"reasonManualAllow", reasonManualAllow},
|
||||
{"ErrAlreadyDecided", ErrAlreadyDecided.Error()},
|
||||
} {
|
||||
assertKorean(t, c.name, c.s)
|
||||
assertChinese(t, c.name, c.s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ func TestInterceptMessagesLocalized(t *testing.T) {
|
||||
// passes through untranslated.
|
||||
func TestJudgeActionLabelLocalized(t *testing.T) {
|
||||
for _, action := range []string{"allow", "deny", "ask"} {
|
||||
assertKorean(t, "judgeActionLabel("+action+")", judgeActionLabel(action))
|
||||
assertChinese(t, "judgeActionLabel("+action+")", judgeActionLabel(action))
|
||||
}
|
||||
if got := judgeActionLabel("weird"); got != "weird" {
|
||||
t.Errorf("judgeActionLabel(weird) = %q, want passthrough", got)
|
||||
@@ -75,7 +75,7 @@ func TestJudgeActionLabelLocalized(t *testing.T) {
|
||||
func TestDefaultMessageLocalized(t *testing.T) {
|
||||
for _, action := range []string{"deny", "ask"} {
|
||||
msg := defaultMessage(action, "R1")
|
||||
assertKorean(t, "defaultMessage("+action+")", msg)
|
||||
assertChinese(t, "defaultMessage("+action+")", msg)
|
||||
if !strings.Contains(msg, "R1") {
|
||||
t.Errorf("defaultMessage(%s) dropped rule name: %q", action, msg)
|
||||
}
|
||||
@@ -90,11 +90,11 @@ func TestDefaultMessageLocalized(t *testing.T) {
|
||||
// the tool name via /도구\s+(\S+)\s+승인/.
|
||||
func TestToolApprovalSummaryLocalized(t *testing.T) {
|
||||
s := fmt.Sprintf(msgToolApprovalRequestFmt, "Bash", 42)
|
||||
assertKorean(t, "msgToolApprovalRequestFmt", s)
|
||||
assertChinese(t, "msgToolApprovalRequestFmt", s)
|
||||
if !strings.Contains(s, "(#42)") {
|
||||
t.Errorf("summary lost the (#N) marker (transcript.tsx pending_id regex): %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "도구 Bash 승인") {
|
||||
t.Errorf("summary lost the '도구 X 승인' shape (transcript.tsx toolName regex): %q", s)
|
||||
if !strings.Contains(s, "工具 Bash 审批") {
|
||||
t.Errorf("summary lost the '工具 X 审批' shape (transcript.tsx toolName regex): %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -40,7 +40,7 @@ type Member struct {
|
||||
const RankActive = int(^uint(0)>>1) - 1
|
||||
|
||||
// ErrExhausted is returned when every member of the chain failed.
|
||||
var ErrExhausted = errors.New("LLM 폴백 체인: 사용 가능한 설정이 없습니다")
|
||||
var ErrExhausted = errors.New("LLM 故障转移链:没有可用的配置")
|
||||
|
||||
// Pool is an llm.Provider that fails over across an ordered chain of members.
|
||||
// It is safe for concurrent use: members are immutable after construction and
|
||||
|
||||
@@ -32,9 +32,9 @@ func hasHanzi(s string) bool {
|
||||
|
||||
// assertSurfacedExhaustion checks the error an exhausted chain hands back: it must
|
||||
// still wrap the ErrExhausted sentinel (callers rely on errors.Is), read as a
|
||||
// Korean message (no Han characters, no full-width colon), and lead with the
|
||||
// Chinese message (Han characters present, no Hangul), and lead with the
|
||||
// localized sentinel text. failProv's body is ASCII ("anthropic: status 402:
|
||||
// nope"), so the whole surfaced string must be Han-free.
|
||||
// nope"), so the whole surfaced string is expected to be Han-bearing but Hangul-free.
|
||||
func assertSurfacedExhaustion(t *testing.T, label string, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
@@ -44,11 +44,11 @@ func assertSurfacedExhaustion(t *testing.T, label string, err error) {
|
||||
t.Fatalf("%s: surfaced error no longer wraps ErrExhausted: %v", label, err)
|
||||
}
|
||||
msg := err.Error()
|
||||
if hasHanzi(msg) {
|
||||
t.Errorf("%s: surfaced error still carries Chinese characters: %q", label, msg)
|
||||
if !hasHanzi(msg) {
|
||||
t.Errorf("%s: surfaced error is not localized to Chinese: %q", label, msg)
|
||||
}
|
||||
if strings.ContainsRune(msg, ':') {
|
||||
t.Errorf("%s: surfaced error still uses a full-width colon: %q", label, msg)
|
||||
if hasHangul(msg) {
|
||||
t.Errorf("%s: surfaced error still carries Korean characters: %q", label, msg)
|
||||
}
|
||||
if !strings.HasPrefix(msg, ErrExhausted.Error()) {
|
||||
t.Errorf("%s: surfaced error does not lead with the localized sentinel: %q", label, msg)
|
||||
@@ -60,7 +60,7 @@ func assertSurfacedExhaustion(t *testing.T, label string, err error) {
|
||||
// transcript. So its text must be Korean, while its identity (errors.Is) must be
|
||||
// preserved for callers that branch on the sentinel.
|
||||
func TestExhaustedErrorLocalized(t *testing.T) {
|
||||
assertKoreanErrText(t, "ErrExhausted", ErrExhausted.Error())
|
||||
assertChineseErrText(t, "ErrExhausted", ErrExhausted.Error())
|
||||
|
||||
// Drive both surfaced paths so a future edit to either wrap site is caught.
|
||||
streamChain := New([]*Member{
|
||||
@@ -78,13 +78,13 @@ func TestExhaustedErrorLocalized(t *testing.T) {
|
||||
assertSurfacedExhaustion(t, "Complete", cerr)
|
||||
}
|
||||
|
||||
// assertKoreanErrText fails unless s contains Hangul and no Han character.
|
||||
func assertKoreanErrText(t *testing.T, label, s string) {
|
||||
// assertChineseErrText fails unless s contains a Han character and no Hangul.
|
||||
func assertChineseErrText(t *testing.T, label, s string) {
|
||||
t.Helper()
|
||||
if !hasHangul(s) {
|
||||
t.Errorf("%s: 한글이 없습니다: %q", label, s)
|
||||
if !hasHanzi(s) {
|
||||
t.Errorf("%s: 缺少汉字: %q", label, s)
|
||||
}
|
||||
if hasHanzi(s) {
|
||||
t.Errorf("%s: 중국어 한자가 남아 있습니다: %q", label, s)
|
||||
if hasHangul(s) {
|
||||
t.Errorf("%s: 仍残留韩文: %q", label, s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestDingTalkFallsBackToMarkdownForBatch(t *testing.T) {
|
||||
t.Fatalf("汇总消息应发 markdown,得到 %v", body["msgtype"])
|
||||
}
|
||||
md, _ := body["markdown"].(map[string]any)
|
||||
if !strings.Contains(md["text"].(string), "최근 30분간") {
|
||||
if !strings.Contains(md["text"].(string), "最近 30 分钟") {
|
||||
t.Errorf("汇总正文缺少时间窗: %v", md["text"])
|
||||
}
|
||||
})
|
||||
@@ -251,7 +251,7 @@ func TestWebhookDefaultTemplateProducesValidJSON(t *testing.T) {
|
||||
// 这条是默认模板存在的意义:标题里带引号与换行时,任何朴素的
|
||||
// `"title": "{{.Title}}"` 写法都会产出非法 JSON。{{json .}} 才不会。
|
||||
srv := capturePost(t, `{"ok":true}`, func(t *testing.T, body map[string]any, _ *http.Request) {
|
||||
if body["title"] != `[🟠 높음] 登录处 "SQL注入" 风险` {
|
||||
if body["title"] != `[🟠 高危] 登录处 "SQL注入" 风险` {
|
||||
t.Errorf("标题未正确还原: %v", body["title"])
|
||||
}
|
||||
items, _ := body["items"].([]any)
|
||||
@@ -414,8 +414,8 @@ func TestChannelValidateReportsMissingFields(t *testing.T) {
|
||||
{KindTelegram, map[string]any{}, "Bot Token"},
|
||||
{KindTelegram, map[string]any{"bot_token": "t"}, "Chat ID"},
|
||||
{KindEmail, map[string]any{}, "SMTP"},
|
||||
{KindEmail, map[string]any{"host": "h"}, "포트"},
|
||||
{KindEmail, map[string]any{"host": "h", "port": 587, "from": "f"}, "수신자"},
|
||||
{KindEmail, map[string]any{"host": "h"}, "端口"},
|
||||
{KindEmail, map[string]any{"host": "h", "port": 587, "from": "f"}, "收件人"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ch, ok := Get(tc.kind)
|
||||
|
||||
+10
-10
@@ -38,10 +38,10 @@ func (dingTalkChannel) DestinationKeys() []string { return []string{"webhook"} }
|
||||
func (dingTalkChannel) Validate(cfg map[string]any) error {
|
||||
hook := cfgString(cfg, "webhook")
|
||||
if hook == "" {
|
||||
return errors.New("Webhook 주소가 없습니다")
|
||||
return errors.New("缺少 Webhook 地址")
|
||||
}
|
||||
if err := validateHTTPURL(hook); err != nil {
|
||||
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
|
||||
return fmt.Errorf("Webhook 地址无效:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -69,7 +69,7 @@ func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message
|
||||
"title": title,
|
||||
"text": text,
|
||||
"btnOrientation": "0",
|
||||
"singleTitle": "상세 보기",
|
||||
"singleTitle": "查看详情",
|
||||
"singleURL": m.Items[0].DetailURL,
|
||||
},
|
||||
}
|
||||
@@ -90,12 +90,12 @@ func (c dingTalkChannel) Send(ctx context.Context, cfg map[string]any, m Message
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return 0, fmt.Errorf("DingTalk 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
|
||||
return 0, fmt.Errorf("无法解析 DingTalk 响应:%w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.ErrCode != 0 {
|
||||
// 301000 是签名校验失败、310000 是关键词不匹配——都是配置错误,
|
||||
// 重试不会自愈。
|
||||
return 0, Permanent(fmt.Errorf("DingTalk에서 오류가 발생했습니다 (%d): %s", res.ErrCode, res.ErrMsg))
|
||||
return 0, Permanent(fmt.Errorf("DingTalk 发生错误 (%d):%s", res.ErrCode, res.ErrMsg))
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) {
|
||||
u, err := url.Parse(hook)
|
||||
if err != nil {
|
||||
// 不透传 err:url.Parse 的错误文本里带完整地址(含 access_token)。
|
||||
return "", fmt.Errorf("Webhook 주소를 해석하지 못했습니다: %s", redactRequestTarget(hook))
|
||||
return "", fmt.Errorf("无法解析 Webhook 地址:%s", redactRequestTarget(hook))
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set("timestamp", ts)
|
||||
@@ -145,16 +145,16 @@ func dingTalkSignedURL(hook, secret string, now time.Time) (string, error) {
|
||||
func validateHTTPURL(raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("주소를 해석할 수 없습니다 (%s)", redactRequestTarget(raw))
|
||||
return fmt.Errorf("无法解析地址 (%s)", redactRequestTarget(raw))
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("http 또는 https 만 지원합니다. 받은 값: %q", u.Scheme)
|
||||
return fmt.Errorf("仅支持 http 或 https。收到的值:%q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return errors.New("호스트 이름이 없습니다")
|
||||
return errors.New("缺少主机名")
|
||||
}
|
||||
if ip := net.ParseIP(u.Hostname()); ip != nil && isBlockedDialIP(ip) && !allowLocalTargets() {
|
||||
return fmt.Errorf("로컬 또는 링크 로컬 주소 %s 로는 전송하지 않습니다 (로컬 서비스로 보내야 하면 %s=1 로 설정하세요)", ip, AllowLocalTargetsEnv)
|
||||
return fmt.Errorf("不会向本地或链路本地地址 %s 发送 (如需发送到本地服务,请设置 %s=1)", ip, AllowLocalTargetsEnv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,12 +40,12 @@ func mustJSON(t *testing.T, v any) string {
|
||||
// 별개로 이 핀이 있어야 飞书 본문만 중국어로 되돌아가는 회귀를 잡는다.
|
||||
func TestFeishuItemLinesLocalized(t *testing.T) {
|
||||
got := feishuItemLines(asciiStatusChangeItem())
|
||||
assertKorean(t, "feishuItemLines", got)
|
||||
assertChinese(t, "feishuItemLines", got)
|
||||
for _, want := range []string{
|
||||
"**상태 변경**: 처리 대기 → 수정됨",
|
||||
"**유형**: SQLi",
|
||||
"**자산**: a.example.com",
|
||||
"**개요**: SQL injection via q param",
|
||||
"**状态变更**:待处理 → 已修复",
|
||||
"**类型**:SQLi",
|
||||
"**资产**:a.example.com",
|
||||
"**概要**:SQL injection via q param",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("飞书 단건 본문에 %q 가 있어야 합니다:\n%s", want, got)
|
||||
@@ -59,21 +59,21 @@ func TestFeishuCardButtonsLocalized(t *testing.T) {
|
||||
// 단건: "상세 보기" 버튼(DetailURL 있을 때)
|
||||
single, _ := feishuCard(Message{Items: []Item{asciiStatusChangeItem()}})
|
||||
singleJSON := mustJSON(t, single)
|
||||
if hasHan(singleJSON) {
|
||||
t.Errorf("단건 飞书 카드에 중국어 한자가 남았습니다:\n%s", singleJSON)
|
||||
if hasHangul(singleJSON) {
|
||||
t.Errorf("단건 飞书 카드에 한글이 남았습니다:\n%s", singleJSON)
|
||||
}
|
||||
if !strings.Contains(singleJSON, "상세 보기") {
|
||||
t.Errorf("단건 飞书 카드에 '상세 보기' 버튼이 있어야 합니다:\n%s", singleJSON)
|
||||
if !strings.Contains(singleJSON, "查看详情") {
|
||||
t.Errorf("단건 飞书 카드에 '查看详情' 버튼이 있어야 합니다:\n%s", singleJSON)
|
||||
}
|
||||
|
||||
// 다건: "플랫폼에서 전체 보기" 버튼(HomeURL 있을 때)
|
||||
batch, _ := feishuCard(Message{Batch: true, HomeURL: "https://platform.example", Items: hanFreeItems(2)})
|
||||
batchJSON := mustJSON(t, batch)
|
||||
if hasHan(batchJSON) {
|
||||
t.Errorf("다건 飞书 카드에 중국어 한자가 남았습니다:\n%s", batchJSON)
|
||||
if hasHangul(batchJSON) {
|
||||
t.Errorf("다건 飞书 카드에 한글이 남았습니다:\n%s", batchJSON)
|
||||
}
|
||||
if !strings.Contains(batchJSON, "플랫폼에서 전체 보기") {
|
||||
t.Errorf("다건 飞书 카드에 '플랫폼에서 전체 보기' 버튼이 있어야 합니다:\n%s", batchJSON)
|
||||
if !strings.Contains(batchJSON, "在平台查看全部") {
|
||||
t.Errorf("다건 飞书 카드에 '在平台查看全部' 버튼이 있어야 합니다:\n%s", batchJSON)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ func TestDingTalkActionCardButtonLocalized(t *testing.T) {
|
||||
if _, err := (dingTalkChannel{}).Send(context.Background(), map[string]any{"webhook": srv.URL}, m); err != nil {
|
||||
t.Fatalf("투递 실패: %v", err)
|
||||
}
|
||||
if singleTitle != "상세 보기" {
|
||||
t.Errorf("钉钉 ActionCard singleTitle 은 '상세 보기' 여야 합니다, 받은 값 %q", singleTitle)
|
||||
if singleTitle != "查看详情" {
|
||||
t.Errorf("钉钉 ActionCard singleTitle 은 '查看详情' 여야 합니다, 받은 값 %q", singleTitle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,21 +108,21 @@ func TestChinaPlatformValidateLocalized(t *testing.T) {
|
||||
if missing == nil {
|
||||
t.Fatalf("%s: 빈 설정은 검증에 실패해야 합니다", kind)
|
||||
}
|
||||
assertKorean(t, kind+" missing", missing.Error())
|
||||
if !strings.Contains(missing.Error(), "Webhook") || !strings.Contains(missing.Error(), "없습니다") {
|
||||
t.Errorf("%s: 누락 오류는 'Webhook 주소가 없습니다' 여야 합니다, 받은 값 %q", kind, missing)
|
||||
assertChinese(t, kind+" missing", missing.Error())
|
||||
if !strings.Contains(missing.Error(), "Webhook") || !strings.Contains(missing.Error(), "缺少") {
|
||||
t.Errorf("%s: 누락 오류는 '缺少 Webhook 地址' 여야 합니다, 받은 값 %q", kind, missing)
|
||||
}
|
||||
|
||||
// 잘못된 주소(ftp): "Webhook 주소가 올바르지 않습니다: …"
|
||||
// 잘못된 주소(ftp): "Webhook 地址无效: …"
|
||||
bad := ch.Validate(map[string]any{"webhook": "ftp://x"})
|
||||
if bad == nil {
|
||||
t.Fatalf("%s: ftp 주소는 검증에 실패해야 합니다", kind)
|
||||
}
|
||||
if hasHan(bad.Error()) {
|
||||
t.Errorf("%s: 잘못된 주소 오류에 중국어 한자가 남았습니다: %q", kind, bad)
|
||||
if hasHangul(bad.Error()) {
|
||||
t.Errorf("%s: 잘못된 주소 오류에 한글이 남았습니다: %q", kind, bad)
|
||||
}
|
||||
if !strings.Contains(bad.Error(), "Webhook 주소가 올바르지 않습니다") {
|
||||
t.Errorf("%s: 잘못된 주소 오류는 'Webhook 주소가 올바르지 않습니다' 로 시작해야 합니다, 받은 값 %q", kind, bad)
|
||||
if !strings.Contains(bad.Error(), "Webhook 地址无效") {
|
||||
t.Errorf("%s: 잘못된 주소 오류는 'Webhook 地址无效' 로 시작해야 합니다, 받은 값 %q", kind, bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,10 +138,10 @@ func TestChinaPlatformSendErrorsLocalized(t *testing.T) {
|
||||
resp string
|
||||
wantSubs []string
|
||||
}{
|
||||
{KindDingTalk, `{"errcode":310000,"errmsg":"keyword not matched"}`, []string{"DingTalk에서 오류가 발생했습니다", "310000"}},
|
||||
{KindFeishu, `{"code":19021,"msg":"sign error"}`, []string{"Feishu에서 오류가 발생했습니다", "19021"}},
|
||||
{KindWeCom, `{"errcode":45009,"errmsg":"freq out of limit"}`, []string{"WeCom 요청이 제한되었습니다", "45009"}},
|
||||
{KindWeCom, `{"errcode":93000,"errmsg":"invalid webhook"}`, []string{"WeCom에서 오류가 발생했습니다", "93000"}},
|
||||
{KindDingTalk, `{"errcode":310000,"errmsg":"keyword not matched"}`, []string{"DingTalk 发生错误", "310000"}},
|
||||
{KindFeishu, `{"code":19021,"msg":"sign error"}`, []string{"Feishu 发生错误", "19021"}},
|
||||
{KindWeCom, `{"errcode":45009,"errmsg":"freq out of limit"}`, []string{"WeCom 请求受限", "45009"}},
|
||||
{KindWeCom, `{"errcode":93000,"errmsg":"invalid webhook"}`, []string{"WeCom 发生错误", "93000"}},
|
||||
}
|
||||
for _, tc := range bizCases {
|
||||
srv := capturePost(t, tc.resp, nil)
|
||||
@@ -153,8 +153,8 @@ func TestChinaPlatformSendErrorsLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("%s: %s 응답은 오류여야 합니다", tc.kind, tc.resp)
|
||||
}
|
||||
if hasHan(err.Error()) {
|
||||
t.Errorf("%s: 발송 오류에 중국어 한자가 남았습니다: %q", tc.kind, err)
|
||||
if hasHangul(err.Error()) {
|
||||
t.Errorf("%s: 발송 오류에 한글이 남았습니다: %q", tc.kind, err)
|
||||
}
|
||||
for _, sub := range tc.wantSubs {
|
||||
if !strings.Contains(err.Error(), sub) {
|
||||
@@ -182,7 +182,7 @@ func TestChinaPlatformSendErrorsLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("%s: JSON 이 아닌 응답은 오류여야 합니다", pc.kind)
|
||||
}
|
||||
want := pc.platform + " 응답을 해석하지 못했습니다"
|
||||
want := "无法解析 " + pc.platform + " 响应"
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: 파싱 실패 오류에 %q 가 있어야 합니다, 받은 값 %q", pc.kind, want, err)
|
||||
}
|
||||
|
||||
+14
-14
@@ -40,17 +40,17 @@ func (emailChannel) DestinationKeys() []string { return []string{"host", "port",
|
||||
|
||||
func (emailChannel) Validate(cfg map[string]any) error {
|
||||
if cfgString(cfg, "host") == "" {
|
||||
return errors.New("SMTP 서버 주소가 없습니다")
|
||||
return errors.New("缺少 SMTP 服务器地址")
|
||||
}
|
||||
port := cfgInt(cfg, "port")
|
||||
if port <= 0 || port > 65535 {
|
||||
return errors.New("SMTP 포트가 올바르지 않습니다 (1-65535 범위여야 합니다)")
|
||||
return errors.New("SMTP 端口无效(必须在 1-65535 范围内)")
|
||||
}
|
||||
if cfgString(cfg, "from") == "" {
|
||||
return errors.New("발신자 주소가 없습니다")
|
||||
return errors.New("缺少发件人地址")
|
||||
}
|
||||
if len(cfgStrings(cfg, "to")) == 0 {
|
||||
return errors.New("수신자 주소가 최소 하나 필요합니다")
|
||||
return errors.New("至少需要一个收件人地址")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (
|
||||
if !implicitTLS {
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err := client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return 0, fmt.Errorf("STARTTLS 실패: %w", err)
|
||||
return 0, fmt.Errorf("STARTTLS 失败:%w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,28 +93,28 @@ func (c emailChannel) Send(ctx context.Context, cfg map[string]any, m Message) (
|
||||
// 这是**正确**的安全行为,不能绕过,但需要把原因翻译清楚——
|
||||
// 否则使用者只会看到「unencrypted connection」而不知道该怎么办。
|
||||
if strings.Contains(err.Error(), "unencrypted connection") {
|
||||
return 0, Permanent(fmt.Errorf("자격 증명 전송을 거부했습니다: 연결이 암호화되지 않았습니다. TLS 를 켜거나, 465 포트(암시적 TLS)를 사용하거나, 「TLS 사용」을 체크하세요 (%w)", err))
|
||||
return 0, Permanent(fmt.Errorf("拒绝发送凭据:连接未加密。请开启 TLS、使用 465 端口(隐式 TLS),或勾选「使用 TLS」(%w)", err))
|
||||
}
|
||||
return 0, Permanent(fmt.Errorf("SMTP 인증 실패: %w", err))
|
||||
return 0, Permanent(fmt.Errorf("SMTP 认证失败:%w", err))
|
||||
}
|
||||
}
|
||||
if err := client.Mail(from); err != nil {
|
||||
return 0, smtpStageError(fmt.Sprintf("발신자 %s 주소가 거부되었습니다", from), err)
|
||||
return 0, smtpStageError(fmt.Sprintf("发件人地址 %s 被拒绝", from), err)
|
||||
}
|
||||
for _, rcpt := range to {
|
||||
if err := client.Rcpt(rcpt); err != nil {
|
||||
return 0, smtpStageError(fmt.Sprintf("수신자 %s 주소가 거부되었습니다", rcpt), err)
|
||||
return 0, smtpStageError(fmt.Sprintf("收件人地址 %s 被拒绝", rcpt), err)
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("SMTP DATA 실패: %w", err)
|
||||
return 0, fmt.Errorf("SMTP DATA 失败:%w", err)
|
||||
}
|
||||
if _, err := w.Write([]byte(msg)); err != nil {
|
||||
return 0, fmt.Errorf("메일 본문 쓰기 실패: %w", err)
|
||||
return 0, fmt.Errorf("写入邮件正文失败:%w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return 0, fmt.Errorf("메일 제출 실패: %w", err)
|
||||
return 0, fmt.Errorf("提交邮件失败:%w", err)
|
||||
}
|
||||
// Quit 失败不影响「邮件已被服务器接收」这个事实,因此忽略其错误。
|
||||
_ = client.Quit()
|
||||
@@ -145,13 +145,13 @@ func emailDial(ctx context.Context, addr, host string, implicitTLS bool) (*smtp.
|
||||
conn, err = d.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SMTP 서버 연결 실패: %w", err)
|
||||
return nil, fmt.Errorf("连接 SMTP 服务器失败:%w", err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(emailSessionTimeout))
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("SMTP 핸드셰이크 실패: %w", err)
|
||||
return nil, fmt.Errorf("SMTP 握手失败:%w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ func TestEmailSendRefusesPlaintextCredentials(t *testing.T) {
|
||||
t.Skip("本机 DNS 解析到了本地服务器,跳过(不影响其它用例)")
|
||||
}
|
||||
// 连不上 或 被拒发凭据都算通过这条断言;关键是**不能**静默把密码发出去。
|
||||
if !IsPermanent(err) && !strings.Contains(err.Error(), "연결") {
|
||||
if !IsPermanent(err) && !strings.Contains(err.Error(), "连接") {
|
||||
t.Logf("错误:%v(非 localhost 下未能连上属预期)", err)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ func (i Item) Title() string {
|
||||
if i.VulnClass != "" {
|
||||
return i.VulnClass
|
||||
}
|
||||
return "(이름 없는 취약점)"
|
||||
return "(未命名漏洞)"
|
||||
}
|
||||
|
||||
// Message 是一次渠道发送的完整内容。
|
||||
|
||||
+11
-11
@@ -34,10 +34,10 @@ func (feishuChannel) DestinationKeys() []string { return []string{"webhook"} }
|
||||
func (feishuChannel) Validate(cfg map[string]any) error {
|
||||
hook := cfgString(cfg, "webhook")
|
||||
if hook == "" {
|
||||
return errors.New("Webhook 주소가 없습니다")
|
||||
return errors.New("缺少 Webhook 地址")
|
||||
}
|
||||
if err := validateHTTPURL(hook); err != nil {
|
||||
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
|
||||
return fmt.Errorf("Webhook 地址无效:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -69,13 +69,13 @@ func (c feishuChannel) Send(ctx context.Context, cfg map[string]any, m Message)
|
||||
StatusMessage string `json:"StatusMessage"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return 0, fmt.Errorf("Feishu 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
|
||||
return 0, fmt.Errorf("无法解析 Feishu 响应:%w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.Code != 0 {
|
||||
return 0, Permanent(fmt.Errorf("Feishu에서 오류가 발생했습니다 (%d): %s", res.Code, res.Msg))
|
||||
return 0, Permanent(fmt.Errorf("Feishu 发生错误 (%d):%s", res.Code, res.Msg))
|
||||
}
|
||||
if res.StatusCode != 0 {
|
||||
return 0, Permanent(fmt.Errorf("Feishu에서 오류가 발생했습니다 (%d): %s", res.StatusCode, res.StatusMessage))
|
||||
return 0, Permanent(fmt.Errorf("Feishu 发生错误 (%d):%s", res.StatusCode, res.StatusMessage))
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
@@ -133,14 +133,14 @@ func feishuCard(m Message) (map[string]any, int) {
|
||||
elements = append(elements, feishuMarkdownDiv(feishuBatchLine(it, i+1)))
|
||||
}
|
||||
if m.HomeURL != "" {
|
||||
elements = append(elements, feishuButton("플랫폼에서 전체 보기", m.HomeURL))
|
||||
elements = append(elements, feishuButton("在平台查看全部", m.HomeURL))
|
||||
}
|
||||
} else if len(m.Items) > 0 {
|
||||
kept = 1
|
||||
it := m.Items[0]
|
||||
elements = append(elements, feishuMarkdownDiv(feishuItemLines(it)))
|
||||
if it.DetailURL != "" {
|
||||
elements = append(elements, feishuButton("상세 보기", it.DetailURL))
|
||||
elements = append(elements, feishuButton("查看详情", it.DetailURL))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,18 +179,18 @@ func feishuButton(label, url string) map[string]any {
|
||||
func feishuItemLines(it Item) string {
|
||||
out := fmt.Sprintf("**%s · %s**", SeverityLabel(it.Severity), markdownText(it.Title(), 0))
|
||||
if it.IsStatusChange() {
|
||||
out += fmt.Sprintf("\n**상태 변경**: %s → %s",
|
||||
out += fmt.Sprintf("\n**状态变更**:%s → %s",
|
||||
markdownText(StatusLabel(it.FromStatus), 0), markdownText(StatusLabel(it.ToStatus), 0))
|
||||
}
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
out += fmt.Sprintf("\n**유형**: %s", markdownText(it.VulnClass, 0))
|
||||
out += fmt.Sprintf("\n**类型**:%s", markdownText(it.VulnClass, 0))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
out += fmt.Sprintf("\n**자산**: %s", markdownText(a, 0))
|
||||
out += fmt.Sprintf("\n**资产**:%s", markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
|
||||
out += fmt.Sprintf("\n**개요**: %s", s)
|
||||
out += fmt.Sprintf("\n**概要**:%s", s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ func ValidMinSeverity(s string) bool {
|
||||
// 这样历史数据里已经存在的坏值不会让渠道整个读不出来。
|
||||
func (f Filter) Validate() error {
|
||||
if !ValidMinSeverity(f.MinSeverity) {
|
||||
return fmt.Errorf("올바르지 않은 최소 심각도입니다: %q. low / medium / high / critical 중에서 선택하거나, 비워 두면 제한하지 않습니다", f.MinSeverity)
|
||||
return fmt.Errorf("最低严重级别无效:%q。请从 low / medium / high / critical 中选择,留空表示不限制", f.MinSeverity)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+8
-8
@@ -43,7 +43,7 @@ func htmlBody(m Message, maxRunes int) string {
|
||||
b.WriteString(htmlItem(m.Items[0], true))
|
||||
}
|
||||
if m.HomeURL != "" {
|
||||
fmt.Fprintf(&b, `<p style="margin:16px 0 0;"><a href="%s" style="color:#1677ff;">플랫폼에서 전체 보기</a></p>`, htmlEscapeAttr(m.HomeURL))
|
||||
fmt.Fprintf(&b, `<p style="margin:16px 0 0;"><a href="%s" style="color:#1677ff;">在平台查看全部</a></p>`, htmlEscapeAttr(m.HomeURL))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
return TruncateHTML(b.String(), maxRunes)
|
||||
@@ -53,9 +53,9 @@ func htmlBody(m Message, maxRunes int) string {
|
||||
func htmlBatchIntro(m Message) string {
|
||||
var b strings.Builder
|
||||
if m.WindowMinutes > 0 {
|
||||
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">최근 %d분간 신규 취약점 %d건</h2>`, m.WindowMinutes, len(m.Items))
|
||||
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">最近 %d 分钟新增漏洞 %d 条</h2>`, m.WindowMinutes, len(m.Items))
|
||||
} else {
|
||||
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">신규 취약점 %d건</h2>`, len(m.Items))
|
||||
fmt.Fprintf(&b, `<h2 style="font-size:16px;margin:0 0 4px;">新增漏洞 %d 条</h2>`, len(m.Items))
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, it := range m.Items {
|
||||
@@ -103,20 +103,20 @@ func htmlItem(it Item, full bool) string {
|
||||
}
|
||||
|
||||
if it.IsStatusChange() {
|
||||
fmt.Fprintf(&b, `<div><b>상태 변경</b>: %s → %s</div>`,
|
||||
fmt.Fprintf(&b, `<div><b>状态变更</b>:%s → %s</div>`,
|
||||
htmlEscape(StatusLabel(it.FromStatus)), htmlEscape(StatusLabel(it.ToStatus)))
|
||||
}
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
fmt.Fprintf(&b, `<div><b>유형</b>: %s</div>`, htmlEscape(it.VulnClass))
|
||||
fmt.Fprintf(&b, `<div><b>类型</b>:%s</div>`, htmlEscape(it.VulnClass))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
fmt.Fprintf(&b, `<div><b>자산</b>: %s</div>`, htmlEscape(a))
|
||||
fmt.Fprintf(&b, `<div><b>资产</b>:%s</div>`, htmlEscape(a))
|
||||
}
|
||||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||||
fmt.Fprintf(&b, `<div><b>개요</b>: %s</div>`, htmlEscape(s))
|
||||
fmt.Fprintf(&b, `<div><b>概要</b>:%s</div>`, htmlEscape(s))
|
||||
}
|
||||
if it.DetailURL != "" {
|
||||
fmt.Fprintf(&b, `<div style="margin-top:6px;"><a href="%s" style="color:#1677ff;">상세 보기</a></div>`, htmlEscapeAttr(it.DetailURL))
|
||||
fmt.Fprintf(&b, `<div style="margin-top:6px;"><a href="%s" style="color:#1677ff;">查看详情</a></div>`, htmlEscapeAttr(it.DetailURL))
|
||||
}
|
||||
b.WriteString(`</div>`)
|
||||
return b.String()
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
// 이 파일은 F4 ③(이메일 본문·HTML 템플릿) 한국어화를 회귀로부터 지킨다.
|
||||
// html.go 의 라벨과 email.go 의 검증 오류가 중국어로 되돌아가면 잡아낸다.
|
||||
// 헬퍼 hasHan·hasHangul·assertKorean 은 notify_localized_test.go 에 있다(같은 패키지).
|
||||
// 헬퍼 hasHan·hasHangul·assertChinese 은 notify_localized_test.go 에 있다(같은 패키지).
|
||||
//
|
||||
// 데이터(제목·유형·자산·개요)는 전부 ASCII 로 둔다. 그래야 "출력 전체에 한자 0"
|
||||
// 이라는 단언이 콘텐츠가 아니라 골격 라벨의 회귀만 정확히 포착한다. 심각도·상태
|
||||
@@ -30,11 +30,11 @@ func TestHTMLItemLabelsLocalized(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
out := htmlBody(m, 0)
|
||||
if hasHan(out) {
|
||||
t.Errorf("이메일 본문에 중국어 한자가 남아 있습니다:\n%s", out)
|
||||
if hasHangul(out) {
|
||||
t.Errorf("이메일 본문에 한글이 남아 있습니다:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"상태 변경", "유형", "자산", "개요", "상세 보기", "플랫폼에서 전체 보기",
|
||||
"状态变更", "类型", "资产", "概要", "查看详情", "在平台查看全部",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("이메일 본문에 %q 라벨이 없습니다:\n%s", want, out)
|
||||
@@ -48,20 +48,20 @@ func TestHTMLBatchIntroLocalized(t *testing.T) {
|
||||
items := []Item{{Name: "a", Severity: "high"}, {Name: "b", Severity: "low"}}
|
||||
|
||||
withWindow := htmlBatchIntro(Message{Batch: true, WindowMinutes: 30, Items: items})
|
||||
assertKorean(t, "htmlBatchIntro(시간창)", withWindow)
|
||||
for _, want := range []string{"최근 30분간", "신규 취약점", "2건"} {
|
||||
assertChinese(t, "htmlBatchIntro(시간창)", withWindow)
|
||||
for _, want := range []string{"最近 30 分钟", "新增漏洞", "2 条"} {
|
||||
if !strings.Contains(withWindow, want) {
|
||||
t.Errorf("시간창 머리말에 %q 가 없습니다: %q", want, withWindow)
|
||||
}
|
||||
}
|
||||
|
||||
noWindow := htmlBatchIntro(Message{Batch: true, WindowMinutes: 0, Items: items})
|
||||
assertKorean(t, "htmlBatchIntro(시간창 없음)", noWindow)
|
||||
if !strings.Contains(noWindow, "신규 취약점 2건") {
|
||||
t.Errorf("시간창 없는 머리말이 %q 를 포함해야 합니다: %q", "신규 취약점 2건", noWindow)
|
||||
assertChinese(t, "htmlBatchIntro(시간창 없음)", noWindow)
|
||||
if !strings.Contains(noWindow, "新增漏洞 2 条") {
|
||||
t.Errorf("시간창 없는 머리말이 %q 를 포함해야 합니다: %q", "新增漏洞 2 条", noWindow)
|
||||
}
|
||||
if strings.Contains(noWindow, "분간") {
|
||||
t.Errorf("시간창이 없는데 '분간' 이 들어갔습니다: %q", noWindow)
|
||||
if strings.Contains(noWindow, "分钟") {
|
||||
t.Errorf("시간창이 없는데 '分钟' 이 들어갔습니다: %q", noWindow)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +75,9 @@ func TestEmailValidateLocalized(t *testing.T) {
|
||||
substr string
|
||||
}{
|
||||
{"서버 주소 누락", map[string]any{"port": float64(25), "from": "a@b.c", "to": []any{"d@e.f"}}, "SMTP"},
|
||||
{"포트 범위 벗어남", map[string]any{"host": "h"}, "포트"},
|
||||
{"발신자 누락", map[string]any{"host": "h", "port": float64(25), "to": []any{"d@e.f"}}, "발신자"},
|
||||
{"수신자 누락", map[string]any{"host": "h", "port": float64(25), "from": "a@b.c"}, "수신자"},
|
||||
{"포트 범위 벗어남", map[string]any{"host": "h"}, "端口"},
|
||||
{"발신자 누락", map[string]any{"host": "h", "port": float64(25), "to": []any{"d@e.f"}}, "发件人"},
|
||||
{"수신자 누락", map[string]any{"host": "h", "port": float64(25), "from": "a@b.c"}, "收件人"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -85,7 +85,7 @@ func TestEmailValidateLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("검증이 실패해야 합니다: %v", tc.cfg)
|
||||
}
|
||||
assertKorean(t, "Validate("+tc.name+")", err.Error())
|
||||
assertChinese(t, "Validate("+tc.name+")", err.Error())
|
||||
if !strings.Contains(err.Error(), tc.substr) {
|
||||
t.Errorf("오류 메시지에 %q 가 있어야 합니다, 받은 값 %q", tc.substr, err.Error())
|
||||
}
|
||||
|
||||
+13
-13
@@ -73,10 +73,10 @@ func blockInternalDial(_, address string, _ syscall.RawConn) error {
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil {
|
||||
return fmt.Errorf("대상 주소를 해석할 수 없습니다: %q", host)
|
||||
return fmt.Errorf("无法解析目标地址:%q", host)
|
||||
}
|
||||
if isBlockedDialIP(ip) {
|
||||
return fmt.Errorf("로컬 또는 링크 로컬 주소 %s 로는 전송하지 않습니다 (로컬 서비스로 보내야 하면 %s=1 로 설정하세요)", ip, AllowLocalTargetsEnv)
|
||||
return fmt.Errorf("不会向本地或链路本地地址 %s 发送 (如需发送到本地服务,请设置 %s=1)", ip, AllowLocalTargetsEnv)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -109,10 +109,10 @@ var httpClient = &http.Client{
|
||||
Transport: notifyTransport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return errors.New("리다이렉트가 너무 많습니다")
|
||||
return errors.New("重定向次数过多")
|
||||
}
|
||||
if len(via) > 0 && req.URL.Host != via[0].URL.Host {
|
||||
return fmt.Errorf("다른 호스트로의 리다이렉트는 허용하지 않습니다 (%s → %s)", via[0].URL.Host, req.URL.Host)
|
||||
return fmt.Errorf("不允许重定向到其他主机 (%s → %s)", via[0].URL.Host, req.URL.Host)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -135,7 +135,7 @@ func doJSON(ctx context.Context, method, url string, headers map[string]string,
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
// 序列化失败是本地 bug(配置字段类型不对),重试也不会变好。
|
||||
return nil, Permanent(fmt.Errorf("요청 본문을 만들지 못했습니다: %w", err))
|
||||
return nil, Permanent(fmt.Errorf("无法构造请求体:%w", err))
|
||||
}
|
||||
body = bytes.NewReader(raw)
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func doJSON(ctx context.Context, method, url string, headers map[string]string,
|
||||
if err != nil {
|
||||
// URL 非法——多半是用户把地址填错了,属于永久失败。
|
||||
// 这里同样不能透传 err:url.Parse 的错误文本里含完整地址。
|
||||
return nil, Permanent(fmt.Errorf("요청 주소가 올바르지 않습니다: %s", redactRequestTarget(url)))
|
||||
return nil, Permanent(fmt.Errorf("请求地址无效:%s", redactRequestTarget(url)))
|
||||
}
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
@@ -161,24 +161,24 @@ func doJSON(ctx context.Context, method, url string, headers map[string]string,
|
||||
// 不脱敏的话,凭据会顺着这条错误串流到四个地方:notification_deliveries
|
||||
// 的 last_error(明文落库)、投递历史接口的响应(**绕过渠道配置的掩码**)、
|
||||
// 服务端日志、以及测试发送接口回给前端的 502 文本。
|
||||
return nil, fmt.Errorf("요청에 실패했습니다: %s", redactTransportError(err))
|
||||
return nil, fmt.Errorf("请求失败:%s", redactTransportError(err))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, readErr := io.ReadAll(io.LimitReader(resp.Body, respBodyLimit))
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("응답을 읽지 못했습니다: %w", readErr)
|
||||
return nil, fmt.Errorf("无法读取响应:%w", readErr)
|
||||
}
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return raw, nil
|
||||
}
|
||||
// 429(限流)与 408(超时)值得重试;其余 4xx 是配置或权限问题,重试无意义。
|
||||
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusRequestTimeout {
|
||||
return nil, fmt.Errorf("상대 서버가 요청을 제한하거나 응답이 지연되었습니다 (HTTP %d): %s", resp.StatusCode, snippet(raw))
|
||||
return nil, fmt.Errorf("对端服务器限制了请求或响应延迟 (HTTP %d):%s", resp.StatusCode, snippet(raw))
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, fmt.Errorf("상대 서버에 오류가 발생했습니다 (HTTP %d): %s", resp.StatusCode, snippet(raw))
|
||||
return nil, fmt.Errorf("对端服务器发生错误 (HTTP %d):%s", resp.StatusCode, snippet(raw))
|
||||
}
|
||||
return nil, Permanent(fmt.Errorf("상대 서버가 요청을 거부했습니다 (HTTP %d): %s", resp.StatusCode, snippet(raw)))
|
||||
return nil, Permanent(fmt.Errorf("对端服务器拒绝了请求 (HTTP %d):%s", resp.StatusCode, snippet(raw)))
|
||||
}
|
||||
|
||||
// snippet 把响应体压成一行短文本,用于错误信息。响应里可能带换行与大量空白,
|
||||
@@ -205,7 +205,7 @@ func snippet(raw []byte) string {
|
||||
func redactRequestTarget(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return "(주소를 해석할 수 없음)"
|
||||
return "(无法解析地址)"
|
||||
}
|
||||
return u.Scheme + "://" + u.Host + "/…"
|
||||
}
|
||||
@@ -225,7 +225,7 @@ func redactTransportError(err error) string {
|
||||
if uerr.Err != nil {
|
||||
return fmt.Sprintf("%s %s: %s", uerr.Op, host, uerr.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s %s: 알 수 없는 오류", uerr.Op, host)
|
||||
return fmt.Sprintf("%s %s: 未知错误", uerr.Op, host)
|
||||
}
|
||||
// 非 *url.Error(如重定向策略返回的错误)也可能带地址,统一走脱敏。
|
||||
return redactURLsInText(err.Error())
|
||||
|
||||
+11
-11
@@ -72,10 +72,10 @@ func markdownText(s string, maxRunes int) string {
|
||||
// 出现了 `\(1\)` 这种可见的反斜杠。
|
||||
func markdownTitle(m Message) string {
|
||||
if m.Batch {
|
||||
return fmt.Sprintf("취약점 요약 · 총 %d건", len(m.Items))
|
||||
return fmt.Sprintf("漏洞汇总 · 共 %d 条", len(m.Items))
|
||||
}
|
||||
if len(m.Items) == 0 {
|
||||
return "취약점 알림"
|
||||
return "漏洞通知"
|
||||
}
|
||||
it := m.Items[0]
|
||||
return fmt.Sprintf("[%s] %s", SeverityLabel(it.Severity), OneLine(it.Title(), 0))
|
||||
@@ -103,7 +103,7 @@ func markdownBody(m Message, maxBytes int) (string, int) {
|
||||
|
||||
footer := ""
|
||||
if m.HomeURL != "" {
|
||||
footer = fmt.Sprintf("\n[플랫폼에서 전체 보기](%s)\n", m.HomeURL)
|
||||
footer = fmt.Sprintf("\n[在平台查看全部](%s)\n", m.HomeURL)
|
||||
}
|
||||
kept := packItemCount(m.Items, maxBytes, markdownReservedBytes, footer, byteSize, func(it Item, idx int) string {
|
||||
var b strings.Builder
|
||||
@@ -130,12 +130,12 @@ func markdownBody(m Message, maxBytes int) (string, int) {
|
||||
func markdownBatchIntro(m Message, items []Item, total int) string {
|
||||
var b strings.Builder
|
||||
if m.WindowMinutes > 0 {
|
||||
fmt.Fprintf(&b, "**최근 %d분간 신규 취약점 %d건**", m.WindowMinutes, total)
|
||||
fmt.Fprintf(&b, "**最近 %d 分钟新增漏洞 %d 条**", m.WindowMinutes, total)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "**신규 취약점 %d건**", total)
|
||||
fmt.Fprintf(&b, "**新增漏洞 %d 条**", total)
|
||||
}
|
||||
if extra := total - len(items); extra > 0 {
|
||||
fmt.Fprintf(&b, "(이 메시지에는 앞 %d건만 표시하며, 나머지 %d건은 다음 메시지에서 이어서 보냅니다)", len(items), extra)
|
||||
fmt.Fprintf(&b, "(本条仅显示前 %d 条,其余 %d 条将在下一条消息中继续发送)", len(items), extra)
|
||||
}
|
||||
// 按级别给出分布,让读者一眼看到有没有严重项。只统计**本条实际包含**的
|
||||
// 条目,保证「严重 3」和下面能数出来的条目一致。
|
||||
@@ -183,21 +183,21 @@ func writeItem(b *strings.Builder, it Item, prefix string, single bool) {
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
if it.IsStatusChange() {
|
||||
fmt.Fprintf(b, "**상태 변경**: %s → %s\n",
|
||||
fmt.Fprintf(b, "**状态变更**:%s → %s\n",
|
||||
markdownText(StatusLabel(it.FromStatus), 0), markdownText(StatusLabel(it.ToStatus), 0))
|
||||
}
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
fmt.Fprintf(b, "**유형**: %s\n", markdownText(it.VulnClass, 0))
|
||||
fmt.Fprintf(b, "**类型**:%s\n", markdownText(it.VulnClass, 0))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
fmt.Fprintf(b, "**자산**: %s\n", markdownText(a, 0))
|
||||
fmt.Fprintf(b, "**资产**:%s\n", markdownText(a, 0))
|
||||
}
|
||||
if it.Summary != "" {
|
||||
if s := markdownText(it.Summary, maxSummaryRunes); s != "" {
|
||||
fmt.Fprintf(b, "**개요**: %s\n", s)
|
||||
fmt.Fprintf(b, "**概要**:%s\n", s)
|
||||
}
|
||||
}
|
||||
if it.DetailURL != "" {
|
||||
fmt.Fprintf(b, "[상세 보기](%s)\n", it.DetailURL)
|
||||
fmt.Fprintf(b, "[查看详情](%s)\n", it.DetailURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,17 +27,17 @@ func hanFreeItems(n int) []Item {
|
||||
// 공유하는 feishu·html·telegram·webhook 이 함께 쓰는 markdownTitle 이 한국어인지
|
||||
// 검사한다. 하나라도 중국어로 되돌아가면 이 다섯 채널의 제목이 전부 혼재된다.
|
||||
func TestMarkdownTitleLocalized(t *testing.T) {
|
||||
// 다건(汇总): "취약점 요약 · 총 N건"
|
||||
// 다건(汇总): "漏洞汇总 · 共 N 条"
|
||||
got := markdownTitle(Message{Batch: true, Items: hanFreeItems(3)})
|
||||
assertKorean(t, "markdownTitle(batch)", got)
|
||||
if !strings.Contains(got, "취약점 요약") || !strings.Contains(got, "총 3건") {
|
||||
t.Errorf("다건 제목이 '취약점 요약 · 총 3건' 형태여야 합니다, 받은 값 %q", got)
|
||||
assertChinese(t, "markdownTitle(batch)", got)
|
||||
if !strings.Contains(got, "漏洞汇总") || !strings.Contains(got, "共 3 条") {
|
||||
t.Errorf("다건 제목이 '漏洞汇总 · 共 3 条' 형태여야 합니다, 받은 값 %q", got)
|
||||
}
|
||||
// 항목 없음: "취약점 알림"
|
||||
// 항목 없음: "漏洞通知"
|
||||
empty := markdownTitle(Message{})
|
||||
assertKorean(t, "markdownTitle(empty)", empty)
|
||||
if empty != "취약점 알림" {
|
||||
t.Errorf("빈 메시지 제목은 '취약점 알림' 이어야 합니다, 받은 값 %q", empty)
|
||||
assertChinese(t, "markdownTitle(empty)", empty)
|
||||
if empty != "漏洞通知" {
|
||||
t.Errorf("빈 메시지 제목은 '漏洞通知' 이어야 합니다, 받은 값 %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,28 +49,28 @@ func TestMarkdownBatchIntroLocalized(t *testing.T) {
|
||||
|
||||
// 시간창 있음: "최근 N분간 신규 취약점 N건"
|
||||
win := markdownBatchIntro(Message{Batch: true, WindowMinutes: 30}, items, 3)
|
||||
if hasHan(win) {
|
||||
t.Errorf("시간창 머리말에 중국어 한자가 남았습니다: %q", win)
|
||||
if hasHangul(win) {
|
||||
t.Errorf("시간창 머리말에 한글이 남았습니다: %q", win)
|
||||
}
|
||||
if !strings.Contains(win, "최근 30분간") || !strings.Contains(win, "신규 취약점 3건") {
|
||||
t.Errorf("시간창 머리말이 '최근 30분간 신규 취약점 3건' 형태여야 합니다, 받은 값 %q", win)
|
||||
if !strings.Contains(win, "最近 30 分钟") || !strings.Contains(win, "新增漏洞 3 条") {
|
||||
t.Errorf("시간창 머리말이 '最近 30 分钟新增漏洞 3 条' 형태여야 합니다, 받은 값 %q", win)
|
||||
}
|
||||
|
||||
// 시간창 없음: "신규 취약점 N건"(분간 표기 없음)
|
||||
// 시간창 없음: "新增漏洞 N条"(분간 표기 없음)
|
||||
noWin := markdownBatchIntro(Message{Batch: true}, items, 3)
|
||||
if hasHan(noWin) {
|
||||
t.Errorf("머리말에 중국어 한자가 남았습니다: %q", noWin)
|
||||
if hasHangul(noWin) {
|
||||
t.Errorf("머리말에 한글이 남았습니다: %q", noWin)
|
||||
}
|
||||
if !strings.Contains(noWin, "신규 취약점 3건") || strings.Contains(noWin, "분간") {
|
||||
t.Errorf("시간창 없는 머리말은 '신규 취약점 3건'(분간 표기 없음)이어야 합니다, 받은 값 %q", noWin)
|
||||
if !strings.Contains(noWin, "新增漏洞 3 条") || strings.Contains(noWin, "分钟") {
|
||||
t.Errorf("시간창 없는 머리말은 '新增漏洞 3 条'(分钟 표기 없음)이어야 합니다, 받은 값 %q", noWin)
|
||||
}
|
||||
|
||||
// 일부만 담겼을 때: "(이 메시지에는 앞 N건만 … 나머지 N건은 다음 메시지에서 …)"
|
||||
// 일부만 담겼을 때: "(本条仅显示前 N 条, 其余 N 条将在下一条消息中继续发送)"
|
||||
trunc := markdownBatchIntro(Message{Batch: true, WindowMinutes: 30}, items, 5)
|
||||
if hasHan(trunc) {
|
||||
t.Errorf("초과 안내에 중국어 한자가 남았습니다: %q", trunc)
|
||||
if hasHangul(trunc) {
|
||||
t.Errorf("초과 안내에 한글이 남았습니다: %q", trunc)
|
||||
}
|
||||
for _, want := range []string{"앞 3건만", "나머지 2건", "다음 메시지에서"} {
|
||||
for _, want := range []string{"前 3 条", "其余 2 条", "下一条消息"} {
|
||||
if !strings.Contains(trunc, want) {
|
||||
t.Errorf("초과 안내에 %q 가 있어야 합니다, 받은 값 %q", want, trunc)
|
||||
}
|
||||
@@ -94,13 +94,13 @@ func TestWriteItemLabelsLocalized(t *testing.T) {
|
||||
writeItem(&b, it, "", true)
|
||||
got := b.String()
|
||||
|
||||
assertKorean(t, "writeItem(single)", got)
|
||||
assertChinese(t, "writeItem(single)", got)
|
||||
for _, want := range []string{
|
||||
"**상태 변경**: 처리 대기 → 수정됨",
|
||||
"**유형**: SQLi",
|
||||
"**자산**: a.example.com",
|
||||
"**개요**: SQL injection via q param",
|
||||
"[상세 보기](https://platform.example/finding/1)",
|
||||
"**状态变更**:待处理 → 已修复",
|
||||
"**类型**:SQLi",
|
||||
"**资产**:a.example.com",
|
||||
"**概要**:SQL injection via q param",
|
||||
"[查看详情](https://platform.example/finding/1)",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("단건 렌더에 %q 가 있어야 합니다:\n%s", want, got)
|
||||
@@ -116,7 +116,7 @@ func TestMarkdownBodyFooterLocalized(t *testing.T) {
|
||||
if kept != 2 {
|
||||
t.Fatalf("한도 없음(0)이면 2건 모두 담겨야 합니다, 받은 값 %d", kept)
|
||||
}
|
||||
if !strings.Contains(body, "[플랫폼에서 전체 보기](https://platform.example)") {
|
||||
t.Errorf("본문 끝에 '플랫폼에서 전체 보기' 링크가 있어야 합니다:\n%s", body)
|
||||
if !strings.Contains(body, "[在平台查看全部](https://platform.example)") {
|
||||
t.Errorf("본문 끝에 '在平台查看全部' 링크가 있어야 합니다:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -70,9 +70,9 @@ type ErrDestinationChangedWithoutCredentials struct {
|
||||
}
|
||||
|
||||
func (e *ErrDestinationChangedWithoutCredentials) Error() string {
|
||||
return "대상 주소(" + strings.Join(e.Changed, ", ") + ")가 변경되었습니다. 자격 증명 필드(" +
|
||||
strings.Join(e.Missing, ", ") + ")를 함께 다시 입력하세요: 새 값을 넣거나, 비워 두어 더는 자격 증명이 필요 없음을 표시하세요. " +
|
||||
"기존 자격 증명은 이전 주소에만 유효하므로, 그대로 사용하면 새 주소에 넘겨주는 셈입니다."
|
||||
return "目标地址(" + strings.Join(e.Changed, ", ") + ")已更改。请同时重新填写凭据字段(" +
|
||||
strings.Join(e.Missing, ", ") + "):填入新值,或留空以表示不再需要凭据。" +
|
||||
"现有凭据仅对旧地址有效,继续沿用等于将其交给新地址。"
|
||||
}
|
||||
|
||||
// PrepareConfigUpdate 合并渠道配置,并处理「目标地址变更」这一安全敏感情况。
|
||||
@@ -101,7 +101,7 @@ func (e *ErrDestinationChangedWithoutCredentials) Error() string {
|
||||
func PrepareConfigUpdate(kind string, stored, incoming map[string]any) (map[string]any, error) {
|
||||
channel, ok := Get(kind)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("등록되지 않은 채널 유형입니다: %q", kind)
|
||||
return nil, fmt.Errorf("未注册的渠道类型:%q", kind)
|
||||
}
|
||||
secrets := channel.SecretKeys()
|
||||
destinations := channel.DestinationKeys()
|
||||
@@ -174,7 +174,7 @@ func rejectMaskedInContainers(incoming map[string]any, secretKeys []string) erro
|
||||
continue
|
||||
}
|
||||
if strings.Contains(string(encoded), MaskedPrefix) {
|
||||
return fmt.Errorf("%s 필드의 내용에 마스킹 표시(%q)가 들어 있습니다. 이 필드는 전체를 비워 두어 기존 값을 유지하거나 전체를 새 값으로 제출해야 하며, 구조체 내부에 마스킹 표시를 끼워 넣을 수 없습니다",
|
||||
return fmt.Errorf("%s 字段的内容中带有掩码标记(%q)。该字段必须整体留空以保留原值,或整体提交新值,不能在结构体内部插入掩码标记",
|
||||
key, MaskedPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
// F4 ⑥: notify/mask.go·filter.go 의 사용자 노출 문구(알림 채널 설정을 저장·갱신할 때
|
||||
// server/notify_api.go 가 writeErr(400) 로 그대로 돌려주는 검증 오류)가 한국어이고
|
||||
// 한자가 없음을 핀 고정한다. channel.go 는 사용자 노출 문구가 없어(전부 주석) 대상이
|
||||
// 아니다. hasHan / hasHangul / assertKorean 은 notify_localized_test.go 에 정의돼 있다.
|
||||
// 아니다. hasHan / hasHangul / assertChinese 은 notify_localized_test.go 에 정의돼 있다.
|
||||
|
||||
// TestFilterValidateLocalized 는 min_severity 를 잘못 넣었을 때의 검증 오류를 검사한다.
|
||||
// 이 오류는 저장·갱신 경로(server/notify_api.go:264·369)에서 400 으로 노출된다.
|
||||
@@ -18,7 +18,7 @@ func TestFilterValidateLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("잘못된 최소 심각도인데 오류가 없습니다")
|
||||
}
|
||||
assertKorean(t, "Filter.Validate", err.Error())
|
||||
assertChinese(t, "Filter.Validate", err.Error())
|
||||
// low/medium/high/critical 는 설정 enum 이라 원문 그대로 남아야 한다.
|
||||
for _, tok := range []string{"low", "medium", "high", "critical"} {
|
||||
if !strings.Contains(err.Error(), tok) {
|
||||
@@ -41,7 +41,7 @@ func TestPrepareConfigUpdateUnknownKindLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("미등록 채널 유형인데 오류가 없습니다")
|
||||
}
|
||||
assertKorean(t, "PrepareConfigUpdate unknown kind", err.Error())
|
||||
assertChinese(t, "PrepareConfigUpdate unknown kind", err.Error())
|
||||
}
|
||||
|
||||
// TestDestinationChangedErrorLocalized 는 실제 경로로 ErrDestinationChangedWithoutCredentials
|
||||
@@ -62,7 +62,7 @@ func TestDestinationChangedErrorLocalized(t *testing.T) {
|
||||
if !errors.As(err, &de) {
|
||||
t.Fatalf("예상한 오류 유형이 아닙니다: %T", err)
|
||||
}
|
||||
assertKorean(t, "ErrDestinationChangedWithoutCredentials", err.Error())
|
||||
assertChinese(t, "ErrDestinationChangedWithoutCredentials", err.Error())
|
||||
// 바뀐 대상 키·누락된 자격 증명 키 이름은 설정 필드명이라 메시지에 그대로 들어가야 한다.
|
||||
if !strings.Contains(err.Error(), "url") || !strings.Contains(err.Error(), "headers") {
|
||||
t.Errorf("필드 키 이름이 누락됐습니다: %q", err.Error())
|
||||
@@ -79,7 +79,7 @@ func TestRejectMaskedInContainersLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("구조체 내부에 마스킹 센티넬을 끼워 넣었는데 오류가 없습니다")
|
||||
}
|
||||
assertKorean(t, "rejectMaskedInContainers", err.Error())
|
||||
assertChinese(t, "rejectMaskedInContainers", err.Error())
|
||||
// 센티넬 리터럴(__masked__)은 운영자가 어느 값이 문제인지 알 수 있게 메시지에 표시된다.
|
||||
if !strings.Contains(err.Error(), MaskedPrefix) {
|
||||
t.Errorf("마스킹 센티넬이 메시지에 없습니다: %q", err.Error())
|
||||
|
||||
+13
-13
@@ -47,13 +47,13 @@ func SeverityRank(severity string) int { return severityRank[severity] }
|
||||
func SeverityLabel(severity string) string {
|
||||
switch severity {
|
||||
case "critical":
|
||||
return "🔴 심각"
|
||||
return "🔴 严重"
|
||||
case "high":
|
||||
return "🟠 높음"
|
||||
return "🟠 高危"
|
||||
case "medium":
|
||||
return "🟡 중간"
|
||||
return "🟡 中危"
|
||||
case "low":
|
||||
return "🔵 낮음"
|
||||
return "🔵 低危"
|
||||
default:
|
||||
return severity
|
||||
}
|
||||
@@ -63,23 +63,23 @@ func SeverityLabel(severity string) string {
|
||||
func StatusLabel(status string) string {
|
||||
switch status {
|
||||
case "pending":
|
||||
return "처리 대기"
|
||||
return "待处理"
|
||||
case "in_progress":
|
||||
return "처리 중"
|
||||
return "处理中"
|
||||
case "confirmed":
|
||||
return "확인됨"
|
||||
return "已确认"
|
||||
case "resolved":
|
||||
return "처리됨"
|
||||
return "已处置"
|
||||
case "fixed":
|
||||
return "수정됨"
|
||||
return "已修复"
|
||||
case "false_positive":
|
||||
return "오탐"
|
||||
return "误报"
|
||||
case "ignored":
|
||||
return "무시"
|
||||
return "已忽略"
|
||||
case "duplicate":
|
||||
return "중복"
|
||||
return "重复"
|
||||
case "risk_accepted":
|
||||
return "위험 수용"
|
||||
return "风险接受"
|
||||
default:
|
||||
return status
|
||||
}
|
||||
|
||||
@@ -26,14 +26,14 @@ func hasHangul(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// assertKorean 은 라벨이 한글을 포함하고 중국어 한자는 없음을 단언한다.
|
||||
func assertKorean(t *testing.T, where, got string) {
|
||||
// assertChinese 은 라벨이 한자를 포함하고 한글은 없음을 단언한다.
|
||||
func assertChinese(t *testing.T, where, got string) {
|
||||
t.Helper()
|
||||
if hasHan(got) {
|
||||
t.Errorf("%s: 중국어 한자가 남아 있습니다: %q", where, got)
|
||||
if hasHangul(got) {
|
||||
t.Errorf("%s: 한글이 남아 있습니다: %q", where, got)
|
||||
}
|
||||
if !hasHangul(got) {
|
||||
t.Errorf("%s: 한글이 없습니다: %q", where, got)
|
||||
if !hasHan(got) {
|
||||
t.Errorf("%s: 한자가 없습니다: %q", where, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,14 +43,14 @@ func assertKorean(t *testing.T, where, got string) {
|
||||
func TestSeverityLabelLocalized(t *testing.T) {
|
||||
// 중국어 라벨(严重/高危/中危/低危) → 한국어(심각/높음/중간/낮음), UI status.severity 정합.
|
||||
want := map[string]string{
|
||||
"critical": "심각",
|
||||
"high": "높음",
|
||||
"medium": "중간",
|
||||
"low": "낮음",
|
||||
"critical": "严重",
|
||||
"high": "高危",
|
||||
"medium": "中危",
|
||||
"low": "低危",
|
||||
}
|
||||
for sev, label := range want {
|
||||
got := SeverityLabel(sev)
|
||||
assertKorean(t, "SeverityLabel("+sev+")", got)
|
||||
assertChinese(t, "SeverityLabel("+sev+")", got)
|
||||
if !strings.Contains(got, label) {
|
||||
t.Errorf("SeverityLabel(%q)=%q, %q 를 포함해야 합니다", sev, got, label)
|
||||
}
|
||||
@@ -65,19 +65,19 @@ func TestSeverityLabelLocalized(t *testing.T) {
|
||||
// UI status.finding 네임스페이스(B4a)와 동일 표기여야 상태 변경 알림과 화면이 어긋나지 않는다.
|
||||
func TestStatusLabelLocalized(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"pending": "처리 대기",
|
||||
"in_progress": "처리 중",
|
||||
"confirmed": "확인됨",
|
||||
"resolved": "처리됨",
|
||||
"fixed": "수정됨",
|
||||
"false_positive": "오탐",
|
||||
"ignored": "무시",
|
||||
"duplicate": "중복",
|
||||
"risk_accepted": "위험 수용",
|
||||
"pending": "待处理",
|
||||
"in_progress": "处理中",
|
||||
"confirmed": "已确认",
|
||||
"resolved": "已处置",
|
||||
"fixed": "已修复",
|
||||
"false_positive": "误报",
|
||||
"ignored": "已忽略",
|
||||
"duplicate": "重复",
|
||||
"risk_accepted": "风险接受",
|
||||
}
|
||||
for status, label := range want {
|
||||
got := StatusLabel(status)
|
||||
assertKorean(t, "StatusLabel("+status+")", got)
|
||||
assertChinese(t, "StatusLabel("+status+")", got)
|
||||
if got != label {
|
||||
t.Errorf("StatusLabel(%q)=%q, %q 를 기대했습니다", status, got, label)
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func TestStatusLabelLocalized(t *testing.T) {
|
||||
// 한국어 자리표시자(이름 없는 취약점)인지 검사한다. 절대 빈 제목을 내보내지 않는다.
|
||||
func TestItemTitlePlaceholderLocalized(t *testing.T) {
|
||||
got := Item{}.Title()
|
||||
assertKorean(t, "Item{}.Title()", got)
|
||||
assertChinese(t, "Item{}.Title()", got)
|
||||
// 이름이 있으면 그 이름을, 유형만 있으면 유형을 우선한다(대체 로직 미회귀 확인).
|
||||
if n := (Item{Name: "로그인 SQLi"}).Title(); n != "로그인 SQLi" {
|
||||
t.Errorf("이름 우선 로직이 깨졌습니다, 받은 값 %q", n)
|
||||
@@ -111,10 +111,10 @@ func TestAssetLineLocalized(t *testing.T) {
|
||||
}
|
||||
// 상한 초과: 앞 limit 개 + 한국어 "등 N개"(N 은 전체 개수).
|
||||
got := assetLine([]string{"a", "b", "c", "d", "e"}, 2)
|
||||
if hasHan(got) {
|
||||
t.Errorf("자산 나열에 중국어 한자가 남았습니다: %q", got)
|
||||
if hasHangul(got) {
|
||||
t.Errorf("자산 나열에 한글이 남았습니다: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "등 5개") {
|
||||
t.Errorf("전체 개수 5 를 '등 5개'로 표기해야 합니다, 받은 값 %q", got)
|
||||
if !strings.Contains(got, "等 5 个") {
|
||||
t.Errorf("전체 개수 5 를 '等 5 个'로 표기해야 합니다, 받은 값 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@ func TestMarkdownBodyPacksWholeItemsWithinByteLimit(t *testing.T) {
|
||||
}
|
||||
// 头部必须如实说明本条只包含多少条、其余有多少条——否则读者会把头部
|
||||
// 那个数字当成全部。
|
||||
if !strings.Contains(body, "나머지") || !strings.Contains(body, "다음 메시지에서") {
|
||||
if !strings.Contains(body, "其余") || !strings.Contains(body, "下一条消息") {
|
||||
t.Fatalf("头部应说明还有多少条未包含在本条里:\n%s", body[:minInt(400, len(body))])
|
||||
}
|
||||
// 只应包含前 kept 条。
|
||||
@@ -84,7 +84,7 @@ func TestTelegramPackingUsesRuneBudget(t *testing.T) {
|
||||
if kept <= 0 || kept >= len(m.Items) {
|
||||
t.Fatalf("应只装下一部分,得到 %d", kept)
|
||||
}
|
||||
if !strings.Contains(text, "다음 메시지") {
|
||||
if !strings.Contains(text, "下一条消息") {
|
||||
t.Fatalf("应说明还有余量未包含:\n%.300s", text)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -133,7 +133,7 @@ func assetLine(assets []string, limit int) string {
|
||||
if limit <= 0 || len(assets) <= limit {
|
||||
return strings.Join(assets, ", ")
|
||||
}
|
||||
return strings.Join(assets[:limit], ", ") + " 등 " + itoa(len(assets)) + "개"
|
||||
return strings.Join(assets[:limit], ", ") + " 等 " + itoa(len(assets)) + " 个"
|
||||
}
|
||||
|
||||
// itoa 是 strconv.Itoa 的短别名,仅用于拼接展示文本,避免到处 import strconv。
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestAssetLineOmitsExcess(t *testing.T) {
|
||||
}
|
||||
// 超出上限时必须标注总数,否则读者不知道还有多少资产没列出来。
|
||||
got := assetLine([]string{"a", "b", "c", "d", "e"}, 2)
|
||||
if !strings.Contains(got, "등 5개") {
|
||||
if !strings.Contains(got, "等 5 个") {
|
||||
t.Fatalf("应标注总数 5,得到 %q", got)
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func TestSeverityAndStatusLabels(t *testing.T) {
|
||||
if !AtLeast("critical", "") {
|
||||
t.Fatal("空门槛应放行")
|
||||
}
|
||||
if got := StatusLabel("fixed"); got != "수정됨" {
|
||||
if got := StatusLabel("fixed"); got != "已修复" {
|
||||
t.Fatalf("未知状态映射,得到 %q", got)
|
||||
}
|
||||
// 未知状态原样回显,不臆造标签。
|
||||
|
||||
+16
-16
@@ -34,14 +34,14 @@ func (telegramChannel) DestinationKeys() []string { return []string{"base_url"}
|
||||
|
||||
func (telegramChannel) Validate(cfg map[string]any) error {
|
||||
if cfgString(cfg, "bot_token") == "" {
|
||||
return errors.New("Bot Token이 없습니다")
|
||||
return errors.New("缺少 Bot Token")
|
||||
}
|
||||
if cfgString(cfg, "chat_id") == "" {
|
||||
return errors.New("Chat ID가 없습니다")
|
||||
return errors.New("缺少 Chat ID")
|
||||
}
|
||||
if base := cfgString(cfg, "base_url"); base != "" {
|
||||
if err := validateHTTPURL(base); err != nil {
|
||||
return fmt.Errorf("API 주소가 올바르지 않습니다: %w", err)
|
||||
return fmt.Errorf("API 地址无效:%w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -72,7 +72,7 @@ func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message
|
||||
Description string `json:"description"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return 0, fmt.Errorf("Telegram 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
|
||||
return 0, fmt.Errorf("无法解析 Telegram 响应:%w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.OK {
|
||||
return kept, nil
|
||||
@@ -80,9 +80,9 @@ func (c telegramChannel) Send(ctx context.Context, cfg map[string]any, m Message
|
||||
// 429 是限流,退避后重试有效;其余(400 参数错、401 token 错、403 被拉黑、
|
||||
// 404 chat 不存在)都是配置问题,重试不会自愈。
|
||||
if res.ErrorCode == 429 {
|
||||
return 0, fmt.Errorf("Telegram 요청 제한에 걸렸습니다: %s", res.Description)
|
||||
return 0, fmt.Errorf("Telegram 请求受限:%s", res.Description)
|
||||
}
|
||||
return 0, Permanent(fmt.Errorf("Telegram 응답 오류 %d: %s", res.ErrorCode, res.Description))
|
||||
return 0, Permanent(fmt.Errorf("Telegram 响应错误 %d:%s", res.ErrorCode, res.Description))
|
||||
}
|
||||
|
||||
// telegramEndpoint 拼出 sendMessage 地址。base_url 留空时用官方 API,
|
||||
@@ -98,7 +98,7 @@ func telegramEndpoint(cfg map[string]any) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
// 不透传 err:地址里含 Bot Token,且此时连 addr 都不该回显。
|
||||
return "", fmt.Errorf("API 주소를 조합하지 못했습니다 (API 주소: %s)", redactRequestTarget(base))
|
||||
return "", fmt.Errorf("无法拼接 API 地址 (API 地址: %s)", redactRequestTarget(base))
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func telegramHTML(m Message) (string, int) {
|
||||
// Telegram 的上限是**字符数**,所以打包也按字符计量(runeSize)。
|
||||
footer := ""
|
||||
if m.HomeURL != "" {
|
||||
footer = fmt.Sprintf("\n\n<a href=\"%s\">플랫폼에서 전체 보기</a>", telegramEscapeAttr(m.HomeURL))
|
||||
footer = fmt.Sprintf("\n\n<a href=\"%s\">在平台查看全部</a>", telegramEscapeAttr(m.HomeURL))
|
||||
}
|
||||
kept := packItemCount(m.Items, telegramTextLimit, telegramReservedRunes, footer, runeSize, func(it Item, idx int) string {
|
||||
return telegramBatchLine(it, idx+1)
|
||||
@@ -130,20 +130,20 @@ func telegramHTML(m Message) (string, int) {
|
||||
}
|
||||
it := m.Items[0]
|
||||
if it.IsStatusChange() {
|
||||
b.WriteString(fmt.Sprintf("\n<b>상태 변경</b>: %s → %s",
|
||||
b.WriteString(fmt.Sprintf("\n<b>状态变更</b>:%s → %s",
|
||||
telegramEscape(StatusLabel(it.FromStatus)), telegramEscape(StatusLabel(it.ToStatus))))
|
||||
}
|
||||
if it.VulnClass != "" && it.VulnClass != it.Title() {
|
||||
b.WriteString("\n<b>유형</b>: " + telegramEscape(it.VulnClass))
|
||||
b.WriteString("\n<b>类型</b>:" + telegramEscape(it.VulnClass))
|
||||
}
|
||||
if a := assetLine(it.Assets, maxAssetsShown); a != "" {
|
||||
b.WriteString("\n<b>자산</b>: " + telegramEscape(a))
|
||||
b.WriteString("\n<b>资产</b>:" + telegramEscape(a))
|
||||
}
|
||||
if s := OneLine(it.Summary, maxSummaryRunes); s != "" {
|
||||
b.WriteString("\n<b>개요</b>: " + telegramEscape(s))
|
||||
b.WriteString("\n<b>概要</b>:" + telegramEscape(s))
|
||||
}
|
||||
if it.DetailURL != "" {
|
||||
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">상세 보기</a>", telegramEscapeAttr(it.DetailURL)))
|
||||
b.WriteString(fmt.Sprintf("\n\n<a href=\"%s\">查看详情</a>", telegramEscapeAttr(it.DetailURL)))
|
||||
}
|
||||
return TruncateHTML(b.String(), telegramTextLimit), 1
|
||||
}
|
||||
@@ -162,12 +162,12 @@ func telegramBatchLine(it Item, idx int) string {
|
||||
// telegramBatchTitle 渲染汇总消息的标题行。条数用的是**本条实际包含**的条数,
|
||||
// 而不是本批总数——否则读者会以为消息头写的数字就是全部。
|
||||
func telegramBatchTitle(m Message, items []Item, total int) string {
|
||||
title := fmt.Sprintf("취약점 요약 · 총 %d건", total)
|
||||
title := fmt.Sprintf("漏洞汇总 · 共 %d 条", total)
|
||||
if extra := total - len(items); extra > 0 {
|
||||
title += fmt.Sprintf(" (앞 %d건만 표시, 나머지 %d건은 다음 메시지에서 이어집니다)", len(items), extra)
|
||||
title += fmt.Sprintf(" (仅显示前 %d 条,其余 %d 条将在下一条消息中继续)", len(items), extra)
|
||||
}
|
||||
if m.WindowMinutes > 0 {
|
||||
title = fmt.Sprintf("최근 %d분간 · %s", m.WindowMinutes, title)
|
||||
title = fmt.Sprintf("最近 %d 分钟 · %s", m.WindowMinutes, title)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
+7
-7
@@ -83,17 +83,17 @@ type webhookItem struct {
|
||||
func (webhookChannel) Validate(cfg map[string]any) error {
|
||||
raw := cfgString(cfg, "url")
|
||||
if raw == "" {
|
||||
return errors.New("대상 URL이 없습니다")
|
||||
return errors.New("缺少目标 URL")
|
||||
}
|
||||
if err := validateHTTPURL(raw); err != nil {
|
||||
return fmt.Errorf("대상 URL이 올바르지 않습니다: %w", err)
|
||||
return fmt.Errorf("目标 URL 无效:%w", err)
|
||||
}
|
||||
if m := strings.ToUpper(cfgString(cfg, "method")); m != "" && m != http.MethodGet && m != http.MethodPost && m != http.MethodPut && m != http.MethodPatch {
|
||||
return fmt.Errorf("지원하지 않는 메서드입니다: %s (GET/POST/PUT/PATCH 중에서 선택하세요)", m)
|
||||
return fmt.Errorf("不支持的方法:%s (请从 GET/POST/PUT/PATCH 中选择)", m)
|
||||
}
|
||||
if tpl := cfgString(cfg, "body_template"); tpl != "" {
|
||||
if _, err := parseWebhookTemplate(tpl); err != nil {
|
||||
return fmt.Errorf("요청 본문 템플릿 문법이 올바르지 않습니다: %w", err)
|
||||
return fmt.Errorf("请求体模板语法无效:%w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -119,7 +119,7 @@ func (c webhookChannel) Send(ctx context.Context, cfg map[string]any, m Message)
|
||||
// 模板渲染出的是字符串形式的 JSON,这里转成 json.RawMessage 原样发出,
|
||||
// 避免二次转义把用户精心构造的结构套进一个 JSON 字符串里。
|
||||
if !json.Valid([]byte(body)) {
|
||||
return 0, Permanent(errors.New("요청 본문 템플릿의 렌더링 결과가 올바른 JSON이 아닙니다"))
|
||||
return 0, Permanent(errors.New("请求体模板的渲染结果不是合法的 JSON"))
|
||||
}
|
||||
payload = json.RawMessage(body)
|
||||
}
|
||||
@@ -147,11 +147,11 @@ func renderWebhookBody(tpl string, m Message) (string, error) {
|
||||
}
|
||||
t, err := parseWebhookTemplate(tpl)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("요청 본문 템플릿 문법이 올바르지 않습니다: %w", err)
|
||||
return "", fmt.Errorf("请求体模板语法无效:%w", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, newWebhookTemplateData(m)); err != nil {
|
||||
return "", fmt.Errorf("요청 본문 템플릿을 렌더링하지 못했습니다: %w", err)
|
||||
return "", fmt.Errorf("无法渲染请求体模板:%w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// 이 파일은 F4 ④⑤(telegram·webhook·공용 HTTP 전송 계층) 한국어화를 회귀로부터
|
||||
// 지킨다. telegram.go 의 메시지 라벨·배치 제목, webhook.go 의 설정 검증 오류,
|
||||
// http.go 의 전송 오류, 그리고 다섯 채널이 공유하는 validateHTTPURL 이 중국어로
|
||||
// 되돌아가면 잡아낸다. 헬퍼 hasHan·hasHangul·assertKorean 은
|
||||
// 되돌아가면 잡아낸다. 헬퍼 hasHan·hasHangul·assertChinese 은
|
||||
// notify_localized_test.go 에 있다(같은 패키지).
|
||||
//
|
||||
// 데이터(제목·유형·자산·개요)는 전부 ASCII 로 둔다. 그래야 "출력 전체에 한자 0"
|
||||
@@ -39,10 +39,10 @@ func TestTelegramItemLabelsLocalized(t *testing.T) {
|
||||
if kept != 1 {
|
||||
t.Fatalf("단건 메시지는 1 을 보고해야 합니다, 받은 값 %d", kept)
|
||||
}
|
||||
if hasHan(out) {
|
||||
t.Errorf("Telegram 본문에 중국어 한자가 남아 있습니다:\n%s", out)
|
||||
if hasHangul(out) {
|
||||
t.Errorf("Telegram 본문에 한글이 남아 있습니다:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{"상태 변경", "유형", "자산", "개요", "상세 보기"} {
|
||||
for _, want := range []string{"状态变更", "类型", "资产", "概要", "查看详情"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("Telegram 본문에 %q 라벨이 없습니다:\n%s", want, out)
|
||||
}
|
||||
@@ -57,8 +57,8 @@ func TestTelegramBatchTitleLocalized(t *testing.T) {
|
||||
|
||||
// 넘침 있음 + 시간창: 요약·총건수·앞 N건·나머지·다음 메시지·최근 N분간.
|
||||
over := telegramBatchTitle(Message{WindowMinutes: 30}, items, 5)
|
||||
assertKorean(t, "telegramBatchTitle(넘침)", over)
|
||||
for _, want := range []string{"취약점 요약 · 총 5건", "앞 2건만 표시", "나머지 3건", "다음 메시지", "최근 30분간"} {
|
||||
assertChinese(t, "telegramBatchTitle(넘침)", over)
|
||||
for _, want := range []string{"漏洞汇总 · 共 5 条", "前 2 条", "其余 3 条", "下一条消息", "最近 30 分钟"} {
|
||||
if !strings.Contains(over, want) {
|
||||
t.Errorf("넘침 제목에 %q 가 없습니다: %q", want, over)
|
||||
}
|
||||
@@ -66,12 +66,12 @@ func TestTelegramBatchTitleLocalized(t *testing.T) {
|
||||
|
||||
// 넘침 없음 + 시간창 없음: 다음 메시지 안내가 없어야 한다.
|
||||
full := telegramBatchTitle(Message{}, items, 2)
|
||||
assertKorean(t, "telegramBatchTitle(전량)", full)
|
||||
if !strings.Contains(full, "취약점 요약 · 총 2건") {
|
||||
t.Errorf("전량 제목에 '취약점 요약 · 총 2건' 이 있어야 합니다: %q", full)
|
||||
assertChinese(t, "telegramBatchTitle(전량)", full)
|
||||
if !strings.Contains(full, "漏洞汇总 · 共 2 条") {
|
||||
t.Errorf("전량 제목에 '漏洞汇总 · 共 2 条' 이 있어야 합니다: %q", full)
|
||||
}
|
||||
if strings.Contains(full, "다음 메시지") {
|
||||
t.Errorf("넘치지 않았는데 '다음 메시지' 안내가 들어갔습니다: %q", full)
|
||||
if strings.Contains(full, "下一条消息") {
|
||||
t.Errorf("넘치지 않았는데 '下一条消息' 안내가 들어갔습니다: %q", full)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func TestTelegramValidateLocalized(t *testing.T) {
|
||||
if err := (telegramChannel{}).Validate(map[string]any{}); err == nil {
|
||||
t.Fatal("bot_token 누락은 검증 실패여야 합니다")
|
||||
} else {
|
||||
assertKorean(t, "telegram Validate(bot_token)", err.Error())
|
||||
assertChinese(t, "telegram Validate(bot_token)", err.Error())
|
||||
if !strings.Contains(err.Error(), "Bot Token") {
|
||||
t.Errorf("Bot Token 누락을 알려줘야 합니다: %q", err.Error())
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func TestTelegramValidateLocalized(t *testing.T) {
|
||||
if err := (telegramChannel{}).Validate(map[string]any{"bot_token": "t"}); err == nil {
|
||||
t.Fatal("chat_id 누락은 검증 실패여야 합니다")
|
||||
} else {
|
||||
assertKorean(t, "telegram Validate(chat_id)", err.Error())
|
||||
assertChinese(t, "telegram Validate(chat_id)", err.Error())
|
||||
if !strings.Contains(err.Error(), "Chat ID") {
|
||||
t.Errorf("Chat ID 누락을 알려줘야 합니다: %q", err.Error())
|
||||
}
|
||||
@@ -102,16 +102,16 @@ func TestWebhookValidateLocalized(t *testing.T) {
|
||||
if err := (webhookChannel{}).Validate(map[string]any{}); err == nil {
|
||||
t.Fatal("url 누락은 검증 실패여야 합니다")
|
||||
} else {
|
||||
assertKorean(t, "webhook Validate(url)", err.Error())
|
||||
if !strings.Contains(err.Error(), "대상 URL") {
|
||||
t.Errorf("대상 URL 누락을 알려줘야 합니다: %q", err.Error())
|
||||
assertChinese(t, "webhook Validate(url)", err.Error())
|
||||
if !strings.Contains(err.Error(), "目标 URL") {
|
||||
t.Errorf("目标 URL 누락을 알려줘야 합니다: %q", err.Error())
|
||||
}
|
||||
}
|
||||
// 지원하지 않는 메서드: 한국어 + 허용 목록 노출.
|
||||
if err := (webhookChannel{}).Validate(map[string]any{"url": "https://example.com/hook", "method": "DELETE"}); err == nil {
|
||||
t.Fatal("DELETE 는 검증 실패여야 합니다")
|
||||
} else {
|
||||
assertKorean(t, "webhook Validate(method)", err.Error())
|
||||
assertChinese(t, "webhook Validate(method)", err.Error())
|
||||
if !strings.Contains(err.Error(), "GET/POST/PUT/PATCH") {
|
||||
t.Errorf("허용 메서드 목록을 알려줘야 합니다: %q", err.Error())
|
||||
}
|
||||
@@ -120,8 +120,8 @@ func TestWebhookValidateLocalized(t *testing.T) {
|
||||
if err := (webhookChannel{}).Validate(map[string]any{"url": "https://example.com/hook", "body_template": "{{"}); err == nil {
|
||||
t.Fatal("깨진 템플릿은 검증 실패여야 합니다")
|
||||
} else {
|
||||
assertKorean(t, "webhook Validate(template)", err.Error())
|
||||
if !strings.Contains(err.Error(), "템플릿") {
|
||||
assertChinese(t, "webhook Validate(template)", err.Error())
|
||||
if !strings.Contains(err.Error(), "模板") {
|
||||
t.Errorf("템플릿 오류임을 알려줘야 합니다: %q", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -135,7 +135,7 @@ func TestValidateHTTPURLLocalized(t *testing.T) {
|
||||
if err := validateHTTPURL("ftp://example.com/x"); err == nil {
|
||||
t.Fatal("ftp 스킴은 거부되어야 합니다")
|
||||
} else {
|
||||
assertKorean(t, "validateHTTPURL(scheme)", err.Error())
|
||||
assertChinese(t, "validateHTTPURL(scheme)", err.Error())
|
||||
if !strings.Contains(err.Error(), "http") {
|
||||
t.Errorf("지원 스킴(http/https)을 알려줘야 합니다: %q", err.Error())
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func TestValidateHTTPURLLocalized(t *testing.T) {
|
||||
if err := validateHTTPURL("http://"); err == nil {
|
||||
t.Fatal("호스트 없는 주소는 거부되어야 합니다")
|
||||
} else {
|
||||
assertKorean(t, "validateHTTPURL(host)", err.Error())
|
||||
assertChinese(t, "validateHTTPURL(host)", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestHTTPLocalTargetBlockedLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("로컬 주소는 기본적으로 차단되어야 합니다")
|
||||
}
|
||||
assertKorean(t, "blockInternalDial", err.Error())
|
||||
assertChinese(t, "blockInternalDial", err.Error())
|
||||
if !strings.Contains(err.Error(), AllowLocalTargetsEnv) {
|
||||
t.Errorf("차단 오류는 우회 방법(%s)을 알려줘야 합니다: %q", AllowLocalTargetsEnv, err.Error())
|
||||
}
|
||||
@@ -165,16 +165,16 @@ func TestHTTPLocalTargetBlockedLocalized(t *testing.T) {
|
||||
// TestHTTPRedactHelpersLocalized 는 주소 탈감(redact) 자리표시자와 전송 오류의
|
||||
// "알 수 없는 오류" 분기가 한국어인지 확인한다.
|
||||
func TestHTTPRedactHelpersLocalized(t *testing.T) {
|
||||
if got := redactRequestTarget(""); !strings.Contains(got, "해석할 수 없") {
|
||||
t.Errorf("해석 불가 주소는 한국어 자리표시자여야 합니다, 받은 값 %q", got)
|
||||
if got := redactRequestTarget(""); !strings.Contains(got, "无法解析") {
|
||||
t.Errorf("해석 불가 주소는 중국어 자리표시자여야 합니다, 받은 값 %q", got)
|
||||
}
|
||||
// *url.Error 의 Err 가 nil 인 분기.
|
||||
got := redactTransportError(&url.Error{Op: "Get", URL: "http://api.example", Err: nil})
|
||||
if hasHan(got) {
|
||||
t.Errorf("전송 오류 탈감 결과에 한자가 남았습니다: %q", got)
|
||||
if hasHangul(got) {
|
||||
t.Errorf("전송 오류 탈감 결과에 한글이 남았습니다: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "알 수 없는 오류") {
|
||||
t.Errorf("Err 가 nil 이면 '알 수 없는 오류' 여야 합니다, 받은 값 %q", got)
|
||||
if !strings.Contains(got, "未知错误") {
|
||||
t.Errorf("Err 가 nil 이면 '未知错误' 여야 합니다, 받은 값 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,9 +187,9 @@ func TestHTTPStatusErrorsLocalized(t *testing.T) {
|
||||
code int
|
||||
want string
|
||||
}{
|
||||
{http.StatusForbidden, "요청을 거부했습니다"},
|
||||
{http.StatusInternalServerError, "오류가 발생했습니다"},
|
||||
{http.StatusTooManyRequests, "요청을 제한하거나"},
|
||||
{http.StatusForbidden, "拒绝"},
|
||||
{http.StatusInternalServerError, "错误"},
|
||||
{http.StatusTooManyRequests, "限制"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -201,7 +201,7 @@ func TestHTTPStatusErrorsLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("HTTP %d 는 오류여야 합니다", tc.code)
|
||||
}
|
||||
assertKorean(t, "doJSON(HTTP status)", err.Error())
|
||||
assertChinese(t, "doJSON(HTTP status)", err.Error())
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("HTTP %d 오류에 %q 가 있어야 합니다, 받은 값 %q", tc.code, tc.want, err.Error())
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestTemplateCanOnlyProduceJSON(t *testing.T) {
|
||||
if err == nil || !IsPermanent(err) {
|
||||
t.Fatalf("渲染出非 JSON 应判永久失败,得到 %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "올바른 JSON") {
|
||||
if !strings.Contains(err.Error(), "合法的 JSON") {
|
||||
t.Errorf("错误信息应说明是 JSON 问题,得到 %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-5
@@ -34,10 +34,10 @@ func (weComChannel) DestinationKeys() []string { return []string{"webhook"} }
|
||||
func (weComChannel) Validate(cfg map[string]any) error {
|
||||
hook := cfgString(cfg, "webhook")
|
||||
if hook == "" {
|
||||
return errors.New("Webhook 주소가 없습니다")
|
||||
return errors.New("缺少 Webhook 地址")
|
||||
}
|
||||
if err := validateHTTPURL(hook); err != nil {
|
||||
return fmt.Errorf("Webhook 주소가 올바르지 않습니다: %w", err)
|
||||
return fmt.Errorf("Webhook 地址无效:%w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -62,17 +62,17 @@ func (c weComChannel) Send(ctx context.Context, cfg map[string]any, m Message) (
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return 0, fmt.Errorf("WeCom 응답을 해석하지 못했습니다: %w (%s)", err, snippet(raw))
|
||||
return 0, fmt.Errorf("无法解析 WeCom 响应:%w (%s)", err, snippet(raw))
|
||||
}
|
||||
if res.ErrCode != 0 {
|
||||
// 45009 是接口调用超过限制——平台的限流窗口会滚动,退避后重试是有效的,
|
||||
// 所以显式归为可重试。走到这里说明客户端 rate_per_min 配得过于激进,
|
||||
// 重试只是兜底,真正的修法是调低该渠道的限流值。
|
||||
if res.ErrCode == 45009 {
|
||||
return 0, fmt.Errorf("WeCom 요청이 제한되었습니다 (%d): %s", res.ErrCode, res.ErrMsg)
|
||||
return 0, fmt.Errorf("WeCom 请求受限 (%d):%s", res.ErrCode, res.ErrMsg)
|
||||
}
|
||||
// 93000 是 webhook key 无效——永久失败,重试不会自愈。
|
||||
return 0, Permanent(fmt.Errorf("WeCom에서 오류가 발생했습니다 (%d): %s", res.ErrCode, res.ErrMsg))
|
||||
return 0, Permanent(fmt.Errorf("WeCom 发生错误 (%d):%s", res.ErrCode, res.ErrMsg))
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
+30
-30
@@ -30,7 +30,7 @@ func sortFindingsForExport(fs []*db.DBFinding) {
|
||||
|
||||
// findingTitle 은 취약점의 사람이 읽을 수 있는 제목을 고른다: 이름 → 유형 → 「미분류」.
|
||||
func findingTitle(f *db.DBFinding) string {
|
||||
return nz(f.Name, nz(f.VulnClass, "미분류"))
|
||||
return nz(f.Name, nz(f.VulnClass, "未分类"))
|
||||
}
|
||||
|
||||
// FindingsMarkdown 은 여러 건의 findings 를 하나의 요약 보고서로 통합한다(요약 + 심각도별
|
||||
@@ -40,48 +40,48 @@ func FindingsMarkdown(fs []*db.DBFinding, generatedAt time.Time) string {
|
||||
sortFindingsForExport(items)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("# 취약점 요약 보고서\n\n")
|
||||
fmt.Fprintf(&b, "- **생성 시각**: %s\n", generatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(&b, "- **취약점 총계**: %d건\n\n", len(items))
|
||||
b.WriteString("# 漏洞摘要报告\n\n")
|
||||
fmt.Fprintf(&b, "- **生成时间**: %s\n", generatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(&b, "- **漏洞总数**: %d 个\n\n", len(items))
|
||||
|
||||
// 요약: 심각도별 집계.
|
||||
counts := map[string]int{}
|
||||
for _, f := range items {
|
||||
counts[f.Severity]++
|
||||
}
|
||||
b.WriteString("## 요약\n\n")
|
||||
b.WriteString("| 심각도 | 개수 |\n| --- | --- |\n")
|
||||
b.WriteString("## 摘要\n\n")
|
||||
b.WriteString("| 严重程度 | 数量 |\n| --- | --- |\n")
|
||||
for _, s := range []struct{ key, label string }{
|
||||
{"critical", "심각"}, {"high", "높음"}, {"medium", "중간"}, {"low", "낮음"},
|
||||
{"critical", "严重"}, {"high", "高危"}, {"medium", "中危"}, {"low", "低危"},
|
||||
} {
|
||||
fmt.Fprintf(&b, "| %s | %d |\n", s.label, counts[s.key])
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
if len(items) == 0 {
|
||||
b.WriteString("_일치하는 취약점이 없습니다._\n")
|
||||
b.WriteString("_没有匹配的漏洞。_\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
b.WriteString("## 취약점 상세\n\n")
|
||||
b.WriteString("## 漏洞详情\n\n")
|
||||
for i, f := range items {
|
||||
fmt.Fprintf(&b, "### %d. [%s] %s\n\n", i+1, strings.ToUpper(nz(f.Severity, "info")), findingTitle(f))
|
||||
if f.VulnClass != "" {
|
||||
fmt.Fprintf(&b, "- **유형**: %s\n", f.VulnClass)
|
||||
fmt.Fprintf(&b, "- **类型**: %s\n", f.VulnClass)
|
||||
}
|
||||
fmt.Fprintf(&b, "- **상태**: %s\n", nz(f.Status, "pending"))
|
||||
fmt.Fprintf(&b, "- **状态**: %s\n", nz(f.Status, "pending"))
|
||||
if desc := strings.TrimSpace(f.TaskDescription); desc != "" {
|
||||
fmt.Fprintf(&b, "- **소속 작업**: %s\n", desc)
|
||||
fmt.Fprintf(&b, "- **所属任务**: %s\n", desc)
|
||||
}
|
||||
fmt.Fprintf(&b, "- **발견 시각**: %s\n\n", f.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(&b, "- **发现时间**: %s\n\n", f.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
if s := strings.TrimSpace(f.Summary); s != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", s)
|
||||
}
|
||||
if e := strings.TrimSpace(f.Evidence); e != "" {
|
||||
fmt.Fprintf(&b, "**증거:**\n\n```\n%s\n```\n\n", e)
|
||||
fmt.Fprintf(&b, "**证据:**\n\n```\n%s\n```\n\n", e)
|
||||
}
|
||||
if rep := strings.TrimSpace(f.Report); rep != "" {
|
||||
b.WriteString("**상세 보고서:**\n\n")
|
||||
b.WriteString("**详细报告:**\n\n")
|
||||
b.WriteString(rep)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
@@ -96,23 +96,23 @@ func SingleFindingMarkdown(f *db.DBFinding, generatedAt time.Time) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# [%s] %s\n\n", strings.ToUpper(nz(f.Severity, "info")), findingTitle(f))
|
||||
if f.VulnClass != "" {
|
||||
fmt.Fprintf(&b, "- **유형**: %s\n", f.VulnClass)
|
||||
fmt.Fprintf(&b, "- **类型**: %s\n", f.VulnClass)
|
||||
}
|
||||
fmt.Fprintf(&b, "- **심각도**: %s\n", nz(f.Severity, "info"))
|
||||
fmt.Fprintf(&b, "- **상태**: %s\n", nz(f.Status, "pending"))
|
||||
fmt.Fprintf(&b, "- **严重程度**: %s\n", nz(f.Severity, "info"))
|
||||
fmt.Fprintf(&b, "- **状态**: %s\n", nz(f.Status, "pending"))
|
||||
if desc := strings.TrimSpace(f.TaskDescription); desc != "" {
|
||||
fmt.Fprintf(&b, "- **소속 작업**: %s\n", desc)
|
||||
fmt.Fprintf(&b, "- **所属任务**: %s\n", desc)
|
||||
}
|
||||
fmt.Fprintf(&b, "- **발견 시각**: %s\n", f.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(&b, "- **생성 시각**: %s\n\n", generatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(&b, "- **发现时间**: %s\n", f.CreatedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(&b, "- **生成时间**: %s\n\n", generatedAt.Format("2006-01-02 15:04:05"))
|
||||
if s := strings.TrimSpace(f.Summary); s != "" {
|
||||
fmt.Fprintf(&b, "## 개요\n\n%s\n\n", s)
|
||||
fmt.Fprintf(&b, "## 概述\n\n%s\n\n", s)
|
||||
}
|
||||
if e := strings.TrimSpace(f.Evidence); e != "" {
|
||||
fmt.Fprintf(&b, "## 증거\n\n```\n%s\n```\n\n", e)
|
||||
fmt.Fprintf(&b, "## 证据\n\n```\n%s\n```\n\n", e)
|
||||
}
|
||||
if rep := strings.TrimSpace(f.Report); rep != "" {
|
||||
b.WriteString("## 상세 보고서\n\n")
|
||||
b.WriteString("## 详细报告\n\n")
|
||||
b.WriteString(rep)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func FindingsCSV(fs []*db.DBFinding) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("\xEF\xBB\xBF") // UTF-8 BOM
|
||||
w := csv.NewWriter(&buf)
|
||||
_ = w.Write([]string{"ID", "이름", "유형", "심각도", "상태", "소속 작업", "발견 시각", "개요", "트래픽 증거 개수", "트래픽 증거 ID"})
|
||||
_ = w.Write([]string{"ID", "名称", "类型", "严重程度", "状态", "所属任务", "发现时间", "概述", "流量证据数量", "流量证据 ID"})
|
||||
for _, f := range items {
|
||||
_ = w.Write([]string{
|
||||
fmt.Sprintf("%d", f.ID),
|
||||
@@ -183,18 +183,18 @@ func findingTrafficMarkdown(f *db.DBFinding, attachments bool) string {
|
||||
return ""
|
||||
}
|
||||
var out strings.Builder
|
||||
out.WriteString("\n## 관련 트래픽 증거\n\n")
|
||||
fmt.Fprintf(&out, "증거 버전: %d, 바인딩 개수: %d.\n\n", f.EvidenceVersion, len(f.TrafficBindings))
|
||||
out.WriteString("\n## 相关流量证据\n\n")
|
||||
fmt.Fprintf(&out, "证据版本: %d, 绑定数量: %d.\n\n", f.EvidenceVersion, len(f.TrafficBindings))
|
||||
if stale {
|
||||
out.WriteString("증거가 변경되어 상세 보고서를 갱신해야 합니다.\n\n")
|
||||
out.WriteString("证据已变更,需要更新详细报告。\n\n")
|
||||
}
|
||||
for i, b := range f.TrafficBindings {
|
||||
fmt.Fprintf(&out, "%d. **증거 #%d · %s**: `%s %s`, 상태 코드 %d\n", i+1, b.ID, b.Role, b.Snapshot.Method, strings.ReplaceAll(b.Snapshot.URL, "`", "%60"), b.Snapshot.Status)
|
||||
fmt.Fprintf(&out, "%d. **证据 #%d · %s**: `%s %s`, 状态码 %d\n", i+1, b.ID, b.Role, b.Snapshot.Method, strings.ReplaceAll(b.Snapshot.URL, "`", "%60"), b.Snapshot.Status)
|
||||
if b.Note != "" {
|
||||
fmt.Fprintf(&out, " %s\n", strings.ReplaceAll(b.Note, "\n", "\n "))
|
||||
}
|
||||
if attachments {
|
||||
fmt.Fprintf(&out, " [요청 메시지](evidence/%d/%d/request.http) · [응답 메시지](evidence/%d/%d/response.http)\n", f.ID, b.ID, f.ID, b.ID)
|
||||
fmt.Fprintf(&out, " [请求消息](evidence/%d/%d/request.http) · [响应消息](evidence/%d/%d/response.http)\n", f.ID, b.ID, f.ID, b.ID)
|
||||
}
|
||||
}
|
||||
out.WriteString("\n")
|
||||
|
||||
+10
-10
@@ -48,14 +48,14 @@ var sevRank = map[string]int{"critical": 0, "high": 1, "medium": 2, "low": 3, ""
|
||||
// Markdown renders the report.
|
||||
func Markdown(in Input) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# 침투 테스트 보고서: %s\n\n", nz(in.Title, "이름 없는 작업"))
|
||||
fmt.Fprintf(&b, "- **작업 목표**: %s\n", nz(in.Goal, "(지정되지 않음)"))
|
||||
fmt.Fprintf(&b, "- **생성 시각**: %s\n\n", in.GeneratedAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Fprintf(&b, "# 渗透测试报告: %s\n\n", nz(in.Title, "未命名任务"))
|
||||
fmt.Fprintf(&b, "- **任务目标**: %s\n", nz(in.Goal, "(未指定)"))
|
||||
fmt.Fprintf(&b, "- **生成时间**: %s\n\n", in.GeneratedAt.Format("2006-01-02 15:04:05"))
|
||||
|
||||
// summary
|
||||
fmt.Fprintf(&b, "## 요약\n\n")
|
||||
fmt.Fprintf(&b, "- 확인된 취약점: **%d**건\n", len(in.Findings))
|
||||
fmt.Fprintf(&b, "- 자산: ")
|
||||
fmt.Fprintf(&b, "## 摘要\n\n")
|
||||
fmt.Fprintf(&b, "- 已确认漏洞: **%d** 个\n", len(in.Findings))
|
||||
fmt.Fprintf(&b, "- 资产: ")
|
||||
var types []string
|
||||
for t := range in.AssetCounts {
|
||||
types = append(types, t)
|
||||
@@ -70,9 +70,9 @@ func Markdown(in Input) string {
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// findings
|
||||
fmt.Fprintf(&b, "## 취약점\n\n")
|
||||
fmt.Fprintf(&b, "## 漏洞\n\n")
|
||||
if len(in.Findings) == 0 {
|
||||
b.WriteString("_이번에 확인된 취약점이 없습니다._\n\n")
|
||||
b.WriteString("_本次未确认到任何漏洞。_\n\n")
|
||||
} else {
|
||||
fs := make([]findingView, 0, len(in.Findings))
|
||||
for _, n := range in.Findings {
|
||||
@@ -80,10 +80,10 @@ func Markdown(in Input) string {
|
||||
}
|
||||
sort.SliceStable(fs, func(i, j int) bool { return sevRank[fs[i].Severity] < sevRank[fs[j].Severity] })
|
||||
for i, f := range fs {
|
||||
fmt.Fprintf(&b, "### %d. [%s] %s\n\n", i+1, strings.ToUpper(nz(f.Severity, "info")), nz(f.Name, nz(f.VulnClass, "미분류")))
|
||||
fmt.Fprintf(&b, "### %d. [%s] %s\n\n", i+1, strings.ToUpper(nz(f.Severity, "info")), nz(f.Name, nz(f.VulnClass, "未分类")))
|
||||
fmt.Fprintf(&b, "%s\n\n", nz(f.Summary, ""))
|
||||
if f.PoC != "" {
|
||||
fmt.Fprintf(&b, "**PoC / 증거:**\n\n```\n%s\n```\n\n", f.PoC)
|
||||
fmt.Fprintf(&b, "**PoC / 证据:**\n\n```\n%s\n```\n\n", f.PoC)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,13 +21,35 @@ func containsHan(s string) (rune, bool) {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// containsHangul 은 문자열에 한글 음절이 남아 있으면 true 를 돌려준다.
|
||||
// 중국어로 바뀐 뒤 한글이 되돌아오는 회귀를 잡는다.
|
||||
func containsHangul(s string) (rune, bool) {
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Hangul, r) {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// assertChinese 는 s 에 한자가 있고 한글이 없음을 단언한다.
|
||||
func assertChinese(t *testing.T, label, s string) {
|
||||
t.Helper()
|
||||
if r, ok := containsHangul(s); ok {
|
||||
t.Fatalf("%s: 한글이 남아 있습니다 %q:\n%s", label, string(r), s)
|
||||
}
|
||||
if _, ok := containsHan(s); !ok {
|
||||
t.Fatalf("%s: 한자가 없습니다:\n%s", label, s)
|
||||
}
|
||||
}
|
||||
|
||||
func sampleFindingNode(t *testing.T) *db.Node {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"vulnclass": "SQL Injection",
|
||||
"name": "로그인 폼 SQL 주입",
|
||||
"name": "登录表单 SQL 注入",
|
||||
"severity": "high",
|
||||
"summary": "로그인 파라미터에서 오류 기반 SQL 주입이 확인되었습니다.",
|
||||
"summary": "在登录参数中确认了基于错误的 SQL 注入。",
|
||||
"evidence": map[string]string{"poc": "' OR '1'='1"},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -40,20 +62,20 @@ func sampleDBFinding() *db.DBFinding {
|
||||
return &db.DBFinding{
|
||||
ID: 123,
|
||||
VulnClass: "SQL Injection",
|
||||
Name: "로그인 폼 SQL 주입",
|
||||
Name: "登录表单 SQL 注入",
|
||||
Severity: "high",
|
||||
Summary: "로그인 파라미터에서 오류 기반 SQL 주입이 확인되었습니다.",
|
||||
Summary: "在登录参数中确认了基于错误的 SQL 注入。",
|
||||
Evidence: "' OR '1'='1",
|
||||
Status: "confirmed",
|
||||
Report: "상세 분석 본문.",
|
||||
Report: "详细分析正文。",
|
||||
CreatedAt: time.Date(2026, 10, 4, 2, 30, 0, 0, time.UTC),
|
||||
EvidenceVersion: 2,
|
||||
TaskDescription: "데모 대상 침투 테스트",
|
||||
TaskDescription: "演示目标渗透测试",
|
||||
TrafficBindings: []db.FindingTrafficBinding{
|
||||
{
|
||||
ID: 9,
|
||||
Role: "request",
|
||||
Note: "주입 페이로드 전송 지점",
|
||||
Note: "注入载荷发送点",
|
||||
Snapshot: db.TrafficEvidenceSnapshot{
|
||||
Method: "POST",
|
||||
URL: "https://sandbox.local/login",
|
||||
@@ -68,23 +90,21 @@ func sampleDBFinding() *db.DBFinding {
|
||||
// 중국어 한자가 전혀 없음을 검증한다.
|
||||
func TestReportMarkdownLocalized(t *testing.T) {
|
||||
out := Markdown(Input{
|
||||
Title: "데모 작업",
|
||||
Goal: "샌드박스 전수 점검",
|
||||
Title: "演示任务",
|
||||
Goal: "沙箱全面检查",
|
||||
GeneratedAt: time.Date(2026, 10, 4, 2, 30, 0, 0, time.UTC),
|
||||
AssetCounts: map[string]int{"host": 2, "url": 5},
|
||||
Findings: []*db.Node{sampleFindingNode(t)},
|
||||
})
|
||||
if r, ok := containsHan(out); ok {
|
||||
t.Fatalf("보고서 골격에 중국어 한자 %q 가 남아 있다:\n%s", string(r), out)
|
||||
}
|
||||
assertChinese(t, "보고서", out)
|
||||
for _, want := range []string{
|
||||
"# 침투 테스트 보고서: 데모 작업",
|
||||
"- **작업 목표**: 샌드박스 전수 점검",
|
||||
"- **생성 시각**:",
|
||||
"## 요약",
|
||||
"- 확인된 취약점: **1**건",
|
||||
"## 취약점",
|
||||
"**PoC / 증거:**",
|
||||
"# 渗透测试报告: 演示任务",
|
||||
"- **任务目标**: 沙箱全面检查",
|
||||
"- **生成时间**:",
|
||||
"## 摘要",
|
||||
"- 已确认漏洞: **1** 个",
|
||||
"## 漏洞",
|
||||
"**PoC / 证据:**",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("보고서 골격에 %q 가 없다", want)
|
||||
@@ -94,12 +114,10 @@ func TestReportMarkdownLocalized(t *testing.T) {
|
||||
|
||||
// TestReportMarkdownEmptyLocalized 는 취약점이 없을 때의 안내 문구가 한국어임을 검증한다.
|
||||
func TestReportMarkdownEmptyLocalized(t *testing.T) {
|
||||
out := Markdown(Input{Title: "빈 작업", GeneratedAt: time.Now()})
|
||||
if r, ok := containsHan(out); ok {
|
||||
t.Fatalf("빈 보고서에 중국어 한자 %q 가 남아 있다:\n%s", string(r), out)
|
||||
}
|
||||
if !strings.Contains(out, "_이번에 확인된 취약점이 없습니다._") {
|
||||
t.Errorf("빈 보고서 안내 문구가 한국어가 아니다:\n%s", out)
|
||||
out := Markdown(Input{Title: "空任务", GeneratedAt: time.Now()})
|
||||
assertChinese(t, "빈 보고서", out)
|
||||
if !strings.Contains(out, "_本次未确认到任何漏洞。_") {
|
||||
t.Errorf("빈 보고서 안내 문구가 중국어가 아니다:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,25 +126,23 @@ func TestReportMarkdownEmptyLocalized(t *testing.T) {
|
||||
func TestFindingsMarkdownLocalized(t *testing.T) {
|
||||
f := sampleDBFinding()
|
||||
out := FindingsMarkdown([]*db.DBFinding{f}, time.Date(2026, 10, 4, 2, 30, 0, 0, time.UTC))
|
||||
if r, ok := containsHan(out); ok {
|
||||
t.Fatalf("취약점 요약 보고서에 중국어 한자 %q 가 남아 있다:\n%s", string(r), out)
|
||||
}
|
||||
assertChinese(t, "취약점 요약 보고서", out)
|
||||
for _, want := range []string{
|
||||
"# 취약점 요약 보고서",
|
||||
"- **생성 시각**:",
|
||||
"- **취약점 총계**: 1건",
|
||||
"## 요약",
|
||||
"| 심각도 | 개수 |",
|
||||
"| 심각 |", "| 높음 |", "| 중간 |", "| 낮음 |", // 심각도 라벨(UI status.severity 와 동일)
|
||||
"## 취약점 상세",
|
||||
"- **유형**: SQL Injection",
|
||||
"- **상태**: confirmed",
|
||||
"- **소속 작업**: 데모 대상 침투 테스트",
|
||||
"- **발견 시각**:",
|
||||
"**증거:**",
|
||||
"**상세 보고서:**",
|
||||
"## 관련 트래픽 증거",
|
||||
"증거 버전: 2, 바인딩 개수: 1.",
|
||||
"# 漏洞摘要报告",
|
||||
"- **生成时间**:",
|
||||
"- **漏洞总数**: 1 个",
|
||||
"## 摘要",
|
||||
"| 严重程度 | 数量 |",
|
||||
"| 严重 |", "| 高危 |", "| 中危 |", "| 低危 |", // 심각도 라벨(UI status.severity 와 동일)
|
||||
"## 漏洞详情",
|
||||
"- **类型**: SQL Injection",
|
||||
"- **状态**: confirmed",
|
||||
"- **所属任务**: 演示目标渗透测试",
|
||||
"- **发现时间**:",
|
||||
"**证据:**",
|
||||
"**详细报告:**",
|
||||
"## 相关流量证据",
|
||||
"证据版本: 2, 绑定数量: 1.",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("요약 보고서에 %q 가 없다", want)
|
||||
@@ -134,16 +150,14 @@ func TestFindingsMarkdownLocalized(t *testing.T) {
|
||||
}
|
||||
|
||||
single := SingleFindingMarkdown(f, time.Date(2026, 10, 4, 2, 30, 0, 0, time.UTC))
|
||||
if r, ok := containsHan(single); ok {
|
||||
t.Fatalf("단건 보고서에 중국어 한자 %q 가 남아 있다:\n%s", string(r), single)
|
||||
}
|
||||
assertChinese(t, "단건 보고서", single)
|
||||
for _, want := range []string{
|
||||
"- **심각도**: high",
|
||||
"## 개요",
|
||||
"## 증거",
|
||||
"## 상세 보고서",
|
||||
"[요청 메시지](evidence/123/9/request.http)",
|
||||
"[응답 메시지](evidence/123/9/response.http)",
|
||||
"- **严重程度**: high",
|
||||
"## 概述",
|
||||
"## 证据",
|
||||
"## 详细报告",
|
||||
"[请求消息](evidence/123/9/request.http)",
|
||||
"[响应消息](evidence/123/9/response.http)",
|
||||
} {
|
||||
if !strings.Contains(single, want) {
|
||||
t.Errorf("단건 보고서에 %q 가 없다", want)
|
||||
@@ -157,10 +171,8 @@ func TestFindingsCSVLocalized(t *testing.T) {
|
||||
raw := FindingsCSV([]*db.DBFinding{sampleDBFinding()})
|
||||
out := strings.TrimPrefix(string(raw), "\xEF\xBB\xBF")
|
||||
header := strings.SplitN(out, "\n", 2)[0]
|
||||
if r, ok := containsHan(header); ok {
|
||||
t.Fatalf("CSV 헤더에 중국어 한자 %q 가 남아 있다: %s", string(r), header)
|
||||
}
|
||||
for _, col := range []string{"ID", "이름", "유형", "심각도", "상태", "소속 작업", "발견 시각", "개요", "트래픽 증거 개수", "트래픽 증거 ID"} {
|
||||
assertChinese(t, "CSV 헤더", header)
|
||||
for _, col := range []string{"ID", "名称", "类型", "严重程度", "状态", "所属任务", "发现时间", "概述", "流量证据数量", "流量证据 ID"} {
|
||||
if !strings.Contains(header, col) {
|
||||
t.Errorf("CSV 헤더에 %q 열이 없다: %s", col, header)
|
||||
}
|
||||
|
||||
+22
-22
@@ -80,14 +80,14 @@ func applyStaged(p Paths) (Action, State) {
|
||||
log.Printf("[update] 暂存的新版本未通过校验,已丢弃,继续运行当前版本:%v", err)
|
||||
cleanStaged(p)
|
||||
_ = os.Remove(p.Marker)
|
||||
return Continue, State{FailedStage: true, Detail: "새 버전 검증에 실패해 폐기했습니다: " + err.Error()}
|
||||
return Continue, State{FailedStage: true, Detail: "新版本校验失败,已丢弃: " + err.Error()}
|
||||
}
|
||||
|
||||
if err := swap(p); err != nil {
|
||||
log.Printf("[update] 换装失败,继续运行当前版本:%v", err)
|
||||
cleanStaged(p)
|
||||
_ = os.Remove(p.Marker)
|
||||
return Continue, State{FailedStage: true, Detail: "버전 교체에 실패했습니다: " + err.Error()}
|
||||
return Continue, State{FailedStage: true, Detail: "版本替换失败: " + err.Error()}
|
||||
}
|
||||
|
||||
// 换装成功。保留标记,交给下一次启动(跑的就是新版)确认是否稳定。
|
||||
@@ -115,12 +115,12 @@ func confirmOrRollback(p Paths, m marker) (Action, State) {
|
||||
// 让进程按当前状态起——起不来的话用户至少能在日志里看到原因。
|
||||
log.Printf("[update] 新版本连续 %d 次启动失败,且回滚失败:%v", maxAttempts, err)
|
||||
_ = os.Remove(p.Marker)
|
||||
return Continue, State{Detail: "새 버전 시작에 실패하고 롤백도 실패했습니다: " + err.Error()}
|
||||
return Continue, State{Detail: "新版本启动失败,回滚也失败: " + err.Error()}
|
||||
}
|
||||
log.Printf("[update] 新版本连续 %d 次启动失败,已回滚到 %s,退出以重启(exit %d)",
|
||||
maxAttempts, orUnknown(m.From), ExitRestart)
|
||||
_ = os.Remove(p.Marker)
|
||||
return Restart, State{RolledBack: true, Detail: fmt.Sprintf("새 버전 시작에 실패해 이전 버전으로 롤백했습니다(%s)", orUnknown(m.From))}
|
||||
return Restart, State{RolledBack: true, Detail: fmt.Sprintf("新版本启动失败,已回滚到上一版本(%s)", orUnknown(m.From))}
|
||||
}
|
||||
if err := writeMarker(p.Marker, m); err != nil {
|
||||
log.Printf("[update] 更新升级标记失败:%v", err)
|
||||
@@ -160,14 +160,14 @@ const SettleDelay = 30 * time.Second
|
||||
func verifyStaged(p Paths) error {
|
||||
want, err := os.ReadFile(p.Sum)
|
||||
if err != nil {
|
||||
return fmt.Errorf("체크섬 읽기 실패: %w", err)
|
||||
return fmt.Errorf("校验和读取失败: %w", err)
|
||||
}
|
||||
got, err := fileSHA256(p.New)
|
||||
if err != nil {
|
||||
return fmt.Errorf("체크섬 계산 실패: %w", err)
|
||||
return fmt.Errorf("校验和计算失败: %w", err)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(string(want)), got) {
|
||||
return errors.New("SHA256 이 일치하지 않습니다(다운로드가 손상됐거나 변조됨)")
|
||||
return errors.New("SHA256 不匹配(下载已损坏或被篡改)")
|
||||
}
|
||||
return smokeTest(p.New)
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func verifyStaged(p Paths) error {
|
||||
// 这能挡掉下载截断、架构选错(exec format error)、缺依赖等一大类问题。
|
||||
func smokeTest(bin string) error {
|
||||
if err := os.Chmod(bin, 0o755); err != nil {
|
||||
return fmt.Errorf("실행 권한 부여 실패: %w", err)
|
||||
return fmt.Errorf("授予执行权限失败: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
@@ -185,14 +185,14 @@ func smokeTest(bin string) error {
|
||||
cmd.Env = append(os.Environ(), smokeEnv+"=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if ctx.Err() != nil {
|
||||
return errors.New("스모크 테스트 시간 초과(새 바이너리가 응답하지 않음)")
|
||||
return errors.New("冒烟测试超时(新二进制文件无响应)")
|
||||
}
|
||||
if err != nil {
|
||||
snippet := strings.TrimSpace(string(out))
|
||||
if len(snippet) > 300 {
|
||||
snippet = snippet[:300] + "…"
|
||||
}
|
||||
return fmt.Errorf("스모크 테스트 실패: %v: %s", err, snippet)
|
||||
return fmt.Errorf("冒烟测试失败: %v: %s", err, snippet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -204,17 +204,17 @@ func smokeTest(bin string) error {
|
||||
func swap(p Paths) error {
|
||||
// Windows 的 rename 不会覆盖已存在的目标,上一轮升级留下的 .old 必须先清掉。
|
||||
if err := os.Remove(p.Old); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("이전 백업 %s 정리 실패: %w", p.Old, err)
|
||||
return fmt.Errorf("清理旧备份 %s 失败: %w", p.Old, err)
|
||||
}
|
||||
if err := os.Rename(p.Current, p.Old); err != nil {
|
||||
return fmt.Errorf("현재 버전 백업 실패: %w", err)
|
||||
return fmt.Errorf("当前版本备份失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(p.New, p.Current); err != nil {
|
||||
// 换装失败但当前版本已经被挪走了,必须原样放回去,否则下次启动没有可执行文件。
|
||||
if rerr := os.Rename(p.Old, p.Current); rerr != nil {
|
||||
return fmt.Errorf("새 버전 설치 실패(%v), 현재 버전 복구도 실패: %w", err, rerr)
|
||||
return fmt.Errorf("新版本安装失败(%v),当前版本恢复也失败: %w", err, rerr)
|
||||
}
|
||||
return fmt.Errorf("새 버전 설치 실패: %w", err)
|
||||
return fmt.Errorf("新版本安装失败: %w", err)
|
||||
}
|
||||
_ = os.Remove(p.Sum)
|
||||
return nil
|
||||
@@ -223,16 +223,16 @@ func swap(p Paths) error {
|
||||
// rollback 把 swap 备份的旧版本换回来。
|
||||
func rollback(p Paths) error {
|
||||
if _, err := os.Stat(p.Old); err != nil {
|
||||
return fmt.Errorf("롤백할 백업이 없습니다(%s): %w", p.Old, err)
|
||||
return fmt.Errorf("没有可回滚的备份(%s): %w", p.Old, err)
|
||||
}
|
||||
// 把起不来的新版挪到 .failed 留作排查,而不是直接删掉。
|
||||
failed := p.Current + ".failed"
|
||||
_ = os.Remove(failed)
|
||||
if err := os.Rename(p.Current, failed); err != nil {
|
||||
return fmt.Errorf("실패한 버전 이동 실패: %w", err)
|
||||
return fmt.Errorf("移动失败版本失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(p.Old, p.Current); err != nil {
|
||||
return fmt.Errorf("이전 버전 복구 실패: %w", err)
|
||||
return fmt.Errorf("上一版本恢复失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -245,21 +245,21 @@ func Rollback() error {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(p.Old); err != nil {
|
||||
return errors.New("롤백할 이전 버전이 없습니다(" + p.Old + " 없음)")
|
||||
return errors.New("没有可回滚的上一版本(不存在 " + p.Old + ")")
|
||||
}
|
||||
cleanStaged(p)
|
||||
if err := smokeTest(p.Old); err != nil {
|
||||
return fmt.Errorf("이전 버전을 실행할 수 없어 롤백을 거부합니다: %w", err)
|
||||
return fmt.Errorf("无法运行上一版本,拒绝回滚: %w", err)
|
||||
}
|
||||
// 交换当前与备份:回滚之后还能再滚回来。
|
||||
tmp := p.Current + ".swap"
|
||||
_ = os.Remove(tmp)
|
||||
if err := os.Rename(p.Current, tmp); err != nil {
|
||||
return fmt.Errorf("현재 버전 이동 실패: %w", err)
|
||||
return fmt.Errorf("当前版本移动失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(p.Old, p.Current); err != nil {
|
||||
_ = os.Rename(tmp, p.Current)
|
||||
return fmt.Errorf("이전 버전 설치 실패: %w", err)
|
||||
return fmt.Errorf("上一版本安装失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, p.Old); err != nil {
|
||||
log.Printf("[update] 回滚后整理备份失败(不影响运行):%v", err)
|
||||
@@ -293,7 +293,7 @@ func fileSHA256(path string) (string, error) {
|
||||
|
||||
func orUnknown(s string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "알 수 없는 버전"
|
||||
return "未知版本"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ func NewClient(proxy string) *http.Client {
|
||||
Timeout: 30 * time.Minute, // 下载整包,不能按请求级超时卡死
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("리다이렉트 횟수가 너무 많습니다")
|
||||
return fmt.Errorf("重定向次数过多")
|
||||
}
|
||||
return checkURL(req.URL)
|
||||
},
|
||||
@@ -76,10 +76,10 @@ func NewClient(proxy string) *http.Client {
|
||||
// checkURL 强制 https + 域名白名单。
|
||||
func checkURL(u *url.URL) error {
|
||||
if u.Scheme != "https" {
|
||||
return fmt.Errorf("HTTPS 가 아닌 주소를 거부합니다: %s", u.Scheme+"://"+u.Host)
|
||||
return fmt.Errorf("拒绝非 HTTPS 地址: %s", u.Scheme+"://"+u.Host)
|
||||
}
|
||||
if !allowedHosts[strings.ToLower(u.Hostname())] {
|
||||
return fmt.Errorf("GitHub 도메인이 아닌 주소를 거부합니다: %s", u.Hostname())
|
||||
return fmt.Errorf("拒绝非 GitHub 域名地址: %s", u.Hostname())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -98,26 +98,26 @@ func FetchLatest(ctx context.Context, c *http.Client) (*Release, error) {
|
||||
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GitHub 에 접속하지 못했습니다(시스템 설정에서 전역 프록시를 설정할 수 있습니다): %w", err)
|
||||
return nil, fmt.Errorf("无法连接到 GitHub(可在系统设置中配置全局代理): %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusForbidden, resp.StatusCode == http.StatusTooManyRequests:
|
||||
// 未认证的 GitHub API 是每 IP 每小时 60 次,共用出口 IP 时很容易撞上。
|
||||
return nil, fmt.Errorf("GitHub API 요청 제한에 걸렸습니다(시간당 60회). 잠시 후 다시 시도하세요")
|
||||
return nil, fmt.Errorf("触发了 GitHub API 请求限制(每小时 60 次)。请稍后重试")
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return nil, fmt.Errorf("저장소 %s 에 아직 정식 릴리스가 없습니다", Repo)
|
||||
return nil, fmt.Errorf("仓库 %s 尚未有正式发布", Repo)
|
||||
case resp.StatusCode != http.StatusOK:
|
||||
return nil, fmt.Errorf("GitHub 응답 코드 %d", resp.StatusCode)
|
||||
return nil, fmt.Errorf("GitHub 响应代码 %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rel Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
|
||||
return nil, fmt.Errorf("Release 해석 실패: %w", err)
|
||||
return nil, fmt.Errorf("Release 解析失败: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(rel.TagName) == "" {
|
||||
return nil, fmt.Errorf("Release 에 tag 가 없습니다")
|
||||
return nil, fmt.Errorf("Release 中没有 tag")
|
||||
}
|
||||
return &rel, nil
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ type Paths struct {
|
||||
func ResolvePaths() (Paths, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return Paths{}, fmt.Errorf("실행 파일 위치 확인 실패: %w", err)
|
||||
return Paths{}, fmt.Errorf("可执行文件位置检查失败: %w", err)
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
|
||||
@@ -33,19 +33,19 @@ func hasHanzi(s string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// assertKoreanError 는 오류가 존재하고, 한글을 포함하며, 중국어 한자가 없음을 단언한다.
|
||||
// %w 로 감싼 하부 stdlib 오류의 영어는 허용된다(한자만 금지).
|
||||
func assertKoreanError(t *testing.T, label string, err error) {
|
||||
// assertChineseError 는 오류가 존재하고, 한자를 포함하며, 한글이 없음을 단언한다.
|
||||
// %w 로 감싼 하부 stdlib 오류의 영어는 허용된다(한글만 금지).
|
||||
func assertChineseError(t *testing.T, label string, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("%s: 오류를 기대했으나 nil 이었습니다", label)
|
||||
}
|
||||
msg := err.Error()
|
||||
if !hasHangul(msg) {
|
||||
t.Errorf("%s: 한글이 없습니다: %q", label, msg)
|
||||
if !hasHanzi(msg) {
|
||||
t.Errorf("%s: 한자가 없습니다: %q", label, msg)
|
||||
}
|
||||
if hasHanzi(msg) {
|
||||
t.Errorf("%s: 중국어 한자가 남아 있습니다: %q", label, msg)
|
||||
if hasHangul(msg) {
|
||||
t.Errorf("%s: 한글이 남아 있습니다: %q", label, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,13 +68,13 @@ func stubClient(status int, body string) *http.Client {
|
||||
// 문구 언어는 보지 않는다. 여기서는 두 거부 경로의 오류가 한국어인지 확인한다.
|
||||
func TestCheckURLErrorsLocalized(t *testing.T) {
|
||||
nonHTTPS := checkURL(mustParse(t, "http://api.github.com/x"))
|
||||
assertKoreanError(t, "non-https", nonHTTPS)
|
||||
assertChineseError(t, "non-https", nonHTTPS)
|
||||
if !strings.Contains(nonHTTPS.Error(), "HTTPS") {
|
||||
t.Errorf("non-https 오류에 HTTPS 표기가 없습니다: %q", nonHTTPS.Error())
|
||||
}
|
||||
|
||||
nonGitHub := checkURL(mustParse(t, "https://evil.example.com/x"))
|
||||
assertKoreanError(t, "non-github-host", nonGitHub)
|
||||
assertChineseError(t, "non-github-host", nonGitHub)
|
||||
if !strings.Contains(nonGitHub.Error(), "GitHub") {
|
||||
t.Errorf("non-github 오류에 GitHub 표기가 없습니다: %q", nonGitHub.Error())
|
||||
}
|
||||
@@ -86,25 +86,25 @@ func TestFetchLatestErrorsLocalized(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, errRate := FetchLatest(ctx, stubClient(http.StatusForbidden, ""))
|
||||
assertKoreanError(t, "rate-limited", errRate)
|
||||
assertChineseError(t, "rate-limited", errRate)
|
||||
if !strings.Contains(errRate.Error(), "60") {
|
||||
t.Errorf("요청 제한 오류에 60 표기가 없습니다: %q", errRate.Error())
|
||||
}
|
||||
|
||||
_, errNF := FetchLatest(ctx, stubClient(http.StatusNotFound, ""))
|
||||
assertKoreanError(t, "not-found", errNF)
|
||||
assertChineseError(t, "not-found", errNF)
|
||||
|
||||
_, errStatus := FetchLatest(ctx, stubClient(http.StatusInternalServerError, ""))
|
||||
assertKoreanError(t, "bad-status", errStatus)
|
||||
assertChineseError(t, "bad-status", errStatus)
|
||||
if !strings.Contains(errStatus.Error(), "GitHub") {
|
||||
t.Errorf("상태 오류에 GitHub 표기가 없습니다: %q", errStatus.Error())
|
||||
}
|
||||
|
||||
_, errJSON := FetchLatest(ctx, stubClient(http.StatusOK, "{not json"))
|
||||
assertKoreanError(t, "bad-json", errJSON)
|
||||
assertChineseError(t, "bad-json", errJSON)
|
||||
|
||||
_, errTag := FetchLatest(ctx, stubClient(http.StatusOK, "{}"))
|
||||
assertKoreanError(t, "missing-tag", errTag)
|
||||
assertChineseError(t, "missing-tag", errTag)
|
||||
if !strings.Contains(errTag.Error(), "tag") {
|
||||
t.Errorf("tag 누락 오류에 tag 표기가 없습니다: %q", errTag.Error())
|
||||
}
|
||||
@@ -116,5 +116,5 @@ func TestFetchLatestErrorsLocalized(t *testing.T) {
|
||||
func TestStageMissingAssetLocalized(t *testing.T) {
|
||||
rel := &Release{TagName: "v9.9.9"} // Assets 비어 있음
|
||||
err := Stage(context.Background(), &http.Client{}, rel, "0.0.1", nil)
|
||||
assertKoreanError(t, "stage-missing-asset", err)
|
||||
assertChineseError(t, "stage-missing-asset", err)
|
||||
}
|
||||
|
||||
+31
-31
@@ -59,17 +59,17 @@ func Stage(ctx context.Context, c *http.Client, rel *Release, currentVersion str
|
||||
name := AssetName(rel.TagName, runtime.GOOS, runtime.GOARCH)
|
||||
asset, ok := rel.FindAsset(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("이 버전은 %s/%s 용 릴리스 패키지를 제공하지 않습니다(%s 누락)", runtime.GOOS, runtime.GOARCH, name)
|
||||
return fmt.Errorf("该版本未提供适用于 %s/%s 的发布包(缺少 %s)", runtime.GOOS, runtime.GOARCH, name)
|
||||
}
|
||||
|
||||
prog(PhaseDownload, 0, "체크섬 목록을 가져오는 중…")
|
||||
prog(PhaseDownload, 0, "正在获取校验和列表…")
|
||||
sums, err := fetchSums(ctx, c, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
want, ok := sums[name]
|
||||
if !ok {
|
||||
return fmt.Errorf("%s 에 %s 항목이 없어 검증되지 않은 바이너리 설치를 거부합니다", sumsAsset, name)
|
||||
return fmt.Errorf("%s 中没有 %s 条目,拒绝安装未经验证的二进制文件", sumsAsset, name)
|
||||
}
|
||||
|
||||
// 临时文件全部落在目标目录里,保证最后的 rename 是同一文件系统内的原子操作
|
||||
@@ -81,37 +81,37 @@ func Stage(ctx context.Context, c *http.Client, rel *Release, currentVersion str
|
||||
_ = os.Remove(binPath)
|
||||
}()
|
||||
|
||||
prog(PhaseDownload, 0, fmt.Sprintf("%s 내려받는 중(%s)…", name, humanSize(asset.Size)))
|
||||
prog(PhaseDownload, 0, fmt.Sprintf("正在下载 %s(%s)…", name, humanSize(asset.Size)))
|
||||
got, err := download(ctx, c, asset, zipPath, prog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prog(PhaseVerify, -1, "SHA256 검증 중…")
|
||||
prog(PhaseVerify, -1, "正在校验 SHA256…")
|
||||
if !strings.EqualFold(got, want) {
|
||||
return fmt.Errorf("SHA256 이 일치하지 않습니다: 기대값 %s, 실제값 %s(다운로드가 손상됐거나 변조됨)", short(want), short(got))
|
||||
return fmt.Errorf("SHA256 不匹配: 期待值 %s, 实际值 %s(下载已损坏或被篡改)", short(want), short(got))
|
||||
}
|
||||
|
||||
prog(PhaseExtract, -1, "압축을 풀고 스모크 테스트 중…")
|
||||
prog(PhaseExtract, -1, "正在解压并进行冒烟测试…")
|
||||
if err := extractBinary(zipPath, binPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := smokeTest(binPath); err != nil {
|
||||
return fmt.Errorf("새 버전이 현재 시스템에서 실행되지 않습니다: %w", err)
|
||||
return fmt.Errorf("新版本在当前系统上无法运行: %w", err)
|
||||
}
|
||||
|
||||
// 暂存件自己的 sha256 单独存一份:下次启动换装前还要再校验一次,
|
||||
// 防止暂存后到重启前这段时间里文件被改动或写坏。
|
||||
binSum, err := fileSHA256(binPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("새 바이너리 체크섬 계산 실패: %w", err)
|
||||
return fmt.Errorf("新二进制文件校验和计算失败: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(p.Sum, []byte(binSum), 0o644); err != nil {
|
||||
return fmt.Errorf("체크섬 기록 실패: %w", err)
|
||||
return fmt.Errorf("校验和记录失败: %w", err)
|
||||
}
|
||||
if err := os.Rename(binPath, p.New); err != nil {
|
||||
_ = os.Remove(p.Sum)
|
||||
return fmt.Errorf("새 버전 준비 실패: %w", err)
|
||||
return fmt.Errorf("新版本准备失败: %w", err)
|
||||
}
|
||||
|
||||
if err := writeMarker(p.Marker, marker{
|
||||
@@ -120,10 +120,10 @@ func Stage(ctx context.Context, c *http.Client, rel *Release, currentVersion str
|
||||
StagedAt: time.Now().Unix(),
|
||||
}); err != nil {
|
||||
// 标记只影响自动回滚能力,暂存件本身已就位,不因此中断升级。
|
||||
prog(PhaseStaged, -1, "경고: 업데이트 표시 파일 기록에 실패해 이번 업데이트는 자동 롤백 보호를 받지 못합니다")
|
||||
prog(PhaseStaged, -1, "警告: 更新标记文件写入失败,本次更新将不受自动回滚保护")
|
||||
}
|
||||
|
||||
prog(PhaseStaged, 100, "새 버전이 준비되었습니다. 재시작 중…")
|
||||
prog(PhaseStaged, 100, "新版本已准备就绪。正在重启…")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -131,21 +131,21 @@ func Stage(ctx context.Context, c *http.Client, rel *Release, currentVersion str
|
||||
func fetchSums(ctx context.Context, c *http.Client, rel *Release) (map[string]string, error) {
|
||||
asset, ok := rel.FindAsset(sumsAsset)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("이 릴리스에 %s 항목이 없어 무결성을 검증할 수 없으므로 업데이트를 거부합니다", sumsAsset)
|
||||
return nil, fmt.Errorf("该发布中没有 %s 条目,无法验证完整性,拒绝更新", sumsAsset)
|
||||
}
|
||||
body, err := get(ctx, c, asset.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s 내려받기 실패: %w", sumsAsset, err)
|
||||
return nil, fmt.Errorf("%s 下载失败: %w", sumsAsset, err)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(body, 1<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s 읽기 실패: %w", sumsAsset, err)
|
||||
return nil, fmt.Errorf("%s 读取失败: %w", sumsAsset, err)
|
||||
}
|
||||
out := parseSums(string(raw))
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("%s 내용이 비어 있거나 형식을 인식할 수 없습니다", sumsAsset)
|
||||
return nil, fmt.Errorf("%s 内容为空或格式无法识别", sumsAsset)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -191,26 +191,26 @@ func isHexSHA256(s string) bool {
|
||||
func download(ctx context.Context, c *http.Client, a Asset, dst string, prog Progress) (string, error) {
|
||||
body, err := get(ctx, c, a.URL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s 내려받기 실패: %w", a.Name, err)
|
||||
return "", fmt.Errorf("%s 下载失败: %w", a.Name, err)
|
||||
}
|
||||
defer body.Close()
|
||||
|
||||
f, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("임시 파일 생성 실패: %w", err)
|
||||
return "", fmt.Errorf("临时文件创建失败: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
pw := &progressWriter{total: a.Size, prog: prog, name: a.Name, last: time.Now()}
|
||||
if _, err := io.Copy(io.MultiWriter(f, h, pw), body); err != nil {
|
||||
return "", fmt.Errorf("다운로드가 중단되었습니다: %w", err)
|
||||
return "", fmt.Errorf("下载已中断: %w", err)
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
return "", fmt.Errorf("디스크 기록 실패: %w", err)
|
||||
return "", fmt.Errorf("磁盘写入失败: %w", err)
|
||||
}
|
||||
if a.Size > 0 && pw.written != a.Size {
|
||||
return "", fmt.Errorf("다운로드가 완전하지 않습니다: 기대 %d바이트, 실제 %d바이트", a.Size, pw.written)
|
||||
return "", fmt.Errorf("下载不完整: 期待 %d 字节, 实际 %d 字节", a.Size, pw.written)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func extractBinary(zipPath, dst string) error {
|
||||
}
|
||||
zr, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("릴리스 패키지 열기 실패: %w", err)
|
||||
return fmt.Errorf("发布包打开失败: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
@@ -257,29 +257,29 @@ func extractBinary(zipPath, dst string) error {
|
||||
}
|
||||
rc, err := entry.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 읽기 실패: %w", entry.Name, err)
|
||||
return fmt.Errorf("%s 读取失败: %w", entry.Name, err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
f, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("새 바이너리 쓰기 실패: %w", err)
|
||||
return fmt.Errorf("新二进制文件写入失败: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, io.LimitReader(rc, maxBinarySize+1))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 압축 해제 실패: %w", entry.Name, err)
|
||||
return fmt.Errorf("%s 解压失败: %w", entry.Name, err)
|
||||
}
|
||||
if n > maxBinarySize {
|
||||
return fmt.Errorf("릴리스 패키지 안의 실행 파일이 %s 보다 커서 압축 해제를 거부합니다", humanSize(maxBinarySize))
|
||||
return fmt.Errorf("发布包内的可执行文件超过 %s,拒绝解压", humanSize(maxBinarySize))
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("릴리스 패키지 안의 %s 파일이 비어 있습니다", want)
|
||||
return fmt.Errorf("发布包内的 %s 文件为空", want)
|
||||
}
|
||||
return f.Sync()
|
||||
}
|
||||
return fmt.Errorf("릴리스 패키지에서 %s 파일을 찾지 못했습니다", want)
|
||||
return fmt.Errorf("在发布包中未找到 %s 文件", want)
|
||||
}
|
||||
|
||||
// checkWritable 提前确认目录可写。没有这一步,非 root 运行、或二进制被放在系统
|
||||
@@ -287,7 +287,7 @@ func extractBinary(zipPath, dst string) error {
|
||||
func checkWritable(dir string) error {
|
||||
probe, err := os.CreateTemp(dir, ".artex-update-probe-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("프로그램 디렉터리 %s 에 쓸 수 없어 자동 업데이트를 할 수 없습니다(권한을 확인하거나 수동 업데이트를 사용하세요): %w", dir, err)
|
||||
return fmt.Errorf("无法写入程序目录 %s,无法自动更新(请检查权限或使用手动更新): %w", dir, err)
|
||||
}
|
||||
name := probe.Name()
|
||||
_ = probe.Close()
|
||||
@@ -314,7 +314,7 @@ func (w *progressWriter) Write(b []byte) (int, error) {
|
||||
if w.total > 0 {
|
||||
pct = int(w.written * 100 / w.total)
|
||||
}
|
||||
w.prog(PhaseDownload, pct, fmt.Sprintf("내려받는 중 %s / %s", humanSize(w.written), humanSize(w.total)))
|
||||
w.prog(PhaseDownload, pct, fmt.Sprintf("正在下载 %s / %s", humanSize(w.written), humanSize(w.total)))
|
||||
return len(b), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -17,10 +17,10 @@ import (
|
||||
// 않는다). 필드명과 kind 열거값(pattern·exact_ip·cidr·kind)은 클라이언트가 그대로
|
||||
// 주고받는 와이어 식별자라 번역하지 않고 원문을 유지한다.
|
||||
const (
|
||||
errAssetInterceptPatternEmpty = "패턴을 입력하세요"
|
||||
errAssetInterceptInvalidExactIPFmt = "exact_ip 는 올바른 IP 주소여야 합니다: %s"
|
||||
errAssetInterceptInvalidCIDRFmt = "cidr 는 올바른 네트워크 대역이어야 합니다(예: 192.168.0.0/16): %s"
|
||||
errAssetInterceptInvalidKindFmt = "kind 값이 올바르지 않습니다: %s"
|
||||
errAssetInterceptPatternEmpty = "请输入模式"
|
||||
errAssetInterceptInvalidExactIPFmt = "exact_ip 必须是合法的 IP 地址: %s"
|
||||
errAssetInterceptInvalidCIDRFmt = "cidr 必须是合法的网络段(例如: 192.168.0.0/16): %s"
|
||||
errAssetInterceptInvalidKindFmt = "kind 的值无效: %s"
|
||||
)
|
||||
|
||||
// --- asset intercept rule CRUD ---
|
||||
|
||||
@@ -6,7 +6,7 @@ import "testing"
|
||||
// 오류 문구를 한국어로 유지하는 회귀 방어 테스트다. 두 검증기
|
||||
// (validateAssetInterceptRuleReq·validateTaskInterceptRuleReq)는 DB 를 쓰지 않는 순수
|
||||
// 함수라, 상수 핀 고정에 더해 실제 오류 경로를 직접 호출해 반환 문구까지 검증한다.
|
||||
// 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
// 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
// 필드명·열거값(pattern·exact_ip·cidr·kind·action·block·allow)은 와이어 식별자라 ASCII 로
|
||||
// 남으며 한자 판정에서 걸리지 않는다. 누군가 둘러싼 설명을 중국어로 되돌리면 실패한다.
|
||||
func TestAssetInterceptErrorsLocalized(t *testing.T) {
|
||||
@@ -18,7 +18,7 @@ func TestAssetInterceptErrorsLocalized(t *testing.T) {
|
||||
{"bad_kind", errAssetInterceptInvalidKindFmt},
|
||||
{"bad_action", errTaskInterceptInvalidAction},
|
||||
} {
|
||||
assertKoreanError(t, c.label, c.msg)
|
||||
assertChineseError(t, c.label, c.msg)
|
||||
}
|
||||
|
||||
// 실제 경로: validateAssetInterceptRuleReq 의 네 거부 분기
|
||||
@@ -36,7 +36,7 @@ func TestAssetInterceptErrorsLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("%s: 오류가 없습니다(검증을 통과함)", c.label)
|
||||
}
|
||||
assertKoreanError(t, c.label, err.Error())
|
||||
assertChineseError(t, c.label, err.Error())
|
||||
}
|
||||
|
||||
// 유효 입력은 거부되지 않는다
|
||||
@@ -50,7 +50,7 @@ func TestAssetInterceptErrorsLocalized(t *testing.T) {
|
||||
if err := validateTaskInterceptRuleReq(&badAction); err == nil {
|
||||
t.Fatalf("bad_action_path: 오류가 없습니다(검증을 통과함)")
|
||||
} else {
|
||||
assertKoreanError(t, "bad_action_path", err.Error())
|
||||
assertChineseError(t, "bad_action_path", err.Error())
|
||||
}
|
||||
|
||||
// action 공란은 block 기본값으로 정규화되어 통과한다
|
||||
|
||||
+4
-4
@@ -21,8 +21,8 @@ const maxCompanyMutationBodyBytes = 2 << 20
|
||||
// 회사·자산 API 가 사용자에게 돌려주는 오류 응답 문구. 용어집 기준으로 company 는
|
||||
// "회사"로 둔다. 사람이 읽는 메시지만 한국어로 두고 식별자·필드명은 원문 보존.
|
||||
const (
|
||||
errCompanyRequestTooLarge = "요청 본문이 너무 큽니다"
|
||||
errCompanyNameConflict = "이미 존재하는 회사 이름입니다"
|
||||
errCompanyRequestTooLarge = "请求正文过大"
|
||||
errCompanyNameConflict = "公司名称已存在"
|
||||
)
|
||||
|
||||
// 자산 출처(provenance) summary 라벨. task_asset_links.source_summary 에 저장돼 작업
|
||||
@@ -30,8 +30,8 @@ const (
|
||||
// 두어 같은 패널에서 출처 라벨이 언어별로 어긋나지 않게 한다(db.manualTaskScopeSummary
|
||||
// 도 같은 성격의 수동 추가 라벨). 비교·분기에 쓰이지 않는 표시 전용 값이다.
|
||||
const (
|
||||
taskAssetSourceAPISummary = "자산 API 로 등록"
|
||||
taskAssetSourceTaskSummary = "작업 설명 또는 목표로 초기화"
|
||||
taskAssetSourceAPISummary = "通过资产 API 注册"
|
||||
taskAssetSourceTaskSummary = "由任务描述或目标初始化"
|
||||
)
|
||||
|
||||
func decodeCompanyMutationRequest(w http.ResponseWriter, r *http.Request, value any) bool {
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestAssetErrorConstantsLocalized(t *testing.T) {
|
||||
"errCompanyNameConflict": errCompanyNameConflict,
|
||||
}
|
||||
for label, msg := range cases {
|
||||
assertKoreanError(t, label, msg)
|
||||
assertChineseError(t, label, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestTaskAssetProvenanceLocalized(t *testing.T) {
|
||||
"taskAssetSourceTaskSummary": taskAssetSourceTaskSummary,
|
||||
}
|
||||
for label, msg := range cases {
|
||||
assertKoreanError(t, label, msg)
|
||||
assertChineseError(t, label, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestAssetResponsesLocalized(t *testing.T) {
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("scope+asset_ids status=%d, want 400 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertKoreanError(t, "scope-conflict", decodeErrorField(t, rec.Body.Bytes()))
|
||||
assertChineseError(t, "scope-conflict", decodeErrorField(t, rec.Body.Bytes()))
|
||||
|
||||
// attachTaskAssets — an oversized body trips MaxBytesReader during decode.
|
||||
bigReq := httptest.NewRequest(http.MethodPost, "/api/tasks/1/assets",
|
||||
@@ -64,7 +64,7 @@ func TestAssetResponsesLocalized(t *testing.T) {
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("task asset too-large status=%d, want 413 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertKoreanError(t, "task-asset-too-large", decodeErrorField(t, rec.Body.Bytes()))
|
||||
assertChineseError(t, "task-asset-too-large", decodeErrorField(t, rec.Body.Bytes()))
|
||||
|
||||
// decodeCompanyMutationRequest — a body-only helper, no DB or Server state.
|
||||
compReq := httptest.NewRequest(http.MethodPost, "/api/companies",
|
||||
@@ -77,5 +77,5 @@ func TestAssetResponsesLocalized(t *testing.T) {
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("company too-large status=%d, want 413 (body %s)", rec.Code, rec.Body.String())
|
||||
}
|
||||
assertKoreanError(t, "company-too-large", decodeErrorField(t, rec.Body.Bytes()))
|
||||
assertChineseError(t, "company-too-large", decodeErrorField(t, rec.Body.Bytes()))
|
||||
}
|
||||
|
||||
+14
-14
@@ -27,18 +27,18 @@ const (
|
||||
// 오류 문구는 로그인 화면(web messages auth.login.errorCredential)과 표기를 맞췄다.
|
||||
// token 은 기술 용어라 원문 그대로 둔다(로그·주석은 BRIEF 방침상 최하위라 손대지 않음).
|
||||
const (
|
||||
authErrUnauthorized = "인증이 필요합니다"
|
||||
authErrTokenInvalid = "token 이 유효하지 않거나 만료되었습니다"
|
||||
authErrPasswordAlreadySet = "비밀번호가 이미 설정되어 있습니다"
|
||||
authErrPasswordEmpty = "비밀번호를 입력해 주세요"
|
||||
authErrNewPasswordEmpty = "새 비밀번호를 입력해 주세요"
|
||||
authErrPasswordHash = "비밀번호 암호화에 실패했습니다"
|
||||
authErrSaveFailedPrefix = "저장에 실패했습니다: "
|
||||
authErrTokenGen = "token 생성에 실패했습니다"
|
||||
authErrBadRequest = "요청 형식이 올바르지 않습니다"
|
||||
authErrPasswordNotInit = "비밀번호가 초기화되지 않았습니다. 먼저 비밀번호를 설정해 주세요"
|
||||
authErrCurrentPasswordWrong = "현재 비밀번호가 올바르지 않습니다"
|
||||
authErrBadCredential = "사용자 이름 또는 비밀번호가 올바르지 않습니다"
|
||||
authErrUnauthorized = "需要认证"
|
||||
authErrTokenInvalid = "token 无效或已过期"
|
||||
authErrPasswordAlreadySet = "密码已设置"
|
||||
authErrPasswordEmpty = "请输入密码"
|
||||
authErrNewPasswordEmpty = "请输入新密码"
|
||||
authErrPasswordHash = "密码加密失败"
|
||||
authErrSaveFailedPrefix = "保存失败: "
|
||||
authErrTokenGen = "token 生成失败"
|
||||
authErrBadRequest = "请求格式无效"
|
||||
authErrPasswordNotInit = "密码尚未初始化,请先设置密码"
|
||||
authErrCurrentPasswordWrong = "当前密码不正确"
|
||||
authErrBadCredential = "用户名或密码不正确"
|
||||
)
|
||||
|
||||
// loadOrCreateJWTKey reads the 32-byte signing key from keyDir/jwt.key. keyDir is
|
||||
@@ -56,7 +56,7 @@ func loadOrCreateJWTKey(keyDir, dataDir string) ([]byte, error) {
|
||||
if data, rerr := os.ReadFile(legacy); rerr == nil {
|
||||
if werr := os.WriteFile(path, data, 0o600); werr == nil {
|
||||
_ = os.Remove(legacy)
|
||||
log.Printf("[auth] JWT 키를 %s 에서 %s 로 이전(탐색 가능한 워크스페이스 밖으로 이동)", legacy, path)
|
||||
log.Printf("[auth] 已将 JWT 密钥从 %s 迁移到 %s(移出可被探测的工作区)", legacy, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ func loadOrCreateJWTKey(keyDir, dataDir string) ([]byte, error) {
|
||||
if err := os.WriteFile(path, buf, 0600); err != nil {
|
||||
return nil, fmt.Errorf("write jwt key: %w", err)
|
||||
}
|
||||
log.Printf("[auth] 새 JWT 키를 %s 에 기록", path)
|
||||
log.Printf("[auth] 已将新 JWT 密钥写入 %s", path)
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// 인증 응답 문구의 한국어화(백로그 F3b)를 지키는 회귀 방어 테스트다. DB 가 없어도
|
||||
// 도는 두 경로로 확인한다: ① requireAuth 미들웨어는 pg 를 거치지 않으므로 실제 HTTP
|
||||
// 응답 본문까지 검사하고, ② 나머지 핸들러 문구는 명명 상수라 상수 자체를 검사한다.
|
||||
// 한국어 판정은 F3a 가 만든 assertKoreanError 헬퍼(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
// 한국어 판정은 F3a 가 만든 assertChineseError 헬퍼(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
|
||||
// TestRequireAuthMessagesLocalized 는 토큰이 없거나 잘못됐을 때 requireAuth 가 돌려주는
|
||||
// 401 응답 본문이 한국어이고 중국어 한자가 없음을 실제 HTTP 핸들러로 확인한다. 이
|
||||
@@ -46,7 +46,7 @@ func TestRequireAuthMessagesLocalized(t *testing.T) {
|
||||
if !strings.Contains(body, c.want) {
|
||||
t.Errorf("응답 본문에 %q 가 없습니다: %s", c.want, body)
|
||||
}
|
||||
assertKoreanError(t, c.name, body)
|
||||
assertChineseError(t, c.name, body)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,6 @@ func TestAuthErrorConstantsLocalized(t *testing.T) {
|
||||
"authErrBadCredential": authErrBadCredential,
|
||||
}
|
||||
for name, msg := range consts {
|
||||
assertKoreanError(t, name, msg)
|
||||
assertChineseError(t, name, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,12 +21,12 @@ const maxChatMentions = 10
|
||||
// chatMentionKinds)과 에이전트 입력 스냅샷 헤더·절단 표시(composeChatMentionMessage
|
||||
// 안)는 표시 문구가 아니라 두뇌 입력 형식이라 원문 그대로 둔다.
|
||||
const (
|
||||
errChatMentionBadID = "인용 ID 가 올바르지 않습니다. 다시 선택해 주세요"
|
||||
errChatMentionTooMany = "메시지 한 건에는 레코드를 최대 10개까지 인용할 수 있습니다"
|
||||
errChatMentionBadSearch = "인용 유형이 올바르지 않거나 검색어가 200자를 초과했습니다"
|
||||
errChatMentionDataUnavail = "인용 데이터를 현재 사용할 수 없습니다"
|
||||
errChatMentionTooLarge = "인용한 내용이 너무 큽니다. 인용 레코드를 줄인 뒤 다시 시도해 주세요"
|
||||
errChatMentionNotFoundFmt = "인용한 %s #%d 레코드가 존재하지 않거나 유형이 일치하지 않습니다. 제거한 뒤 다시 선택해 주세요"
|
||||
errChatMentionBadID = "引用 ID 无效,请重新选择"
|
||||
errChatMentionTooMany = "单条消息最多可引用 10 条记录"
|
||||
errChatMentionBadSearch = "引用类型无效或搜索词超过 200 个字符"
|
||||
errChatMentionDataUnavail = "引用数据当前不可用"
|
||||
errChatMentionTooLarge = "引用的内容过大,请减少引用记录后重试"
|
||||
errChatMentionNotFoundFmt = "引用的 %s #%d 记录不存在或类型不匹配,请移除后重新选择"
|
||||
)
|
||||
|
||||
// chatMentionKindLabel: 사용자 노출 오류에서만 쓰는 표시 전용 한국어 라벨.
|
||||
@@ -34,8 +34,8 @@ const (
|
||||
// chatMentionKinds)과 에이전트 입력(ref.Name, composeChatMentionMessage 안)은
|
||||
// 중국어 라벨을 그대로 유지한다 — 프론트 멘션 칩 표시 i18n(F30)과 분리된 별건.
|
||||
var chatMentionKindLabel = map[string]string{
|
||||
"finding": "취약점", "asset": "자산", "company": "기업", "endpoint": "엔드포인트",
|
||||
"ip": "IP", "app": "앱", "root_domain": "도메인", "subdomain": "서브도메인", "service": "서비스",
|
||||
"finding": "漏洞", "asset": "资产", "company": "企业", "endpoint": "接口",
|
||||
"ip": "IP", "app": "应用", "root_domain": "域名", "subdomain": "子域名", "service": "服务",
|
||||
}
|
||||
|
||||
// The visible token survives drafts, uploads, retries and conversation history.
|
||||
|
||||
@@ -22,14 +22,14 @@ func TestChatMentionErrorsLocalized(t *testing.T) {
|
||||
"dataUnavail": errChatMentionDataUnavail,
|
||||
"tooLarge": errChatMentionTooLarge,
|
||||
} {
|
||||
assertKoreanError(t, label, msg)
|
||||
assertChineseError(t, label, msg)
|
||||
}
|
||||
|
||||
// parseChatMentions — a non-positive id inside a valid wire token.
|
||||
if _, err := parseChatMentions("@[漏洞#0]"); err == nil {
|
||||
t.Fatal("잘못된 인용 ID 가 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "parse.badID", err.Error())
|
||||
assertChineseError(t, "parse.badID", err.Error())
|
||||
}
|
||||
|
||||
// parseChatMentions — exceeding the per-message mention cap (11 distinct ids).
|
||||
@@ -40,14 +40,14 @@ func TestChatMentionErrorsLocalized(t *testing.T) {
|
||||
if _, err := parseChatMentions(b.String()); err == nil {
|
||||
t.Fatal("인용 상한 초과가 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "parse.tooMany", err.Error())
|
||||
assertChineseError(t, "parse.tooMany", err.Error())
|
||||
}
|
||||
|
||||
// composeChatMentionMessage — a mention is present but the database is nil.
|
||||
if _, err := composeChatMentionMessage(nil, "@[漏洞#1]"); err == nil {
|
||||
t.Fatal("DB 없이 인용 해석이 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "compose.dataUnavail", err.Error())
|
||||
assertChineseError(t, "compose.dataUnavail", err.Error())
|
||||
}
|
||||
|
||||
// searchChatMentions — invalid kind and over-long query are rejected before
|
||||
@@ -58,7 +58,7 @@ func TestChatMentionErrorsLocalized(t *testing.T) {
|
||||
if w.Code != 400 {
|
||||
t.Fatalf("검증 실패(400)를 기대했으나 %d 가 반환되었습니다 (%s)", w.Code, q)
|
||||
}
|
||||
assertKoreanError(t, "search."+q, decodeErrorField(t, w.Body.Bytes()))
|
||||
assertChineseError(t, "search."+q, decodeErrorField(t, w.Body.Bytes()))
|
||||
}
|
||||
|
||||
// The display label map mirrors the UI mention kinds, and the not-found
|
||||
@@ -68,5 +68,5 @@ func TestChatMentionErrorsLocalized(t *testing.T) {
|
||||
t.Fatalf("종류 라벨이 누락되었습니다: %s", kind)
|
||||
}
|
||||
}
|
||||
assertKoreanError(t, "notFound", fmt.Sprintf(errChatMentionNotFoundFmt, chatMentionKindLabel["finding"], 7))
|
||||
assertChineseError(t, "notFound", fmt.Sprintf(errChatMentionNotFoundFmt, chatMentionKindLabel["finding"], 7))
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ const maxChatUpload = 128 << 20 // 128 MiB
|
||||
// 는 workspace.go errWsIllegalPath("잘못된 경로입니다")와 같은 꼴이다. ...失败 세 종류는
|
||||
// task_archives.go:229 선례처럼 접두 상수 + err.Error() 로 이어 붙인다.
|
||||
const (
|
||||
errChatUploadScopeInvalid = "scope 값은 task, session, staging 중 하나여야 합니다"
|
||||
errChatUploadBadID = "잘못된 id입니다"
|
||||
errChatUploadTaskDeleting = "작업을 삭제하는 중이라 첨부 파일을 업로드할 수 없습니다"
|
||||
errChatUploadMkdir = "디렉터리를 만들지 못했습니다: "
|
||||
errChatUploadParse = "업로드를 해석하지 못했거나 크기 제한을 초과했습니다: "
|
||||
errChatUploadNoFile = "업로드할 파일이 없습니다(폼 필드 file)"
|
||||
errChatUploadSaveFailed = "저장하지 못했습니다: "
|
||||
errChatUploadScopeInvalid = "scope 值必须是 task、session、staging 之一"
|
||||
errChatUploadBadID = "id 无效"
|
||||
errChatUploadTaskDeleting = "正在删除任务,无法上传附件"
|
||||
errChatUploadMkdir = "无法创建目录: "
|
||||
errChatUploadParse = "无法解析上传内容或超出大小限制: "
|
||||
errChatUploadNoFile = "没有要上传的文件(表单字段 file)"
|
||||
errChatUploadSaveFailed = "保存失败: "
|
||||
)
|
||||
|
||||
// safeChatID guards the {id} path segment against traversal — task ids are numeric,
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// chatupload.go 의 채팅 첨부 업로드 API 에러 응답을 한국어로 유지하는 회귀 방어 테스트다.
|
||||
// 한국어 판정은 F3a 의 assertKoreanError(한글 포함·중국어 한자 0)를, 응답 본문 추출은
|
||||
// 한국어 판정은 F3a 의 assertChineseError(한글 포함·중국어 한자 0)를, 응답 본문 추출은
|
||||
// task_categories 테스트의 decodeErrorField 를 재사용한다(같은 package server).
|
||||
|
||||
// TestChatUploadErrorConstantsLocalized 는 응답 상수 7종이 전부 한국어임을 단언한다.
|
||||
@@ -26,7 +26,7 @@ func TestChatUploadErrorConstantsLocalized(t *testing.T) {
|
||||
"save_failed": errChatUploadSaveFailed,
|
||||
}
|
||||
for label, msg := range cases {
|
||||
assertKoreanError(t, label, msg)
|
||||
assertChineseError(t, label, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestChatUploadHandlerResponsesLocalized(t *testing.T) {
|
||||
if got := decodeErrorField(t, rec.Body.Bytes()); got != errChatUploadScopeInvalid {
|
||||
t.Fatalf("응답 문구 = %q, 기대 = %q", got, errChatUploadScopeInvalid)
|
||||
}
|
||||
assertKoreanError(t, "scope-invalid", errChatUploadScopeInvalid)
|
||||
assertChineseError(t, "scope-invalid", errChatUploadScopeInvalid)
|
||||
})
|
||||
|
||||
t.Run("bad-id", func(t *testing.T) {
|
||||
@@ -70,7 +70,7 @@ func TestChatUploadHandlerResponsesLocalized(t *testing.T) {
|
||||
if got := decodeErrorField(t, rec.Body.Bytes()); got != errChatUploadBadID {
|
||||
t.Fatalf("응답 문구 = %q, 기대 = %q", got, errChatUploadBadID)
|
||||
}
|
||||
assertKoreanError(t, "bad-id", errChatUploadBadID)
|
||||
assertChineseError(t, "bad-id", errChatUploadBadID)
|
||||
})
|
||||
|
||||
t.Run("no-file", func(t *testing.T) {
|
||||
@@ -86,7 +86,7 @@ func TestChatUploadHandlerResponsesLocalized(t *testing.T) {
|
||||
if got := decodeErrorField(t, rec.Body.Bytes()); got != errChatUploadNoFile {
|
||||
t.Fatalf("응답 문구 = %q, 기대 = %q", got, errChatUploadNoFile)
|
||||
}
|
||||
assertKoreanError(t, "no-file", errChatUploadNoFile)
|
||||
assertChineseError(t, "no-file", errChatUploadNoFile)
|
||||
})
|
||||
|
||||
t.Run("task-deleting", func(t *testing.T) {
|
||||
@@ -101,6 +101,6 @@ func TestChatUploadHandlerResponsesLocalized(t *testing.T) {
|
||||
if got := decodeErrorField(t, rec.Body.Bytes()); got != errChatUploadTaskDeleting {
|
||||
t.Fatalf("응답 문구 = %q, 기대 = %q", got, errChatUploadTaskDeleting)
|
||||
}
|
||||
assertKoreanError(t, "task-deleting", errChatUploadTaskDeleting)
|
||||
assertChineseError(t, "task-deleting", errChatUploadTaskDeleting)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ const (
|
||||
// goals_api.go 의 errGoalTaskDeleting* 와 같은 문형이고, kind 검증은 intercept.go 의
|
||||
// "값은 … 중 하나여야 합니다" 패턴을 따른다.
|
||||
const (
|
||||
errConstraintTaskDeletingAdd = "작업을 삭제하는 중이라 제약을 추가할 수 없습니다"
|
||||
errConstraintTaskDeletingEdit = "작업을 삭제하는 중이라 제약을 수정할 수 없습니다"
|
||||
errConstraintTaskDeletingDelete = "작업을 삭제하는 중이라 제약을 삭제할 수 없습니다"
|
||||
errConstraintTextEmpty = "제약 내용은 비워 둘 수 없습니다"
|
||||
errConstraintKindInvalid = "kind 값은 allow 또는 deny 중 하나여야 합니다"
|
||||
errConstraintTaskDeletingAdd = "正在删除任务,无法添加约束"
|
||||
errConstraintTaskDeletingEdit = "正在删除任务,无法修改约束"
|
||||
errConstraintTaskDeletingDelete = "正在删除任务,无法删除约束"
|
||||
errConstraintTextEmpty = "约束内容不能为空"
|
||||
errConstraintKindInvalid = "kind 的值必须是 allow 或 deny 之一"
|
||||
)
|
||||
|
||||
// constraintInjectPlanner / constraintInjectWorker 报告是否把操作约束注入对应 agent 的
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// constraints_api.go 의 제약(约束) 관리 API 에러 응답을 한국어로 유지하는 회귀 방어
|
||||
// 테스트다. 한국어 판정은 F3a 의 assertKoreanError(한글 포함·중국어 한자 0)를, 응답 본문
|
||||
// 테스트다. 한국어 판정은 F3a 의 assertChineseError(한글 포함·중국어 한자 0)를, 응답 본문
|
||||
// 추출은 task_categories 테스트의 decodeErrorField 를 재사용한다(같은 package server).
|
||||
|
||||
// TestConstraintErrorConstantsLocalized 는 응답 상수 5종이 전부 한국어임을 단언한다.
|
||||
@@ -21,7 +21,7 @@ func TestConstraintErrorConstantsLocalized(t *testing.T) {
|
||||
"kind_invalid": errConstraintKindInvalid,
|
||||
}
|
||||
for label, msg := range cases {
|
||||
assertKoreanError(t, label, msg)
|
||||
assertChineseError(t, label, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func TestConstraintHandlersResponsesLocalized(t *testing.T) {
|
||||
if got != c.want {
|
||||
t.Fatalf("응답 문구 = %q, 기대 = %q", got, c.want)
|
||||
}
|
||||
assertKoreanError(t, c.name, got)
|
||||
assertChineseError(t, c.name, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+18
-18
@@ -34,18 +34,18 @@ const (
|
||||
// convRetest* 상수·transcriptErrorSummary 로 분리했다(F9). 트리거 메시지 골격은 아직
|
||||
// 이 묶음 밖이다(F10·저널 참조). 로그·주석은 BRIEF 방침상 최하위.
|
||||
const (
|
||||
convErrRequestTooLarge = "요청 본문이 너무 큽니다"
|
||||
convErrAgentKeyEmpty = "agent_key 는 비어 있을 수 없습니다"
|
||||
convErrAgentKeyTooLong = "agent_key 는 최대 %d자까지 입력할 수 있습니다"
|
||||
convErrAgentNotFound = "에이전트를 찾을 수 없습니다"
|
||||
convErrLLMProfile = "지정한 LLM 설정이 존재하지 않거나 API Key 가 설정되지 않았습니다"
|
||||
convErrTitleTooLong = "제목은 최대 %d자까지 입력할 수 있습니다"
|
||||
convErrTitleOrPinned = "title 또는 pinned 중 하나 이상을 제공해야 합니다"
|
||||
convErrTitleEmpty = "제목은 비어 있을 수 없습니다"
|
||||
convErrBadConvID = "대화 id 가 올바르지 않습니다"
|
||||
convErrIDsCount = "ids 개수는 1에서 %d 사이여야 합니다"
|
||||
convErrMessageEmpty = "메시지는 비어 있을 수 없습니다"
|
||||
convErrBusy = "이 대화가 이전 메시지를 처리하고 있습니다. 잠시 기다려 주세요"
|
||||
convErrRequestTooLarge = "请求正文过大"
|
||||
convErrAgentKeyEmpty = "agent_key 不能为空"
|
||||
convErrAgentKeyTooLong = "agent_key 最多可输入 %d 个字符"
|
||||
convErrAgentNotFound = "找不到该智能体"
|
||||
convErrLLMProfile = "指定的 LLM 配置不存在或未设置 API Key"
|
||||
convErrTitleTooLong = "标题最多可输入 %d 个字符"
|
||||
convErrTitleOrPinned = "必须至少提供 title 或 pinned 之一"
|
||||
convErrTitleEmpty = "标题不能为空"
|
||||
convErrBadConvID = "对话 id 无效"
|
||||
convErrIDsCount = "ids 数量必须在 1 到 %d 之间"
|
||||
convErrMessageEmpty = "消息不能为空"
|
||||
convErrBusy = "该对话正在处理上一条消息,请稍候"
|
||||
)
|
||||
|
||||
// 대화 기본 제목. convDefaultTitle 은 표시 문구이자 센티넬이다. 대화를 만들 때 제목으로
|
||||
@@ -53,8 +53,8 @@ const (
|
||||
// (sendConversationMessage). 대입하는 쪽과 비교하는 쪽이 어긋나면 자동 제목 분기가
|
||||
// 깨지므로 한 상수로 묶는다. convAttachmentTitle 은 첨부만 보낸 첫 메시지의 기본 제목이다.
|
||||
const (
|
||||
convDefaultTitle = "새 대화"
|
||||
convAttachmentTitle = "첨부 메시지"
|
||||
convDefaultTitle = "新对话"
|
||||
convAttachmentTitle = "附件消息"
|
||||
)
|
||||
|
||||
// 재검증(finding_retest) 종결 사유. runConversationTurn 이 재검증 대화를 봉인할 때 쓰고,
|
||||
@@ -63,9 +63,9 @@ const (
|
||||
// 같은 컬럼·패널이라 그 파일에서 함께 한국어로 둔다. 종결 상태 값("failed"/"stopped"/
|
||||
// "completed")은 StatusLabel 로 한국어 라벨에 매핑되는 센티넬이라 ASCII 로 유지한다(F9).
|
||||
const (
|
||||
convRetestFailedToStart = "재검증을 시작하지 못했습니다"
|
||||
convRetestStatusReadFailed = "재검증 상태를 읽지 못했습니다. 다시 시작해 주세요"
|
||||
convRetestStoppedOrClosed = "재검증이 중지되었거나 서비스가 종료되었습니다"
|
||||
convRetestFailedToStart = "无法开始复测"
|
||||
convRetestStatusReadFailed = "无法读取复测状态,请重新开始"
|
||||
convRetestStoppedOrClosed = "复测已停止或服务已关闭"
|
||||
)
|
||||
|
||||
// isDefaultConversationTitle 은 대화가 아직 자동 생성된 기본 제목(빈 값 또는
|
||||
@@ -551,7 +551,7 @@ func transcriptErrorSummary(label, errMsg string) string {
|
||||
if label != "" {
|
||||
label += " "
|
||||
}
|
||||
return "(" + label + "오류: " + errMsg + ")"
|
||||
return "(" + label + "错误: " + errMsg + ")"
|
||||
}
|
||||
|
||||
func (s *Server) runConversationTurn(ctx context.Context, cancel context.CancelCauseFunc, c *db.Conversation, msg, busyKey string) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// conversations.go 의 대화(채팅) API 에러 응답을 한국어로 유지하는 회귀 방어 테스트다.
|
||||
// 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
// 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
|
||||
// TestConversationErrorConstantsLocalized 는 응답 상수 12종이 전부 한국어임을 단언한다.
|
||||
// 핸들러는 s.pg(w)(DB)를 먼저 거쳐 DB 없는 이 호스트에서 끝까지 못 도므로, 상수 자체를
|
||||
@@ -34,7 +34,7 @@ func TestConversationErrorConstantsLocalized(t *testing.T) {
|
||||
{"busy", convErrBusy},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assertKoreanError(t, c.name, c.msg)
|
||||
assertChineseError(t, c.name, c.msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,15 +65,15 @@ func TestDecodeConversationRequestTooLargeLocalized(t *testing.T) {
|
||||
if resp.Error != convErrRequestTooLarge {
|
||||
t.Fatalf("응답 error = %q, 기대 = %q", resp.Error, convErrRequestTooLarge)
|
||||
}
|
||||
assertKoreanError(t, "decode.too_large.response", resp.Error)
|
||||
assertChineseError(t, "decode.too_large.response", resp.Error)
|
||||
}
|
||||
|
||||
// TestConversationDefaultTitlesLocalized 는 대화 기본 제목 두 상수(F8)가 한국어이고 서로
|
||||
// 구별됨을 단언한다. convDefaultTitle 은 생성 기본값이자 자동 제목 분기의 센티넬이므로,
|
||||
// 중국어 "新对话" 로 되돌아가면 사용자가 대화 목록·삭제 다이얼로그에서 중국어를 보게 된다.
|
||||
func TestConversationDefaultTitlesLocalized(t *testing.T) {
|
||||
assertKoreanError(t, "default_title", convDefaultTitle)
|
||||
assertKoreanError(t, "attachment_title", convAttachmentTitle)
|
||||
assertChineseError(t, "default_title", convDefaultTitle)
|
||||
assertChineseError(t, "attachment_title", convAttachmentTitle)
|
||||
if convDefaultTitle == convAttachmentTitle {
|
||||
t.Fatal("기본 제목과 첨부 기본 제목이 같으면 안 된다")
|
||||
}
|
||||
@@ -98,9 +98,9 @@ func TestIsDefaultConversationTitle(t *testing.T) {
|
||||
// 단언한다. 이 값들은 finding_retests.error 컬럼에 저장돼 재검증 패널 item.error 로
|
||||
// 노출되므로, 중국어로 되돌아가면 사용자가 패널에서 중국어 사유를 보게 된다.
|
||||
func TestConversationRetestReasonsLocalized(t *testing.T) {
|
||||
assertKoreanError(t, "retest_failed_to_start", convRetestFailedToStart)
|
||||
assertKoreanError(t, "retest_status_read_failed", convRetestStatusReadFailed)
|
||||
assertKoreanError(t, "retest_stopped_or_closed", convRetestStoppedOrClosed)
|
||||
assertChineseError(t, "retest_failed_to_start", convRetestFailedToStart)
|
||||
assertChineseError(t, "retest_status_read_failed", convRetestStatusReadFailed)
|
||||
assertChineseError(t, "retest_stopped_or_closed", convRetestStoppedOrClosed)
|
||||
}
|
||||
|
||||
// TestTranscriptErrorSummaryLocalized 는 활동 전사 오류 래퍼(F9)를 핀 고정한다. 채팅 턴
|
||||
@@ -113,8 +113,8 @@ func TestTranscriptErrorSummaryLocalized(t *testing.T) {
|
||||
errm string
|
||||
want string
|
||||
}{
|
||||
{"chat_turn", "", "connection reset", "(오류: connection reset)"},
|
||||
{"main_agent", "메인 에이전트", "connection reset", "(메인 에이전트 오류: connection reset)"},
|
||||
{"chat_turn", "", "connection reset", "(错误: connection reset)"},
|
||||
{"main_agent", "主 Agent", "connection reset", "(主 Agent 错误: connection reset)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := transcriptErrorSummary(c.label, c.errm)
|
||||
@@ -131,6 +131,6 @@ func TestTranscriptErrorSummaryLocalized(t *testing.T) {
|
||||
// 래퍼 라벨에 한글이 있고 중국어 한자가 없어야 한다(err 원문은 검사 대상이 아니라
|
||||
// ASCII 로 고정). 라벨이 없는 채팅 턴도 "오류" 한글을 포함한다.
|
||||
wrapper := strings.ReplaceAll(got, c.errm, "")
|
||||
assertKoreanError(t, c.name+".wrapper", wrapper)
|
||||
assertChineseError(t, c.name+".wrapper", wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,14 +46,14 @@ var reToolKey = reAgentKey // 同 agent key 规则:小写字母开头 + 小写
|
||||
// 오류 응답을 한국어로 고정한다. 에이전트가 읽는 도구 실행 결과(actool.Errorf)와 도구
|
||||
// 스키마 description 은 두뇌 경계라 중국어 원문을 보존한다(BRIEF 성능 보존 방침).
|
||||
const (
|
||||
errCustomToolKeyFormat = "key는 소문자로 시작하고 소문자·숫자·밑줄만 사용할 수 있습니다"
|
||||
errCustomToolKindInvalid = "kind 값은 command, script, http, shell 중 하나여야 합니다"
|
||||
errCustomToolHTTPSchemaRequired = "http 도구에는 매개변수 JSON Schema를 반드시 지정해야 합니다(비워 둘 수 없습니다)"
|
||||
errCustomToolKeyExists = "이미 존재하는 key입니다(내장 또는 사용자 지정 도구)"
|
||||
errCustomToolEditCustomOnly = "사용자 지정 도구만 편집할 수 있습니다"
|
||||
errCustomToolBadBody = "요청 본문이 올바르지 않습니다"
|
||||
errCustomToolShellNoExec = "shell 유형 도구는 bash 환경 선언이므로 실행할 내용이 없습니다"
|
||||
errCustomToolUnknownKindPrefix = "알 수 없는 도구 유형입니다: "
|
||||
errCustomToolKeyFormat = "key 必须以小写字母开头,且只能使用小写字母、数字和下划线"
|
||||
errCustomToolKindInvalid = "kind 值必须是 command, script, http, shell 之一"
|
||||
errCustomToolHTTPSchemaRequired = "http 工具必须指定参数 JSON Schema(不能为空)"
|
||||
errCustomToolKeyExists = "key 已存在(内置或自定义工具)"
|
||||
errCustomToolEditCustomOnly = "只能编辑自定义工具"
|
||||
errCustomToolBadBody = "请求正文不正确"
|
||||
errCustomToolShellNoExec = "shell 类型工具是 bash 环境声明,没有可执行内容"
|
||||
errCustomToolUnknownKindPrefix = "未知的工具类型: "
|
||||
)
|
||||
|
||||
func (s *Server) pgCreateCustomTool(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -223,7 +223,7 @@ func (s *Server) seedPythonInterpreter() {
|
||||
}
|
||||
if p := detectPython(); p != "" {
|
||||
_ = s.m.pg.SetSetting(settingPythonInterp, p)
|
||||
log.Printf("[custom-tool] python 인터프리터 자동 감지: %s", p)
|
||||
log.Printf("[custom-tool] 自动检测到 python 解释器: %s", p)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// customtool.go 의 사용자 지정 도구 CRUD·시험 실행 엔드포인트가 writeErr 로 돌려주는
|
||||
// 검증 오류 응답을 한국어로 유지하는 회귀 방어 테스트다. 한국어 판정은 F3a 의
|
||||
// assertKoreanError(한글 포함·중국어 한자 0), 응답 본문 추출은 task_categories 테스트의
|
||||
// assertChineseError(한글 포함·중국어 한자 0), 응답 본문 추출은 task_categories 테스트의
|
||||
// decodeErrorField 를 재사용한다(같은 package server). 에이전트가 읽는 actool.Errorf 도구
|
||||
// 결과와 도구 스키마 description 은 두뇌 경계라 번역 대상이 아니며 이 테스트도 건드리지 않는다.
|
||||
|
||||
@@ -31,7 +31,7 @@ func TestCustomToolErrorConstantsLocalized(t *testing.T) {
|
||||
{"shellNoExec", errCustomToolShellNoExec},
|
||||
{"unknownKindPrefix", errCustomToolUnknownKindPrefix},
|
||||
} {
|
||||
assertKoreanError(t, "customtool."+c.name, c.msg)
|
||||
assertChineseError(t, "customtool."+c.name, c.msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestCustomToolCreateValidationLocalized(t *testing.T) {
|
||||
if got != c.want {
|
||||
t.Fatalf("응답 본문 불일치: got %q want %q", got, c.want)
|
||||
}
|
||||
assertKoreanError(t, "customtool.create."+c.name, got)
|
||||
assertChineseError(t, "customtool.create."+c.name, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ func TestCustomToolTestRunValidationLocalized(t *testing.T) {
|
||||
t.Fatalf("응답 본문 불일치: got %q want %q", got, c.want)
|
||||
}
|
||||
// unknown-kind 는 뒤에 req.Kind("bogus") 라틴 꼬리표가 붙으므로 한자 0 만 확인한다.
|
||||
assertKoreanError(t, "customtool.test."+c.name, errCustomToolUnknownKindPrefix)
|
||||
assertChineseError(t, "customtool.test."+c.name, errCustomToolUnknownKindPrefix)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -86,10 +86,10 @@ var errWorkControlConflict = errors.New("work control conflict")
|
||||
// (각 지점 주석 참조). 의도가 더 이상 paused 상태가 아니라는 재개 충돌 문구는 task_control.go
|
||||
// 의 errIntentCtrlStateConflictFmt 를 재사용해 단일 출처를 유지한다.
|
||||
const (
|
||||
errWorkControlNoRunningWorkFmt = "%w: 의도 %d 에 실행 중인 Worker 가 없습니다 (이미 종료되었거나 아직 할당되지 않았을 수 있습니다)"
|
||||
errWorkControlBusyFmt = "%w: 의도 %d 에 대해 이미 %s 제어가 진행 중입니다"
|
||||
errWorkControlWaitFmt = "의도 %d 의 %s 마무리를 기다리는 중 오류가 발생했습니다: %w"
|
||||
errDetachedWorkerNotReady = "Worker 가 아직 준비되지 않았습니다"
|
||||
errWorkControlNoRunningWorkFmt = "%w: 意图 %d 没有正在运行的 Worker(可能已结束或尚未分配)"
|
||||
errWorkControlBusyFmt = "%w: 意图 %d 的 %s 控制已在进行中"
|
||||
errWorkControlWaitFmt = "等待意图 %d 的 %s 收尾时发生错误: %w"
|
||||
errDetachedWorkerNotReady = "Worker 尚未准备好"
|
||||
)
|
||||
|
||||
// retryableWorkerModelError excludes errors already handled by the task router.
|
||||
@@ -668,8 +668,8 @@ func (e *Engine) Broadcaster() *Broadcaster { return e.bc }
|
||||
// 이 요약들은 node_id 를 달지 않는다. 그래서 한국어화해도 두뇌 입력(BRIEF 경계 #1)을
|
||||
// 건드리지 않는다. 포맷 인자(%d)는 원형 보존. [[G132]]
|
||||
const (
|
||||
goallessTaskDoneSummary = "모든 목표를 달성했고 직접 투입한 의도의 실행도 끝나, 작업을 종료합니다"
|
||||
plannerRoundSummaryFmt = "%d차 계획 수립"
|
||||
goallessTaskDoneSummary = "所有目标均已达成,直接投入的意图也已执行完毕,任务结束"
|
||||
plannerRoundSummaryFmt = "第 %d 轮规划"
|
||||
)
|
||||
|
||||
func (e *Engine) emitActivity(t *Task, r db.Activity) db.Activity {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
// engine.go 의 작업 제어·의도 개입 오류 중 "사용자 노출" 문구를 한국어로 유지하는 회귀
|
||||
// 방어 테스트다. 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어 한자 0)를
|
||||
// 방어 테스트다. 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어 한자 0)를
|
||||
// 재사용한다.
|
||||
//
|
||||
// 호출 그래프 판정(engine.go 상수 블록 주석 참조):
|
||||
@@ -36,7 +36,7 @@ func TestControlWorkNoRunningWorkErrorLocalized(t *testing.T) {
|
||||
if !errors.Is(err, errWorkControlConflict) {
|
||||
t.Fatalf("errors.Is(err, errWorkControlConflict) = false, err=%v", err)
|
||||
}
|
||||
assertKoreanError(t, "control_work_no_running", err.Error())
|
||||
assertChineseError(t, "control_work_no_running", err.Error())
|
||||
}
|
||||
|
||||
// TestEngineUserFacingErrorsLocalized 는 엔진/DB 게이트나 고루틴 타이밍 뒤에 있어 끝까지
|
||||
@@ -51,6 +51,6 @@ func TestEngineUserFacingErrorsLocalized(t *testing.T) {
|
||||
{"detached_worker_not_ready", errDetachedWorkerNotReady},
|
||||
{"detached_state_conflict", fmt.Errorf(errIntentCtrlStateConflictFmt, db.ErrIntentStateConflict).Error()},
|
||||
} {
|
||||
assertKoreanError(t, c.label, c.msg)
|
||||
assertChineseError(t, c.label, c.msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +231,7 @@ func (e *Engine) settleTask(ctx context.Context, t *Task) {
|
||||
// LLM to be ready (bounded by ctx) so a completable task isn't mis-judged timeout.
|
||||
// timeoutFinalRoundSummaryFmt 는 작업 시간 초과 시 마지막 계획 라운드의 표시 전용
|
||||
// 활동 요약이다(node_id 없음·전사에만 노출·되먹임 경로 미접촉). [[G132]]
|
||||
const timeoutFinalRoundSummaryFmt = "작업 시간 초과 마무리·최종 판정(%d차)"
|
||||
const timeoutFinalRoundSummaryFmt = "任务超时收尾·最终判定(第 %d 轮)"
|
||||
|
||||
func (e *Engine) runFinalPlannerRound(ctx context.Context, t *Task) (met bool) {
|
||||
if e.IsDeleting(t.ID) {
|
||||
|
||||
@@ -26,10 +26,10 @@ import (
|
||||
// 이름(漏洞复测)·프로필·note(内置默认)는 에이전트가 읽는 두뇌 입력이거나 시드라
|
||||
// 원문을 보존한다(각 지점 주석 참조, F16 두뇌 경계 계열).
|
||||
const (
|
||||
errFindingRetestNotesTooLong = "재검증 보충 설명은 최대 4000자까지 입력할 수 있습니다"
|
||||
errFindingRetestAgentMissing = "취약점 재검증 에이전트가 없거나 비활성화되어 있습니다. 에이전트 관리에서 retester 를 설정하세요"
|
||||
errFindingRetestToolRequired = "재검증 에이전트에 도구를 활성화하고 연결하세요: "
|
||||
errFindingRetestServiceStopping = "서비스가 종료 중입니다"
|
||||
errFindingRetestNotesTooLong = "复测补充说明最多可输入 4000 个字符"
|
||||
errFindingRetestAgentMissing = "漏洞复测 Agent 不存在或已禁用。请在 Agent 管理中设置 retester"
|
||||
errFindingRetestToolRequired = "请为复测 Agent 启用并连接工具: "
|
||||
errFindingRetestServiceStopping = "服务正在关闭"
|
||||
)
|
||||
|
||||
func (s *Server) listActiveFindingRetests(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package server
|
||||
import "testing"
|
||||
|
||||
// finding_retests.go 의 사용자 노출 HTTP 에러 응답 문구를 한국어로 유지하는 회귀 방어
|
||||
// 테스트다. 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어 한자 0)를
|
||||
// 테스트다. 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어 한자 0)를
|
||||
// 재사용한다. 네 문구를 반환하는 startFindingRetest 핸들러는 맨 앞에서 s.pg(w)(DB)
|
||||
// 게이트를 지나므로 DB 없는 이 호스트에서 끝까지 돌 수 없어, 상수 자체를 핀 고정한다
|
||||
// (finding_traffic·goals_api·notify_api 의 DB 게이트 경로와 같은 방식). 누군가 이
|
||||
@@ -22,6 +22,6 @@ func TestFindingRetestErrorsLocalized(t *testing.T) {
|
||||
{"tool_required", errFindingRetestToolRequired},
|
||||
{"service_stopping", errFindingRetestServiceStopping},
|
||||
} {
|
||||
assertKoreanError(t, c.label, c.msg)
|
||||
assertChineseError(t, c.label, c.msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,10 @@ import (
|
||||
// roTool("get_finding_traffic") 도구 설명은 에이전트가 읽는 두뇌 입력이라 원문을
|
||||
// 보존한다(각 지점 주석 참조, F16 두뇌 경계 계열).
|
||||
const (
|
||||
errFindingTrafficInheritedReadonly = "상속된 취약점은 읽기 전용이라 출처 작업에서 수정해야 합니다"
|
||||
errFindingTrafficSelectRequired = "트래픽을 선택하세요"
|
||||
errFindingTrafficVersionRequired = "version 과 유효한 요청 본문은 필수입니다"
|
||||
errFindingTrafficBindingIDsRequired = "binding_ids 는 필수입니다"
|
||||
errFindingTrafficInheritedReadonly = "继承的漏洞为只读,需要在来源任务中修改"
|
||||
errFindingTrafficSelectRequired = "请选择流量"
|
||||
errFindingTrafficVersionRequired = "version 和有效的请求正文为必填项"
|
||||
errFindingTrafficBindingIDsRequired = "binding_ids 为必填项"
|
||||
)
|
||||
|
||||
func (s *Server) evidenceStore() *evidence.Store {
|
||||
|
||||
@@ -3,7 +3,7 @@ package server
|
||||
import "testing"
|
||||
|
||||
// finding_traffic.go 의 사용자 노출 HTTP 에러 응답 문구를 한국어로 유지하는 회귀 방어
|
||||
// 테스트다. 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어 한자 0)를
|
||||
// 테스트다. 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어 한자 0)를
|
||||
// 재사용한다. 네 문구를 반환하는 경로(findingTrafficAccess·bindFindingTraffic·
|
||||
// editFindingTraffic)는 전부 s.m.pg.GetFinding(DB) 게이트 뒤라 DB 없는 이 호스트에서
|
||||
// 끝까지 돌 수 없으므로 상수 자체를 핀 고정한다(goals_api·notify_api 의 DB 게이트
|
||||
@@ -21,6 +21,6 @@ func TestFindingTrafficErrorsLocalized(t *testing.T) {
|
||||
{"version_required", errFindingTrafficVersionRequired},
|
||||
{"binding_ids_required", errFindingTrafficBindingIDsRequired},
|
||||
} {
|
||||
assertKoreanError(t, c.label, c.msg)
|
||||
assertChineseError(t, c.label, c.msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const maxFindingFollowUpRunes = 4000
|
||||
|
||||
// auditFindingFollowUpSummary 는 사용자가 취약점을 심화 익스플로잇하려고 후속 의도를
|
||||
// 제출할 때 활동 타임라인에 남는 요약 문구다("{요약} #{의도ID}" 형태로 표시된다).
|
||||
const auditFindingFollowUpSummary = "사용자가 제출한 취약점 심화 익스플로잇 의도"
|
||||
const auditFindingFollowUpSummary = "用户提交的漏洞深入利用意图"
|
||||
|
||||
func findingPaginationParam(raw string, fallback, upperBound int) int {
|
||||
value := atoiDefault(raw, fallback)
|
||||
@@ -106,7 +106,7 @@ func (s *Server) deepenFinding(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "요청 본문이 너무 큽니다")
|
||||
writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大")
|
||||
} else {
|
||||
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// findings_groups.go 의 사용자 노출 문구를 한국어로 유지하는 회귀 방어 테스트다.
|
||||
// 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
// 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
|
||||
// TestDeepenFindingBodyTooLargeLocalized 는 본문 초과(413) 응답이 한국어임을 실제 HTTP 로
|
||||
// 확인한다. 이 경로는 MaxBytesReader 디코드 단계에서 반환되어 s.m.pg(DB) 에 닿기 전에 끝나므로
|
||||
@@ -31,11 +31,11 @@ func TestDeepenFindingBodyTooLargeLocalized(t *testing.T) {
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("응답 JSON 파싱 실패: %v (본문 %q)", err, rec.Body.String())
|
||||
}
|
||||
const want = "요청 본문이 너무 큽니다"
|
||||
const want = "请求正文过大"
|
||||
if resp.Error != want {
|
||||
t.Fatalf("응답 문구 = %q, 기대 = %q", resp.Error, want)
|
||||
}
|
||||
assertKoreanError(t, "body_too_large", resp.Error)
|
||||
assertChineseError(t, "body_too_large", resp.Error)
|
||||
}
|
||||
|
||||
// TestFindingFollowUpAuditSummaryLocalized 는 후속 의도 활동 요약 상수가 한국어임을 단언한다.
|
||||
@@ -43,5 +43,5 @@ func TestDeepenFindingBodyTooLargeLocalized(t *testing.T) {
|
||||
// 끝까지 못 도므로 상수 자체를 단언한다. 이 문구는 에이전트가 읽는 의도 payload(사용자가
|
||||
// 입력한 description)와 분리된, 활동 타임라인 표시 전용 요약이다.
|
||||
func TestFindingFollowUpAuditSummaryLocalized(t *testing.T) {
|
||||
assertKoreanError(t, "follow_up_summary", auditFindingFollowUpSummary)
|
||||
assertChineseError(t, "follow_up_summary", auditFindingFollowUpSummary)
|
||||
}
|
||||
|
||||
+6
-6
@@ -18,8 +18,8 @@ import (
|
||||
// 로 고정이라 재개 검증에 닿지 않으므로 두뇌 입력이 아니다. 용어는 task_control.go 의
|
||||
// 종료 상태·일시정지 문구와 맞춘다(resume=재개).
|
||||
const (
|
||||
errGoalResumeTerminal = "종료된 작업은 재개할 수 없습니다"
|
||||
errGoalResumeNotPaused = "일시정지된 작업만 재개할 수 있습니다"
|
||||
errGoalResumeTerminal = "已结束的任务无法恢复"
|
||||
errGoalResumeNotPaused = "只能恢复已暂停的任务"
|
||||
)
|
||||
|
||||
type goalSpec struct {
|
||||
@@ -39,10 +39,10 @@ type goalSpec struct {
|
||||
// 초기 목표 분해 라운드와 동시 실행 대기열 상태 안내다. 한국어화해도 두뇌 입력
|
||||
// (BRIEF 경계 #1)을 건드리지 않는다. 포맷 인자(%d)는 원형 보존. [[G132]]
|
||||
const (
|
||||
goalBreakdownRound0Summary = "0차 목표 분해"
|
||||
queuedConcurrencyLimitSummaryFmt = "대기열 등록: 동시 실행 상한 %d개에 도달해, 빈자리가 나면 자동으로 시작합니다"
|
||||
queuedNoLLMSummary = "대기열 등록: 현재 실행 가능한 LLM 설정이 없어, 설정이 복구되면 자동으로 시작합니다"
|
||||
queuedFIFOSummary = "대기열 등록: 먼저 대기 중인 작업이 있어, FIFO 순서대로 자동으로 시작합니다"
|
||||
goalBreakdownRound0Summary = "第 0 轮目标分解"
|
||||
queuedConcurrencyLimitSummaryFmt = "已加入队列:并发上限 %d 个已满,出现空位后会自动开始"
|
||||
queuedNoLLMSummary = "已加入队列:当前没有可用的 LLM 配置,配置恢复后会自动开始"
|
||||
queuedFIFOSummary = "已加入队列:前面还有等待中的任务,将按 FIFO 顺序自动开始"
|
||||
)
|
||||
|
||||
func (s *Server) launchTask(t *Task, seedText string, seedFirstIntent bool) {
|
||||
|
||||
+7
-7
@@ -13,13 +13,13 @@ import (
|
||||
// gid)·enum 은 원문 그대로 두고, 사람이 읽는 메시지만 한국어로 둔다. 영어 writeErr
|
||||
// (task not found·invalid JSON·bad goal id)는 F3b 중국어 전용 범위라 보존한다.
|
||||
const (
|
||||
errGoalTaskDeletingAdd = "작업을 삭제하는 중이라 목표를 추가할 수 없습니다"
|
||||
errGoalTaskDeletingEdit = "작업을 삭제하는 중이라 목표를 수정할 수 없습니다"
|
||||
errGoalTaskDeletingDelete = "작업을 삭제하는 중이라 목표를 삭제할 수 없습니다"
|
||||
errGoalTextEmpty = "목표 내용은 비워 둘 수 없습니다"
|
||||
errGoalNotFound = "목표를 찾을 수 없습니다"
|
||||
errGoalReadAfterAdd = "목표를 저장한 뒤 읽지 못했습니다"
|
||||
errGoalReadAfterEdit = "목표를 수정한 뒤 읽지 못했습니다"
|
||||
errGoalTaskDeletingAdd = "正在删除任务,无法添加目标"
|
||||
errGoalTaskDeletingEdit = "正在删除任务,无法修改目标"
|
||||
errGoalTaskDeletingDelete = "正在删除任务,无法删除目标"
|
||||
errGoalTextEmpty = "目标内容不能为空"
|
||||
errGoalNotFound = "找不到目标"
|
||||
errGoalReadAfterAdd = "保存目标后读取失败"
|
||||
errGoalReadAfterEdit = "修改目标后读取失败"
|
||||
)
|
||||
|
||||
// 总览「目标管理」的人工 CRUD 接口。与 agent 侧的 set_goals 工具写同一批 goal 节点,
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// goals_api.go 의 목표 관리 API 에러 응답을 한국어로 유지하는 회귀 방어 테스트다.
|
||||
// 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어 한자 0)를, 응답 본문
|
||||
// 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어 한자 0)를, 응답 본문
|
||||
// 추출은 task_categories 테스트의 decodeErrorField 를 재사용한다(같은 package server).
|
||||
|
||||
// TestGoalErrorConstantsLocalized 는 응답 상수 7종이 전부 한국어임을 단언한다. 목표
|
||||
@@ -26,7 +26,7 @@ func TestGoalErrorConstantsLocalized(t *testing.T) {
|
||||
"read_after_edit": errGoalReadAfterEdit,
|
||||
}
|
||||
for label, msg := range cases {
|
||||
assertKoreanError(t, label, msg)
|
||||
assertChineseError(t, label, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func TestGoalHandlersResponsesLocalized(t *testing.T) {
|
||||
if got != c.want {
|
||||
t.Fatalf("응답 문구 = %q, 기대 = %q", got, c.want)
|
||||
}
|
||||
assertKoreanError(t, c.name, got)
|
||||
assertChineseError(t, c.name, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package server
|
||||
import "testing"
|
||||
|
||||
// goals.go 의 작업 재개(admitPausedTask) 사전 조건 검증 오류 문구를 한국어로 유지하는
|
||||
// 회귀 방어 테스트다. 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어
|
||||
// 회귀 방어 테스트다. 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어
|
||||
// 한자 0)를 재사용한다. 두 문구를 반환하는 admitTaskWhen(requirePaused=true) 경로는
|
||||
// s.concMu 잠금 뒤 s.m.Task()·s.engine.IsDeleting()·beginTaskOperation() 엔진 게이트를
|
||||
// 지나므로 DB·엔진 없는 이 호스트에서 끝까지 돌 수 없어, 상수 자체를 핀 고정한다
|
||||
@@ -21,6 +21,6 @@ func TestGoalResumeErrorsLocalized(t *testing.T) {
|
||||
{"resume_terminal", errGoalResumeTerminal},
|
||||
{"resume_not_paused", errGoalResumeNotPaused},
|
||||
} {
|
||||
assertKoreanError(t, c.label, c.msg)
|
||||
assertChineseError(t, c.label, c.msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,17 +16,17 @@ const maxWorkerMessageBytes = 64 << 10
|
||||
// 에러 응답 문구다. 한국어 UI 에서 토스트로 그대로 노출되므로 한국어로 둔다. Worker 는
|
||||
// 엔진 역할 이름이라 로마자를 유지하고(용어집), request_id 는 요청 필드명이라 원문 보존.
|
||||
const (
|
||||
errIntentRequestTooLarge = "요청 본문이 너무 큽니다"
|
||||
errIntentMessageEmpty = "메시지는 비워 둘 수 없습니다"
|
||||
errIntentMessageTooLong = "메시지는 4000자를 초과할 수 없습니다"
|
||||
errIntentBadRequestID = "request_id 는 1~128자의 영문자, 숫자, -, _, ., : 만 사용할 수 있습니다"
|
||||
errIntentTaskDeleting = "작업을 삭제하는 중이라 Worker 에게 메시지를 보낼 수 없습니다"
|
||||
errIntentTaskPaused = "작업이 일시정지되어 있습니다. 먼저 작업을 재개한 뒤 Worker 에게 메시지를 보내세요"
|
||||
errIntentTaskQueued = "대기 중인 작업에는 Worker 에게 메시지를 보낼 수 없습니다"
|
||||
errIntentTaskTerminal = "종료된 작업에는 Worker 에게 메시지를 보낼 수 없습니다"
|
||||
errIntentTaskSettling = "작업을 마무리하는 중이라 Worker 에게 메시지를 보낼 수 없습니다"
|
||||
errIntentInheritedReadonly = "상속된 의도는 읽기 전용이라 Worker 에게 메시지를 보낼 수 없습니다"
|
||||
errIntentNotPaused = "일시정지된 Worker 에게만 메시지를 보낼 수 있습니다. 먼저 일시정지하세요"
|
||||
errIntentRequestTooLarge = "请求正文过大"
|
||||
errIntentMessageEmpty = "消息不能为空"
|
||||
errIntentMessageTooLong = "消息不能超过 4000 个字符"
|
||||
errIntentBadRequestID = "request_id 只能使用 1~128 位的字母、数字、-、_、.、:"
|
||||
errIntentTaskDeleting = "正在删除任务,无法向 Worker 发送消息"
|
||||
errIntentTaskPaused = "任务已暂停,请先恢复任务后再向 Worker 发送消息"
|
||||
errIntentTaskQueued = "排队中的任务无法向 Worker 发送消息"
|
||||
errIntentTaskTerminal = "已结束的任务无法向 Worker 发送消息"
|
||||
errIntentTaskSettling = "正在收尾任务,无法向 Worker 发送消息"
|
||||
errIntentInheritedReadonly = "继承的意图为只读,无法向 Worker 发送消息"
|
||||
errIntentNotPaused = "只能向已暂停的 Worker 发送消息,请先暂停"
|
||||
)
|
||||
|
||||
func validWorkerMessageRequestID(id string) bool {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// intent_intervention.go 의 Worker 개입 API 에러 응답을 한국어로 유지하는 회귀 방어
|
||||
// 테스트다. 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
// 테스트다. 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
|
||||
// TestIntentInterventionErrorConstantsLocalized 는 응답 상수 11종이 전부 한국어임을 단언한다.
|
||||
// 수명주기·의도 상태 경로는 s.engine·Store 설정이 필요해 DB 없는 이 호스트에서 끝까지 못
|
||||
@@ -33,7 +33,7 @@ func TestIntentInterventionErrorConstantsLocalized(t *testing.T) {
|
||||
{"not_paused", errIntentNotPaused},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assertKoreanError(t, c.name, c.msg)
|
||||
assertChineseError(t, c.name, c.msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestSendWorkerMessageInputValidationLocalized(t *testing.T) {
|
||||
if got := errBody(t, rec); got != c.want {
|
||||
t.Fatalf("응답 문구 = %q, 기대 = %q", got, c.want)
|
||||
}
|
||||
assertKoreanError(t, c.name, errBody(t, rec))
|
||||
assertChineseError(t, c.name, errBody(t, rec))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -27,9 +27,9 @@ func (s *Server) chatGuard() *guard.Guard {
|
||||
// intercept.Judge 가 msgModelApprovalFailed 로 감싸 Decision.Message 로 노출하므로,
|
||||
// 래퍼(intercept 패키지)와 언어가 어긋나지 않게 함께 한국어로 둔다.
|
||||
const (
|
||||
errNoJudgeModel = "사용할 수 있는 판정 모델이 설정되지 않았습니다"
|
||||
errJudgeModelUnavailable = "%d번 판정 모델 프로필을 사용할 수 없습니다"
|
||||
errJudgeVerdictMalformed = "모델 판정 형식이 올바르지 않습니다. 판정·실제 동작·성공 후 결과·적중 규칙을 모두 포함해야 합니다"
|
||||
errNoJudgeModel = "未配置可用的判定模型"
|
||||
errJudgeModelUnavailable = "判定模型配置 %d 不可用"
|
||||
errJudgeVerdictMalformed = "模型判定格式不正确,必须包含判定、实际动作、成功后的结果和命中的规则"
|
||||
)
|
||||
|
||||
// wireInterceptReviewer installs the LLM fallback judge into the interceptor. The
|
||||
@@ -326,12 +326,12 @@ func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
|
||||
switch filter.Status {
|
||||
case "", "pending", "allowed", "denied", "timeout":
|
||||
default:
|
||||
return filter, fmt.Errorf("status 값은 pending, allowed, denied, timeout 중 하나여야 합니다")
|
||||
return filter, fmt.Errorf("status 值必须是 pending, allowed, denied, timeout 之一")
|
||||
}
|
||||
switch filter.DecisionSource {
|
||||
case "", "model", "rule", "unknown":
|
||||
default:
|
||||
return filter, fmt.Errorf("decision_source 값은 model, rule, unknown 중 하나여야 합니다")
|
||||
return filter, fmt.Errorf("decision_source 值必须是 model, rule, unknown 之一")
|
||||
}
|
||||
return filter, nil
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func (s *Server) interceptDecide(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if req.Decision != "allowed" && req.Decision != "denied" {
|
||||
writeErr(w, 400, "decision 값은 allowed 또는 denied 중 하나여야 합니다")
|
||||
writeErr(w, 400, "decision 值必须是 allowed 或 denied 之一")
|
||||
return
|
||||
}
|
||||
if err := s.m.interceptor.Decide(id, req.Decision == "allowed"); err != nil {
|
||||
@@ -430,13 +430,13 @@ func (s *Server) interceptSetJudgeConfig(w http.ResponseWriter, r *http.Request)
|
||||
switch req.FailAction {
|
||||
case "allow", "ask", "deny":
|
||||
default:
|
||||
writeErr(w, 400, "fail_action 값은 allow, ask, deny 중 하나여야 합니다")
|
||||
writeErr(w, 400, "fail_action 值必须是 allow, ask, deny 之一")
|
||||
return
|
||||
}
|
||||
switch req.AskTimeoutAction {
|
||||
case "allow", "deny":
|
||||
default:
|
||||
writeErr(w, 400, "ask_timeout_action 값은 allow 또는 deny 중 하나여야 합니다")
|
||||
writeErr(w, 400, "ask_timeout_action 值必须是 allow 或 deny 之一")
|
||||
return
|
||||
}
|
||||
if err := s.m.interceptor.SetJudgeConfig(req); err != nil {
|
||||
@@ -464,29 +464,29 @@ type interceptRuleReq struct {
|
||||
|
||||
func validateInterceptRuleReq(req interceptRuleReq) error {
|
||||
if req.Name == "" {
|
||||
return fmt.Errorf("name 값은 비워 둘 수 없습니다")
|
||||
return fmt.Errorf("name 值不能为空")
|
||||
}
|
||||
switch req.MatchTarget {
|
||||
case "tool_name", "tool_input":
|
||||
default:
|
||||
return fmt.Errorf("match_target 값은 tool_name 또는 tool_input 중 하나여야 합니다")
|
||||
return fmt.Errorf("match_target 值必须是 tool_name 或 tool_input 之一")
|
||||
}
|
||||
switch req.MatchType {
|
||||
case "string", "regex":
|
||||
default:
|
||||
return fmt.Errorf("match_type 값은 string 또는 regex 중 하나여야 합니다")
|
||||
return fmt.Errorf("match_type 值必须是 string 或 regex 之一")
|
||||
}
|
||||
if req.Pattern == "" {
|
||||
return fmt.Errorf("pattern 값은 비워 둘 수 없습니다")
|
||||
return fmt.Errorf("pattern 值不能为空")
|
||||
}
|
||||
switch req.Action {
|
||||
case "allow", "deny", "ask":
|
||||
default:
|
||||
return fmt.Errorf("action 값은 allow, deny, ask 중 하나여야 합니다")
|
||||
return fmt.Errorf("action 值必须是 allow, deny, ask 之一")
|
||||
}
|
||||
if req.MatchType == "regex" {
|
||||
if _, err := regexp.Compile(req.Pattern); err != nil {
|
||||
return fmt.Errorf("pattern 값이 올바른 정규식이 아닙니다: %w", err)
|
||||
return fmt.Errorf("pattern 值不是合法的正则表达式: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -549,11 +549,11 @@ func (s *Server) interceptExecution(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if conv == nil {
|
||||
writeErr(w, http.StatusGone, "대화가 이미 삭제되었습니다")
|
||||
writeErr(w, http.StatusGone, "会话已被删除")
|
||||
return
|
||||
}
|
||||
}
|
||||
writeErr(w, 404, "승인 기록이 삭제되었거나 존재하지 않습니다")
|
||||
writeErr(w, 404, "审批记录已被删除或不存在")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"conversation_id": target.ConversationID, "task_id": target.TaskID, "session": target.Session, "seq": target.Seq, "items": activityDTOs(target.Items)})
|
||||
|
||||
@@ -6,25 +6,25 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// assertKoreanError fails when msg is empty, still carries a CJK Han ideograph
|
||||
// (= untranslated Chinese), or carries no Hangul at all. Hangul and ASCII field
|
||||
// names/enums are allowed; only Han (unicode.Han) marks a leftover Chinese string.
|
||||
func assertKoreanError(t *testing.T, label, msg string) {
|
||||
// assertChineseError fails when msg is empty, carries no Han ideograph
|
||||
// (= untranslated / non-Chinese), or still carries Hangul. Han and ASCII field
|
||||
// names/enums are allowed; only Hangul (unicode.Hangul) marks a leftover Korean string.
|
||||
func assertChineseError(t *testing.T, label, msg string) {
|
||||
t.Helper()
|
||||
if msg == "" {
|
||||
t.Fatalf("%s: 빈 메시지", label)
|
||||
}
|
||||
hasHangul := false
|
||||
hasHan := false
|
||||
for _, r := range msg {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
t.Fatalf("%s: 중국어 한자가 남아 있습니다: %q", label, msg)
|
||||
}
|
||||
if unicode.Is(unicode.Hangul, r) {
|
||||
hasHangul = true
|
||||
t.Fatalf("%s: 한글이 남아 있습니다: %q", label, msg)
|
||||
}
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
hasHan = true
|
||||
}
|
||||
}
|
||||
if !hasHangul {
|
||||
t.Fatalf("%s: 한글이 없습니다: %q", label, msg)
|
||||
if !hasHan {
|
||||
t.Fatalf("%s: 한자가 없습니다: %q", label, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,12 +37,12 @@ func TestInterceptArchiveErrorsLocalized(t *testing.T) {
|
||||
if _, err := interceptFilterParams(url.Values{"status": {"bogus"}}); err == nil {
|
||||
t.Fatal("status 검증이 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "filter.status", err.Error())
|
||||
assertChineseError(t, "filter.status", err.Error())
|
||||
}
|
||||
if _, err := interceptFilterParams(url.Values{"decision_source": {"bogus"}}); err == nil {
|
||||
t.Fatal("decision_source 검증이 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "filter.decision_source", err.Error())
|
||||
assertChineseError(t, "filter.decision_source", err.Error())
|
||||
}
|
||||
|
||||
// intercept.go: validateInterceptRuleReq — each branch isolated by making the
|
||||
@@ -63,14 +63,14 @@ func TestInterceptArchiveErrorsLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("%s: 검증이 통과해서는 안 됩니다", tc.label)
|
||||
}
|
||||
assertKoreanError(t, tc.label, err.Error())
|
||||
assertChineseError(t, tc.label, err.Error())
|
||||
}
|
||||
|
||||
// task_archives.go: normalizeArchiveIDs — batch id validation.
|
||||
if _, err := normalizeArchiveIDs(nil); err == nil {
|
||||
t.Fatal("빈 archive_ids 검증이 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "archive_ids.empty", err.Error())
|
||||
assertChineseError(t, "archive_ids.empty", err.Error())
|
||||
}
|
||||
tooMany := make([]int64, 101)
|
||||
for i := range tooMany {
|
||||
@@ -79,23 +79,23 @@ func TestInterceptArchiveErrorsLocalized(t *testing.T) {
|
||||
if _, err := normalizeArchiveIDs(tooMany); err == nil {
|
||||
t.Fatal("101개 archive_ids 검증이 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "archive_ids.too_many", err.Error())
|
||||
assertChineseError(t, "archive_ids.too_many", err.Error())
|
||||
}
|
||||
if _, err := normalizeArchiveIDs([]int64{0}); err == nil {
|
||||
t.Fatal("0 archive id 검증이 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "archive_ids.nonpositive", err.Error())
|
||||
assertChineseError(t, "archive_ids.nonpositive", err.Error())
|
||||
}
|
||||
|
||||
// task_archives.go: validateArchivePath — package path validation.
|
||||
if err := validateArchivePath("/tmp/artex-data", ""); err == nil {
|
||||
t.Fatal("빈 보관 경로 검증이 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "archive_path.empty", err.Error())
|
||||
assertChineseError(t, "archive_path.empty", err.Error())
|
||||
}
|
||||
if err := validateArchivePath("/tmp/artex-data", "/etc/passwd"); err == nil {
|
||||
t.Fatal("관리 디렉터리 밖 경로 검증이 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "archive_path.outside", err.Error())
|
||||
assertChineseError(t, "archive_path.outside", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func TestInterceptDetailHTTP(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deletedReply := do(http.MethodGet, fmt.Sprintf("/api/intercept/history/%d/execution?conversation=%d", sourceID, conv.ID), "", true)
|
||||
if deletedReply.Code != 410 || !strings.Contains(deletedReply.Body.String(), "대화가 이미 삭제되었습니다") {
|
||||
if deletedReply.Code != 410 || !strings.Contains(deletedReply.Body.String(), "会话已被删除") {
|
||||
t.Fatalf("deleted conversation: %d %s", deletedReply.Code, deletedReply.Body.String())
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -333,7 +333,7 @@ func (m *Manager) Workers() int {
|
||||
// writeErr(400, err.Error()) 로 그대로 응답하며, 에이전트 도구(actool) 로 되먹이는 경로는
|
||||
// 없으므로 사용자 전용으로 보고 한국어로 유지한다. 용어는 UI 설정 화면(ko.json
|
||||
// settings.workers "워크 에이전트 수")과 맞춘다.
|
||||
const errWorkersPositive = "워크 에이전트 수는 1 이상이어야 합니다"
|
||||
const errWorkersPositive = "工作 Agent 数量必须大于等于 1"
|
||||
|
||||
// SetWorkers persists the concurrent work-agent count. Values <=0 are rejected.
|
||||
func (m *Manager) SetWorkers(n int) error {
|
||||
@@ -365,7 +365,7 @@ func NewManager(dir, proxyAddr string) (*Manager, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[pg] 데이터베이스 설정 출처: %s", source)
|
||||
log.Printf("[pg] 数据库配置来源: %s", source)
|
||||
pg, err := pgdb.Open(dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -684,9 +684,9 @@ func (m *Manager) syncBrowserMCPProxy() {
|
||||
return
|
||||
}
|
||||
if proxy != "" {
|
||||
log.Printf("[mcp] browser MCP 캡처 프록시 연결: %s (CA %s)", proxy, cert)
|
||||
log.Printf("[mcp] browser MCP 捕获代理已连接: %s (CA %s)", proxy, cert)
|
||||
} else {
|
||||
log.Printf("[mcp] browser MCP 캡처 프록시 설정 제거")
|
||||
log.Printf("[mcp] browser MCP 捕获代理配置已移除")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package server
|
||||
import "testing"
|
||||
|
||||
// manager.go 의 워크 에이전트 수 설정 검증 오류 문구를 한국어로 유지하는 회귀 방어
|
||||
// 테스트다. 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어 한자 0)를
|
||||
// 테스트다. 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어 한자 0)를
|
||||
// 재사용한다. SetWorkers(n<=0) 분기는 m.pg.SetSetting 에 닿기 전에 반환하므로 DB 없는
|
||||
// 이 호스트에서 빈 Manager 로 실제 구동할 수 있다. 누군가 이 리터럴을 중국어로
|
||||
// 되돌리면 이 테스트가 실패한다.
|
||||
@@ -17,5 +17,5 @@ func TestSetWorkersErrorLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("워크 에이전트 수 0 은 거부되어야 합니다")
|
||||
}
|
||||
assertKoreanError(t, "set_workers_nonpositive", err.Error())
|
||||
assertChineseError(t, "set_workers_nonpositive", err.Error())
|
||||
}
|
||||
|
||||
@@ -30,10 +30,10 @@ type mcpClient interface {
|
||||
// (stdio/http/sse)과 URL 은 와이어 식별자라 원문을 유지하고, UI mcpPage(전송 방식·명령·
|
||||
// 원격 URL)와 표기를 맞춘다.
|
||||
const (
|
||||
errMCPStdioNoCommand = "stdio 전송 방식에 명령이 없습니다"
|
||||
errMCPHTTPNoURL = "http 전송 방식에 URL 이 없습니다"
|
||||
errMCPSSENoURL = "sse 전송 방식에 URL 이 없습니다"
|
||||
errMCPUnknownTransportFmt = "알 수 없는 전송 방식입니다: %q"
|
||||
errMCPStdioNoCommand = "stdio 传输方式未指定命令"
|
||||
errMCPHTTPNoURL = "http 传输方式未指定 URL"
|
||||
errMCPSSENoURL = "sse 传输方式未指定 URL"
|
||||
errMCPUnknownTransportFmt = "未知的传输方式: %q"
|
||||
)
|
||||
|
||||
// connectMCP dials one MCP server per its transport. Callers must Close the client.
|
||||
@@ -79,7 +79,7 @@ func (s *Server) discoverAndCacheMCP(ctx context.Context, m *db.MCPServer) error
|
||||
if err := s.m.pg.SaveMCPTools(m.ID, tools); err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("[mcp] %s 에서 도구 %d개를 발견해 캐시했습니다", m.Name, len(tools))
|
||||
log.Printf("[mcp] 在 %s 上发现并缓存了 %d 个工具", m.Name, len(tools))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ func (s *Server) discoverAndCacheMCP(ctx context.Context, m *db.MCPServer) error
|
||||
func (s *Server) discoverEmptyMCPsOnStartup() {
|
||||
servers, err := s.m.pg.ListMCP()
|
||||
if err != nil {
|
||||
log.Printf("[mcp] 시작 시 자동 발견: 목록을 읽지 못했습니다: %v", err)
|
||||
log.Printf("[mcp] 启动时自动发现: 无法读取列表: %v", err)
|
||||
return
|
||||
}
|
||||
for _, m := range servers {
|
||||
@@ -99,7 +99,7 @@ func (s *Server) discoverEmptyMCPsOnStartup() {
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(s.ctx, 90*time.Second)
|
||||
if err := s.discoverAndCacheMCP(ctx, m); err != nil {
|
||||
log.Printf("[mcp] 시작 시 %s 자동 발견에 실패했습니다: %v", m.Name, err)
|
||||
log.Printf("[mcp] 启动时对 %s 的自动发现失败: %v", m.Name, err)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// 회귀 방어 테스트다. 네 오류 경로(stdio 명령 누락·http URL 누락·sse URL 누락·알 수 없는
|
||||
// 전송 방식)는 전부 실제로 서버에 접속(dial)하기 전에 반환되므로, DB·네트워크 없이
|
||||
// connectMCP 을 직접 호출해 반환 문구까지 검증한다. 한국어 판정은 F3a 가 만든
|
||||
// assertKoreanError(한글 포함·중국어 한자 0)를 재사용한다. 전송 방식 enum(stdio/http/sse)과
|
||||
// assertChineseError(한글 포함·중국어 한자 0)를 재사용한다. 전송 방식 enum(stdio/http/sse)과
|
||||
// URL 은 와이어 식별자라 ASCII 로 남으며 한자 판정에 걸리지 않는다. 누군가 이 문구를
|
||||
// 중국어로 되돌리면 상수 핀 고정과 실제 경로가 함께 실패한다.
|
||||
func TestConnectMCPErrorsLocalized(t *testing.T) {
|
||||
@@ -22,7 +22,7 @@ func TestConnectMCPErrorsLocalized(t *testing.T) {
|
||||
{"sse_no_url", errMCPSSENoURL},
|
||||
{"unknown_transport", errMCPUnknownTransportFmt},
|
||||
} {
|
||||
assertKoreanError(t, c.label, c.msg)
|
||||
assertChineseError(t, c.label, c.msg)
|
||||
}
|
||||
|
||||
// 실제 경로: 네 거부 분기는 모두 dial 전에 반환한다.
|
||||
@@ -39,6 +39,6 @@ func TestConnectMCPErrorsLocalized(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("%s: 오류가 없습니다(검증을 통과함)", c.label)
|
||||
}
|
||||
assertKoreanError(t, c.label, err.Error())
|
||||
assertChineseError(t, c.label, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -78,14 +78,14 @@ var notifyBackoff = []time.Duration{
|
||||
// notify_api.go 의 전달 이력 응답을 거쳐 알림 페이지 「전달 기록」 표(delivery-list.tsx)에
|
||||
// 그대로 표시된다. 따라서 사용자 노출 문구라 한국어로 둔다(진단 로그 log.Printf 는 원문 보존, Z2).
|
||||
const (
|
||||
errDeliveryChannelKindUnregistered = "등록되지 않은 채널 유형입니다: %q"
|
||||
errDeliverySnapshotUnrenderable = "이벤트 스냅샷을 해석하지 못해 이 취약점을 메시지로 만들지 못했습니다"
|
||||
errDeliveryChannelLengthCapped = "이 메시지가 채널 길이 상한에 도달해 앞의 %d건만 전송했고, 나머지는 다음 배치로 미룹니다"
|
||||
errDeliveryNoDeliveredCount = "채널이 전송 건수를 보고하지 않았습니다(delivered=%d)"
|
||||
errDeliveryRetryExhausted = "%d회 재시도 후에도 실패했습니다: %s"
|
||||
errDeliverySnapshotEmpty = "전달 항목 #%d 의 이벤트 스냅샷이 비어 있습니다"
|
||||
errDeliverySnapshotParse = "전달 항목 #%d 의 이벤트 스냅샷을 해석하지 못했습니다: %w"
|
||||
errDeliveryBatchAllUnparseable = "요약 배치의 전달 %d건을 모두 해석하지 못했습니다"
|
||||
errDeliveryChannelKindUnregistered = "未注册的渠道类型: %q"
|
||||
errDeliverySnapshotUnrenderable = "无法解析事件快照,未能将该漏洞生成消息"
|
||||
errDeliveryChannelLengthCapped = "该消息已达渠道长度上限,仅发送了前 %d 条,其余将推迟到下一批"
|
||||
errDeliveryNoDeliveredCount = "渠道未报告发送数量(delivered=%d)"
|
||||
errDeliveryRetryExhausted = "重试 %d 次后仍然失败: %s"
|
||||
errDeliverySnapshotEmpty = "投递项 #%d 的事件快照为空"
|
||||
errDeliverySnapshotParse = "无法解析投递项 #%d 的事件快照: %w"
|
||||
errDeliveryBatchAllUnparseable = "汇总批次中的 %d 条投递全部无法解析"
|
||||
)
|
||||
|
||||
// Notifier 是漏洞推送的投递引擎。
|
||||
|
||||
@@ -32,7 +32,7 @@ func TestNotifierDeliveryReasonsLocalized(t *testing.T) {
|
||||
{"batchAllUnparseable", fmt.Sprintf(errDeliveryBatchAllUnparseable, 2)},
|
||||
}
|
||||
for _, c := range cases {
|
||||
assertKoreanError(t, c.label, c.msg)
|
||||
assertChineseError(t, c.label, c.msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestNotifierParseSnapshotErrorsLocalized(t *testing.T) {
|
||||
if _, err := parseSnapshot(&db.NotificationDelivery{ID: 7}); err == nil {
|
||||
t.Fatal("빈 스냅샷이 오류 없이 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "parseSnapshot.empty", err.Error())
|
||||
assertChineseError(t, "parseSnapshot.empty", err.Error())
|
||||
if !strings.Contains(err.Error(), "7") {
|
||||
t.Fatalf("parseSnapshot.empty: 전달 항목 ID 7 이 메시지에 없습니다: %q", err.Error())
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestNotifierParseSnapshotErrorsLocalized(t *testing.T) {
|
||||
if _, err := parseSnapshot(&db.NotificationDelivery{ID: 9, Snapshot: []byte("{bad")}); err == nil {
|
||||
t.Fatal("깨진 JSON 스냅샷이 오류 없이 통과해서는 안 됩니다")
|
||||
} else {
|
||||
assertKoreanError(t, "parseSnapshot.malformed", err.Error())
|
||||
assertChineseError(t, "parseSnapshot.malformed", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,6 @@ func TestNotifierRenderBatchAllUnparseableLocalized(t *testing.T) {
|
||||
if _, _, err := n.renderBatch(context.Background(), deliveries, "", 30); err == nil {
|
||||
t.Fatal("모든 스냅샷이 해석 불가일 때 오류가 나와야 합니다")
|
||||
} else {
|
||||
assertKoreanError(t, "renderBatch.allUnparseable", err.Error())
|
||||
assertChineseError(t, "renderBatch.allUnparseable", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
+13
-13
@@ -22,24 +22,24 @@ import (
|
||||
// 通知推送→알림(크롬 nav "알림 발송"), 投递→전송, 推送模式→발송 모드. 로그·주석은 BRIEF
|
||||
// 방침상 최하위라 이 묶음 밖이다.
|
||||
const (
|
||||
notifyErrBadJSON = "요청 본문이 올바른 JSON 형식이 아닙니다: "
|
||||
notifyErrKindInvalidFmt = "채널 유형이 올바르지 않습니다. 가능한 값: %s"
|
||||
notifyErrNameMissing = "채널 이름을 입력하세요"
|
||||
notifyErrNameEmpty = "채널 이름은 비워 둘 수 없습니다"
|
||||
notifyErrModeInvalid = "발송 모드가 올바르지 않습니다. 가능한 값: realtime / digest"
|
||||
notifyErrRateNegative = "전송 제한 값은 음수일 수 없습니다"
|
||||
notifyErrChannelID = "채널 id 가 올바르지 않습니다"
|
||||
notifyErrKindUnregFmt = "채널 유형 %q 는 등록되어 있지 않습니다"
|
||||
notifyErrDeliveryID = "전송 id 가 올바르지 않습니다"
|
||||
notifyErrChannelNotFound = "알림 채널을 찾을 수 없습니다"
|
||||
notifyErrBadJSON = "请求正文不是有效的 JSON 格式: "
|
||||
notifyErrKindInvalidFmt = "渠道类型无效。可选值: %s"
|
||||
notifyErrNameMissing = "请输入渠道名称"
|
||||
notifyErrNameEmpty = "渠道名称不能为空"
|
||||
notifyErrModeInvalid = "发送模式无效。可选值: realtime / digest"
|
||||
notifyErrRateNegative = "发送频率限制不能为负数"
|
||||
notifyErrChannelID = "渠道 id 无效"
|
||||
notifyErrKindUnregFmt = "渠道类型 %q 未注册"
|
||||
notifyErrDeliveryID = "投递 id 无效"
|
||||
notifyErrChannelNotFound = "找不到通知渠道"
|
||||
)
|
||||
|
||||
// 채널 연결을 점검할 때 보내는 테스트 메시지 본문이다. 사용자가 등록한 채널로 실제 발송되므로
|
||||
// 한국어로 두되, 받는 사람이 실제 취약점으로 오해하지 않도록 한눈에 테스트임이 드러나게 한다.
|
||||
const (
|
||||
notifyTestName = "테스트 메시지 · 채널 설정 정상"
|
||||
notifyTestClass = "연결 테스트"
|
||||
notifyTestSummary = "ARTEX 알림 채널 테스트 메시지입니다. 이 메시지를 받으셨다면 채널 설정이 정상입니다."
|
||||
notifyTestName = "测试消息 · 渠道配置正常"
|
||||
notifyTestClass = "连接测试"
|
||||
notifyTestSummary = "这是 ARTEX 通知渠道测试消息。如果您收到此消息,说明渠道配置正常。"
|
||||
)
|
||||
|
||||
// notifyChannelDTO 是渠道的对外表述。
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// notify_api.go 의 알림 설정 API 응답 문구와 테스트 메시지를 한국어로 유지하는 회귀 방어
|
||||
// 테스트다. 한국어 판정은 F3a 가 만든 assertKoreanError(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
// 테스트다. 한국어 판정은 F3a 가 만든 assertChineseError(한글 포함·중국어 한자 0)를 재사용한다.
|
||||
|
||||
// TestNotifyErrorConstantsLocalized 는 응답 상수 10종과 테스트 메시지 상수 3종이 전부
|
||||
// 한국어임을 단언한다. 어느 하나라도 중국어로 되돌리면 이 테스트가 실패한다.
|
||||
@@ -30,7 +30,7 @@ func TestNotifyErrorConstantsLocalized(t *testing.T) {
|
||||
"notifyTestSummary": notifyTestSummary,
|
||||
}
|
||||
for label, msg := range cases {
|
||||
assertKoreanError(t, label, msg)
|
||||
assertChineseError(t, label, msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestNotifyChannelLookupErrLocalized(t *testing.T) {
|
||||
if got != notifyErrChannelNotFound {
|
||||
t.Fatalf("응답 문구 = %q, 기대 = %q", got, notifyErrChannelNotFound)
|
||||
}
|
||||
assertKoreanError(t, "channel_not_found", got)
|
||||
assertChineseError(t, "channel_not_found", got)
|
||||
}
|
||||
|
||||
// TestNotifyTestMessageLocalized 는 채널 연결 점검용 테스트 메시지를 순수 함수로 조립해
|
||||
@@ -59,9 +59,9 @@ func TestNotifyTestMessageLocalized(t *testing.T) {
|
||||
t.Fatalf("테스트 메시지 항목 수 = %d, 기대 = 1", len(msg.Items))
|
||||
}
|
||||
item := msg.Items[0]
|
||||
assertKoreanError(t, "test.name", item.Name)
|
||||
assertKoreanError(t, "test.class", item.VulnClass)
|
||||
assertKoreanError(t, "test.summary", item.Summary)
|
||||
assertChineseError(t, "test.name", item.Name)
|
||||
assertChineseError(t, "test.class", item.VulnClass)
|
||||
assertChineseError(t, "test.summary", item.Summary)
|
||||
if msg.HomeURL != base || item.DetailURL != base {
|
||||
t.Fatalf("링크 보존 실패: HomeURL=%q DetailURL=%q (기대 %q)", msg.HomeURL, item.DetailURL, base)
|
||||
}
|
||||
|
||||
+11
-11
@@ -250,7 +250,7 @@ func TestNotifyEndToEndRealtimeDelivery(t *testing.T) {
|
||||
// "SQL注入" 은 입력으로 넣은 취약점 제목이라 카드에 그대로 에코된다(사용자 데이터,
|
||||
// 번역 대상 아님). "높음"·"개요" 는 렌더 라벨이다 — 심각도는 notify.SeverityLabel("high")
|
||||
// 가 내는 "🟠 높음", 요약 머리글은 writeItem 이 붙이는 "**개요**:" 에 각각 들어 있다.
|
||||
for _, want := range []string{"SQL注入", "높음", "개요"} {
|
||||
for _, want := range []string{"SQL注入", "高危", "摘要"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("消息正文缺少 %q:\n%s", want, text)
|
||||
}
|
||||
@@ -362,11 +362,11 @@ func TestNotifyChannelAPICreateValidation(t *testing.T) {
|
||||
// 검증 문구는 이미 한국어로 현지화됐으므로(notify/dingtalk_feishu_wecom_localized_test.go
|
||||
// 가 "Webhook 주소가 없습니다"·"Webhook 주소가 올바르지 않습니다" 로 검증한다) 기대
|
||||
// 문자열도 한국어 안정 문구로 맞춘다.
|
||||
{"类型非法", map[string]any{"name": "x", "kind": "nope", "config": map[string]any{}}, "채널 유형이 올바르지 않습니다"},
|
||||
{"缺名称", map[string]any{"kind": notify.KindDingTalk, "config": map[string]any{"webhook": "https://e.com/h"}}, "채널 이름을 입력하세요"},
|
||||
{"缺 webhook", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{}}, "Webhook 주소가 없습니다"},
|
||||
{"webhook 协议非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": "file:///etc/passwd"}}, "Webhook 주소가 올바르지 않습니다"},
|
||||
{"模式非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "mode": "sometimes", "config": map[string]any{"webhook": "https://e.com/h"}}, "발송 모드가 올바르지 않습니다"},
|
||||
{"类型非法", map[string]any{"name": "x", "kind": "nope", "config": map[string]any{}}, "渠道类型无效"},
|
||||
{"缺名称", map[string]any{"kind": notify.KindDingTalk, "config": map[string]any{"webhook": "https://e.com/h"}}, "请输入渠道名称"},
|
||||
{"缺 webhook", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{}}, "Webhook 地址不能为空"},
|
||||
{"webhook 协议非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "config": map[string]any{"webhook": "file:///etc/passwd"}}, "Webhook 地址无效"},
|
||||
{"模式非法", map[string]any{"name": "x", "kind": notify.KindDingTalk, "mode": "sometimes", "config": map[string]any{"webhook": "https://e.com/h"}}, "发送模式无效"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -444,7 +444,7 @@ func TestNotifyDigestBatchesMultipleFindingsIntoOneMessage(t *testing.T) {
|
||||
text := markdownText(t, hook.last(t))
|
||||
// 시간창이 있는 다이제스트 머리말은 markdown.go 가 "**최근 N분간 신규 취약점 N건**"
|
||||
// 으로 렌더한다(digestInterval 기본 30분이라 WindowMinutes>0).
|
||||
if !strings.Contains(text, "최근") || !strings.Contains(text, "신규 취약점 3건") {
|
||||
if !strings.Contains(text, "近") || !strings.Contains(text, "新增 3 个漏洞") {
|
||||
t.Fatalf("汇总消息缺少条数/时间窗文案:\n%s", text)
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
@@ -507,7 +507,7 @@ func TestNotifyStatusChangeDelivery(t *testing.T) {
|
||||
text := markdownText(t, hook.body(t, i))
|
||||
// 상태 변경 카드는 markdown.go 가 "**상태 변경**: %s → %s" 로, 상태값은
|
||||
// notify.StatusLabel 이 렌더한다(fixed → "수정됨").
|
||||
if strings.Contains(text, "상태 변경") && strings.Contains(text, "수정됨") {
|
||||
if strings.Contains(text, "状态变更") && strings.Contains(text, "已修复") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
@@ -558,7 +558,7 @@ func TestNotifyTestMessageEndpoint(t *testing.T) {
|
||||
}
|
||||
// 测试消息必须一眼能看出是测试,不能被误当成真实漏洞。
|
||||
// 테스트 메시지 제목은 notify_api.go 의 notifyTestName("테스트 메시지 · 채널 설정 정상").
|
||||
if text := markdownText(t, hook.last(t)); !strings.Contains(text, "테스트") {
|
||||
if text := markdownText(t, hook.last(t)); !strings.Contains(text, "测试消息") {
|
||||
t.Fatalf("测试消息应标明是测试: %s", text)
|
||||
}
|
||||
// 配置坏掉时应把渠道的原始错误如实回给用户。
|
||||
@@ -739,7 +739,7 @@ func TestNotifyNoDeepLinkWithoutBaseURL(t *testing.T) {
|
||||
// 상세 링크 머리글은 markdown.go 가 "[상세 보기](URL)" 로 렌더한다 — 외부 주소가
|
||||
// 없으면 이 링크가 아예 나오지 않아야 한다(옛 중국어 "查看详情" 를 검사하면 라벨이
|
||||
// 한국어로 바뀐 지금은 항상 통과해 회귀를 못 잡는다).
|
||||
if text := markdownText(t, body); strings.Contains(text, "상세 보기") {
|
||||
if text := markdownText(t, body); strings.Contains(text, "查看详情") {
|
||||
t.Fatalf("未配外部地址时不该出现详情链接:\n%s", text)
|
||||
}
|
||||
}
|
||||
@@ -800,7 +800,7 @@ func TestNotifyDigestSegmentsAndDefersRemainder(t *testing.T) {
|
||||
}
|
||||
// 消息正文必须如实告知还有多少条没包含在本条里。
|
||||
// 분절 안내는 markdown.go 가 "(이 메시지에는 앞 N건만 … 나머지 N건은 다음 메시지에서 …)" 로 렌더한다.
|
||||
if text := markdownText(t, hook.last(t)); !strings.Contains(text, "나머지") {
|
||||
if text := markdownText(t, hook.last(t)); !strings.Contains(text, "其余") {
|
||||
t.Fatalf("消息应说明还有条目未包含在本条:\n%.400s", text)
|
||||
}
|
||||
|
||||
|
||||
@@ -543,7 +543,7 @@ func (s *Server) refreshBuiltinToolSchemas() {
|
||||
}
|
||||
}
|
||||
_ = s.m.pg.SetSetting(flag, "true")
|
||||
log.Printf("[tools] orchestration/platform 도구 스키마를 코드 기본값으로 새로고침(1회성)")
|
||||
log.Printf("[tools] 已将 orchestration/platform 工具 schema 刷新为代码默认值(一次性)")
|
||||
}
|
||||
|
||||
// unbindGoalMetDefault removes goal_met's default "planner" binding ONCE (guarded by
|
||||
@@ -735,8 +735,8 @@ func (s *Server) upgradeReporterTriggerMessage() {
|
||||
// 들어가지 않는 순수 UI 라벨이라 한국어화한다(BRIEF 사용자 노출 산출물 한국어화 범위). 트리거는
|
||||
// report_finding 도구 호출 기반(OnToolCall·결정적)이라 reporter 는 key "reporter" 로 참조될 뿐
|
||||
// 표시 이름으로 선택되지 않는다. 회귀 방어: reporter_seed_localized_test.go. [[F35]]
|
||||
const reporterAgentName = "보고서 작성"
|
||||
const reporterAgentDescription = "취약점 상세 보고서 작성: 취약점을 발견하면 자동으로 트리거되어, 증거와 실행 과정을 조회한 뒤 Markdown 보고서를 작성해 해당 취약점에 기록합니다."
|
||||
const reporterAgentName = "报告编写"
|
||||
const reporterAgentDescription = "编写漏洞详细报告:发现漏洞后会自动触发,查看证据和执行过程后编写 Markdown 报告并记录到该漏洞中。"
|
||||
|
||||
// seedReporterAgent 预置一个「보고서 작성」自定义 agent(builtin=false,可在 UI 编辑/删除):
|
||||
// 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的
|
||||
@@ -785,7 +785,7 @@ func (s *Server) seedReporterAgent() {
|
||||
}); err != nil {
|
||||
log.Printf("[reporter] 创建触发器失败: %v", err)
|
||||
}
|
||||
log.Printf("[reporter] 「%s」 agent + finding 트리거 사전 구성", reporterAgentName)
|
||||
log.Printf("[reporter] 已预配置「%s」 agent + finding 触发器", reporterAgentName)
|
||||
}
|
||||
|
||||
// seedAutoReportFindingBinding adds "auto" to report_finding's binding ONCE so
|
||||
|
||||
@@ -10,9 +10,9 @@ import "testing"
|
||||
// OnToolCall·결정적) 들어가지 않는 순수 UI 라벨이다. 그래서 한국어화가 BRIEF 경계 #1
|
||||
// (두뇌 미번역)을 건드리지 않는다. 반대로 reporter 의 두뇌 본문(agent.ReporterDefaultPrompt)
|
||||
// 과 트리거 주입 메시지(reporterToolCallMessage)는 모델 입력이라 중국어 원문을 보존하므로
|
||||
// 이 테스트 대상이 아니다. 한국어 판정은 assertKoreanError(한글 포함·중국어 한자 0,
|
||||
// 이 테스트 대상이 아니다. 한국어 판정은 assertChineseError(한글 포함·중국어 한자 0,
|
||||
// intercept_archive_localized_test.go)를 재사용한다. [[F35]] [[G133]]
|
||||
func TestReporterSeedLabelsLocalized(t *testing.T) {
|
||||
assertKoreanError(t, "reporter_name", reporterAgentName)
|
||||
assertKoreanError(t, "reporter_description", reporterAgentDescription)
|
||||
assertChineseError(t, "reporter_name", reporterAgentName)
|
||||
assertChineseError(t, "reporter_description", reporterAgentDescription)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// 경로(planner.workerOutput·get_worker_output 도구)는 intent 범위(node_id)에서 'result'/
|
||||
// 'text' 활동만 고르기 때문이다. 그래서 한국어화가 두뇌 입력(BRIEF 경계 #1)을 건드리지
|
||||
// 않는다. 반대로 두뇌로 되먹여지는 요약(seed 의도 요약 `完成任务目标…`·기본 설명 `未命名任务`
|
||||
// 등)은 원문을 보존하므로 이 테스트 대상이 아니다. 한국어 판정은 assertKoreanError(한글
|
||||
// 등)은 원문을 보존하므로 이 테스트 대상이 아니다. 한국어 판정은 assertChineseError(한글
|
||||
// 포함·중국어 한자 0, intercept_archive_localized_test.go)를 재사용한다. [[G132]]
|
||||
func TestRuntimeActivitySummariesLocalized(t *testing.T) {
|
||||
// 포맷 인자 없는 고정 요약: 그대로 한국어여야 한다.
|
||||
@@ -23,7 +23,7 @@ func TestRuntimeActivitySummariesLocalized(t *testing.T) {
|
||||
"queued_fifo": queuedFIFOSummary,
|
||||
}
|
||||
for label, msg := range plain {
|
||||
assertKoreanError(t, label, msg)
|
||||
assertChineseError(t, label, msg)
|
||||
}
|
||||
|
||||
// %d 포맷 문자열: 포맷 인자가 살아 있고, 포맷한 결과도 한국어이며 숫자가 실제로
|
||||
@@ -38,7 +38,7 @@ func TestRuntimeActivitySummariesLocalized(t *testing.T) {
|
||||
t.Fatalf("%s: 포맷 인자 %%d 가 사라졌습니다: %q", label, f)
|
||||
}
|
||||
got := fmt.Sprintf(f, 7)
|
||||
assertKoreanError(t, label, got)
|
||||
assertChineseError(t, label, got)
|
||||
if !strings.Contains(got, "7") {
|
||||
t.Fatalf("%s: 포맷 인자가 결과에 반영되지 않았습니다: %q", label, got)
|
||||
}
|
||||
|
||||
+30
-30
@@ -41,38 +41,38 @@ var BuildVersion = "dev"
|
||||
// 최종 사용자 응답만 한국어로 둔다. (F3b, 번역어는 완료 파일·web i18n 과 동일 표기)
|
||||
const (
|
||||
// 의도 제어·재실행 (controlIntent / rerunIntent / rerunBlocked)
|
||||
errTaskDeletingIntentControl = "작업을 삭제하는 중이라 의도를 제어할 수 없습니다"
|
||||
errTaskDeletingIntentRerun = "작업을 삭제하는 중이라 의도를 재실행할 수 없습니다"
|
||||
errIntentNotRerunnable = "재실행할 수 있는 상태가 아닙니다(blocked·exhausted·stopped 만 재실행할 수 있습니다)"
|
||||
errTaskDeletingIntentControl = "正在删除任务,无法控制意图"
|
||||
errTaskDeletingIntentRerun = "正在删除任务,无法重新执行意图"
|
||||
errIntentNotRerunnable = "当前状态无法重新执行(只有 blocked·exhausted·stopped 可以重新执行)"
|
||||
|
||||
// 작업 생성 (createTask) — 소스 작업·기업·분류·작업 수준 가로채기 규칙 검증
|
||||
errCreateTaskSourceLimit = "소스 작업은 최대 %d개까지 연결할 수 있습니다"
|
||||
errCreateTaskSourceInvalid = "소스 작업 id가 올바르지 않거나 중복되었습니다"
|
||||
errCreateTaskSourceNotFound = "소스 작업 #%d를 찾을 수 없습니다"
|
||||
errCreateTaskCompanyLimit = "연결 기업이 올바르지 않습니다: 유효한 기업은 최대 %d개까지 선택할 수 있습니다"
|
||||
errCreateTaskInterceptRules = "작업 수준 가로채기 규칙이 올바르지 않습니다: "
|
||||
errCreateTaskCategoryInvalid = "작업 분류를 찾을 수 없거나 올바르지 않습니다"
|
||||
errCreateTaskCompanyInvalid = "연결 기업을 찾을 수 없거나 올바르지 않습니다"
|
||||
errCreateTaskSourceLimit = "最多关联 %d 个来源任务"
|
||||
errCreateTaskSourceInvalid = "来源任务 id 无效或重复"
|
||||
errCreateTaskSourceNotFound = "找不到来源任务 #%d"
|
||||
errCreateTaskCompanyLimit = "关联企业无效:有效企业最多可以选择 %d 个"
|
||||
errCreateTaskInterceptRules = "任务级拦截规则无效:"
|
||||
errCreateTaskCategoryInvalid = "找不到任务分类或分类无效"
|
||||
errCreateTaskCompanyInvalid = "找不到关联企业或企业无效"
|
||||
|
||||
// LLM 설정 검증 (validateTaskProfileIDs → createTask/updateTaskLLMProfiles writeErr)
|
||||
errLLMProfileInvalid = "LLM 설정 id가 올바르지 않거나 중복되었습니다"
|
||||
errLLMProfileNotFound = "LLM 설정 #%d를 찾을 수 없거나 API Key가 설정되어 있지 않습니다"
|
||||
errLLMProfileInvalid = "LLM 配置 id 无效或重复"
|
||||
errLLMProfileNotFound = "找不到 LLM 配置 #%d,或未设置 API Key"
|
||||
|
||||
// 자산 저장소·커버리지 (taskCoverage / taskCoverageGraph / taskAssetRefs …)
|
||||
errAssetStoreDisabled = "자산 저장소가 활성화되어 있지 않습니다"
|
||||
errAssetIDRequired = "asset_id가 필요합니다"
|
||||
errAssetStoreDisabled = "资产存储未启用"
|
||||
errAssetIDRequired = "需要 asset_id"
|
||||
|
||||
// 파이썬 인터프리터 탐지 (pgDetectPython)
|
||||
errPythonNotDetected = "python을 찾을 수 없습니다(python3·python 모두 PATH에 없습니다)"
|
||||
errPythonNotDetected = "找不到 python(python3·python 都不在 PATH 中)"
|
||||
|
||||
// 알림 전역 설정 (링크 기준 주소·요약 주기 — web notify.global 라벨과 동일 표기)
|
||||
errNotifyBaseURLScheme = "링크 기준 주소는 http:// 또는 https://로 시작해야 합니다"
|
||||
errNotifyDigestRange = "요약 주기는 1~1440분 사이여야 합니다"
|
||||
errNotifyBaseURLScheme = "链接基准地址必须以 http:// 或 https:// 开头"
|
||||
errNotifyDigestRange = "摘要周期必须在 1~1440 分钟之间"
|
||||
|
||||
// 메인 에이전트 대화·세션 (newMainSession / chat)
|
||||
errTaskDeletingNewSession = "작업을 삭제하는 중이라 새 세션을 만들 수 없습니다"
|
||||
errTaskDeletingNewMessage = "작업을 삭제하는 중이라 새 메시지를 보낼 수 없습니다"
|
||||
errMainAgentBusy = "메인 에이전트가 이전 메시지를 처리하는 중입니다. 잠시 후 다시 시도해 주세요"
|
||||
errTaskDeletingNewSession = "正在删除任务,无法创建新会话"
|
||||
errTaskDeletingNewMessage = "正在删除任务,无法发送新消息"
|
||||
errMainAgentBusy = "主 Agent 正在处理上一条消息,请稍后重试"
|
||||
)
|
||||
|
||||
// 사용자에게 노출되지만 writeErr 를 거치지 않는 응답 문구(한국어). writeJSON 의 error
|
||||
@@ -80,13 +80,13 @@ const (
|
||||
// nav 경로는 web i18n 라벨(nav.system="시스템", llmConfig="LLM 설정")과 동일 표기. (F34)
|
||||
const (
|
||||
// 채팅 LLM 부재 사유 (chatUnavailableReason → writeErr 503 / 대화 종료 사유)
|
||||
errChatNoLLMProfile = "아직 LLM 설정이 없습니다: 시스템 → LLM 설정에서 설정을 하나 추가해 주세요"
|
||||
errChatNoActiveLLMProfile = "활성화된 LLM 설정이 없습니다: 시스템 → LLM 설정에서 하나를 활성화하거나, 이 대화에서 사용할 설정을 지정해 주세요"
|
||||
errChatLLMNotReady = "LLM이 준비되지 않아 대화할 수 없습니다: 시스템 → LLM 설정에 사용할 수 있고 활성화된 설정이 있는지 확인해 주세요"
|
||||
errChatNoLLMProfile = "还没有 LLM 配置:请在 系统 → LLM 配置 中添加一个配置"
|
||||
errChatNoActiveLLMProfile = "没有已启用的 LLM 配置:请在 系统 → LLM 配置 中启用一个,或为本对话指定要使用的配置"
|
||||
errChatLLMNotReady = "LLM 尚未就绪,无法对话:请检查 系统 → LLM 配置 中是否有可用且已启用的配置"
|
||||
|
||||
// LLM 연결 테스트·웹 검색 프로브 (writeJSON {ok:false, error:…}, 설정 화면 점검 결과)
|
||||
errLLMTestNoAPIKey = "API Key가 제공되지 않았습니다"
|
||||
errWebSearchProbeNoResults = "검색 결과가 0건입니다(요청이 제한되었거나 프록시가 연결되지 않았을 수 있습니다)"
|
||||
errLLMTestNoAPIKey = "未提供 API Key"
|
||||
errWebSearchProbeNoResults = "搜索结果为空(请求可能被限制,或代理未连接)"
|
||||
)
|
||||
|
||||
// 규칙 모드 채팅(LLM 미설정 시 사람이 직접 조종하는 fallbackChat) 사용자 응답(한국어).
|
||||
@@ -95,9 +95,9 @@ const (
|
||||
// 순수 입력 파싱이고 와이어 포맷이 아니므로 중국어·영어 트리거를 유지한 채 더하기만 한다.
|
||||
// 용어는 web i18n·GLOSSARY 와 정합: intent→의도, hint→힌트, finding→취약점, planner→플래너. (F34 d)
|
||||
const (
|
||||
fallbackIntentInjected = "우선순위 높은 의도를 하나 주입했습니다: "
|
||||
fallbackHintRecorded = "힌트를 기록했습니다. 플래너가 다음에 읽습니다: "
|
||||
fallbackChatStatus = "(규칙 모드, LLM 미설정) 현재 현황: 자산 %d개, 대기 의도 %d개, 확인된 취약점 %d개.\n사용 가능한 명령: \"의도 ...\"로 의도를 주입하고, \"힌트 ...\"로 플래너에게 힌트를 전달합니다."
|
||||
fallbackIntentInjected = "已注入一个高优先级意图:"
|
||||
fallbackHintRecorded = "已记录提示,规划器下次会读取:"
|
||||
fallbackChatStatus = "(规则模式,未配置 LLM)当前状态:资产 %d 个,待处理意图 %d 个,已确认漏洞 %d 个。\n可用命令:用 \"意图 ...\" 注入意图,用 \"提示 ...\" 向规划器传递提示。"
|
||||
)
|
||||
|
||||
// Server exposes the ARTEX backend over a JSON HTTP API for the shadcn/ui
|
||||
@@ -301,7 +301,7 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
} else {
|
||||
// 핵심 기능(취약점 IM 알림 전송)을 끄는 분기라 시작 로그를 남긴다. 이 변수는
|
||||
// 통합 테스트 전용이므로, 운영에서 켜져 있으면 실수나 환경 상속을 의심할 단서가 된다.
|
||||
log.Printf("[notify] 백그라운드 알림 전송 루프가 %s 로 꺼졌습니다(테스트 전용) — 취약점 IM 알림이 전송되지 않습니다", notifyBackgroundDisabledEnv)
|
||||
log.Printf("[notify] 后台通知发送循环已因 %s 关闭(仅测试用)——漏洞 IM 通知将不会发送", notifyBackgroundDisabledEnv)
|
||||
}
|
||||
// Fill the tool cache for any enabled MCP that has none yet (notably the
|
||||
// seeded browser MCP on first run). Async so it never blocks startup.
|
||||
@@ -3823,7 +3823,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) {
|
||||
_, err := ma.Chat(ctx, maTaskID, mainSeg, s.m.Assets(), t.Store, t.Goal, agentMsg, emit, t.Notify, resume, t.NotifyGoal, t.NotifyHint)
|
||||
s.engine.EndLLMCall(t.ID)
|
||||
if err != nil && ctx.Err() == nil {
|
||||
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: transcriptErrorSummary("메인 에이전트", err.Error()), MainSeg: segPtr})
|
||||
s.engine.emitActivity(t, db.Activity{Worker: "mainagent", Kind: "text", IsError: true, Summary: transcriptErrorSummary("主 Agent", err.Error()), MainSeg: segPtr})
|
||||
}
|
||||
}()
|
||||
writeJSON(w, 202, map[string]any{"status": "accepted", "mode": "llm"})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user