First Commit
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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
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
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// 취약점 페이지의 「내보내기」용 렌더링: 여러 건의 findings 테이블 행을 요약 Markdown,
|
||||
// 개별 Markdown, 또는 CSV 로 렌더링한다. JSON 은 server 계층이 DTO 로 직접 직렬화하므로
|
||||
// 여기서는 다루지 않는다.
|
||||
|
||||
// sortFindingsForExport 는 심각도 내림차순, 그다음 시각 역순으로 정렬한다. 요약 보고서의 그룹화와 동일하다.
|
||||
func sortFindingsForExport(fs []*db.DBFinding) {
|
||||
sort.SliceStable(fs, func(i, j int) bool {
|
||||
ri, rj := sevRank[fs[i].Severity], sevRank[fs[j].Severity]
|
||||
if ri != rj {
|
||||
return ri < rj // sevRank 는 값이 작을수록 심각하다
|
||||
}
|
||||
return fs[i].CreatedAt.After(fs[j].CreatedAt)
|
||||
})
|
||||
}
|
||||
|
||||
// findingTitle 은 취약점의 사람이 읽을 수 있는 제목을 고른다: 이름 → 유형 → 「미분류」.
|
||||
func findingTitle(f *db.DBFinding) string {
|
||||
return nz(f.Name, nz(f.VulnClass, "미분류"))
|
||||
}
|
||||
|
||||
// FindingsMarkdown 은 여러 건의 findings 를 하나의 요약 보고서로 통합한다(요약 + 심각도별
|
||||
// 그룹화, 각 건은 유형/상태/소속 작업/증거/상세 보고서를 포함).
|
||||
func FindingsMarkdown(fs []*db.DBFinding, generatedAt time.Time) string {
|
||||
items := append([]*db.DBFinding(nil), fs...)
|
||||
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))
|
||||
|
||||
// 요약: 심각도별 집계.
|
||||
counts := map[string]int{}
|
||||
for _, f := range items {
|
||||
counts[f.Severity]++
|
||||
}
|
||||
b.WriteString("## 요약\n\n")
|
||||
b.WriteString("| 심각도 | 개수 |\n| --- | --- |\n")
|
||||
for _, s := range []struct{ key, label string }{
|
||||
{"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")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
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", nz(f.Status, "pending"))
|
||||
if desc := strings.TrimSpace(f.TaskDescription); desc != "" {
|
||||
fmt.Fprintf(&b, "- **소속 작업**: %s\n", desc)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if rep := strings.TrimSpace(f.Report); rep != "" {
|
||||
b.WriteString("**상세 보고서:**\n\n")
|
||||
b.WriteString(rep)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(findingTrafficMarkdown(f, false))
|
||||
b.WriteString("---\n\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// SingleFindingMarkdown 은 개별 취약점을 독립된 Markdown 한 건으로 렌더링한다(「취약점 하나당 파일 하나」 묶음에 사용).
|
||||
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", 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", 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)
|
||||
}
|
||||
if e := strings.TrimSpace(f.Evidence); 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(rep)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(findingTrafficMarkdown(f, true))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
var unsafeFilenameChars = regexp.MustCompile(`[^\p{Han}\p{L}\p{N}._-]+`)
|
||||
|
||||
// FindingFilename 은 「취약점 하나당 파일 하나」를 위해 안전한 .md 파일 이름을 생성한다.
|
||||
// 예: `critical_SQL주입_#123.md`. 경로 구분자와 제어 문자를 제거하여 zip 내 비정상 경로를 막는다.
|
||||
func FindingFilename(f *db.DBFinding) string {
|
||||
sev := nz(f.Severity, "info")
|
||||
title := findingTitle(f)
|
||||
name := fmt.Sprintf("%s_%s_#%d", sev, title, f.ID)
|
||||
name = unsafeFilenameChars.ReplaceAllString(name, "_")
|
||||
name = strings.Trim(name, "._")
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("finding_%d", f.ID)
|
||||
}
|
||||
// 방어적 처리: 경로를 한 번 더 벗겨 zip slip 을 차단한다.
|
||||
name = path.Base(name)
|
||||
if len(name) > 120 {
|
||||
name = name[:120]
|
||||
}
|
||||
return name + ".md"
|
||||
}
|
||||
|
||||
// FindingsCSV 는 여러 건의 findings 를 CSV 로 렌더링한다(UTF-8 BOM 포함, Excel 이 한글을
|
||||
// 올바르게 인식하도록). 긴 report/evidence 전문은 담지 않고 요약 성격의 필드만 넣는다.
|
||||
// 전문이 필요하면 Markdown/JSON 으로 내보낸다.
|
||||
func FindingsCSV(fs []*db.DBFinding) []byte {
|
||||
items := append([]*db.DBFinding(nil), fs...)
|
||||
sortFindingsForExport(items)
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("\xEF\xBB\xBF") // UTF-8 BOM
|
||||
w := csv.NewWriter(&buf)
|
||||
_ = w.Write([]string{"ID", "이름", "유형", "심각도", "상태", "소속 작업", "발견 시각", "개요", "트래픽 증거 개수", "트래픽 증거 ID"})
|
||||
for _, f := range items {
|
||||
_ = w.Write([]string{
|
||||
fmt.Sprintf("%d", f.ID),
|
||||
findingTitle(f),
|
||||
f.VulnClass,
|
||||
nz(f.Severity, "info"),
|
||||
nz(f.Status, "pending"),
|
||||
f.TaskDescription,
|
||||
f.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
strings.TrimSpace(f.Summary),
|
||||
fmt.Sprint(len(f.TrafficBindings)), findingTrafficIDs(f),
|
||||
})
|
||||
}
|
||||
w.Flush()
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func findingTrafficIDs(f *db.DBFinding) string {
|
||||
ids := make([]string, 0, len(f.TrafficBindings))
|
||||
for _, b := range f.TrafficBindings {
|
||||
ids = append(ids, fmt.Sprint(b.ID))
|
||||
}
|
||||
return strings.Join(ids, ",")
|
||||
}
|
||||
|
||||
func findingTrafficMarkdown(f *db.DBFinding, attachments bool) string {
|
||||
stale := f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion
|
||||
if len(f.TrafficBindings) == 0 && !stale {
|
||||
return ""
|
||||
}
|
||||
var out strings.Builder
|
||||
out.WriteString("\n## 관련 트래픽 증거\n\n")
|
||||
fmt.Fprintf(&out, "증거 버전: %d, 바인딩 개수: %d.\n\n", f.EvidenceVersion, len(f.TrafficBindings))
|
||||
if stale {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
out.WriteString("\n")
|
||||
return out.String()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
// Package report renders a deliverable Markdown report from the exploration
|
||||
// graph: confirmed findings (with severity/evidence) (docs §15 P5).
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// Input bundles what the report needs.
|
||||
type Input struct {
|
||||
Title string
|
||||
Goal string
|
||||
GeneratedAt time.Time
|
||||
AssetCounts map[string]int
|
||||
Findings []*db.Node
|
||||
}
|
||||
|
||||
type findingView struct {
|
||||
VulnClass string
|
||||
Name string
|
||||
Severity string
|
||||
Summary string
|
||||
PoC string
|
||||
}
|
||||
|
||||
func parseFinding(n *db.Node) findingView {
|
||||
var p struct {
|
||||
VulnClass string `json:"vulnclass"`
|
||||
Name string `json:"name"`
|
||||
Severity string `json:"severity"`
|
||||
Summary string `json:"summary"`
|
||||
Evidence struct {
|
||||
PoC string `json:"poc"`
|
||||
} `json:"evidence"`
|
||||
}
|
||||
_ = json.Unmarshal(n.Payload, &p)
|
||||
return findingView{p.VulnClass, p.Name, p.Severity, p.Summary, p.Evidence.PoC}
|
||||
}
|
||||
|
||||
var sevRank = map[string]int{"critical": 0, "high": 1, "medium": 2, "low": 3, "": 4}
|
||||
|
||||
// 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"))
|
||||
|
||||
// summary
|
||||
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)
|
||||
}
|
||||
sort.Strings(types)
|
||||
for i, t := range types {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
fmt.Fprintf(&b, "%s %d", t, in.AssetCounts[t])
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
// findings
|
||||
fmt.Fprintf(&b, "## 취약점\n\n")
|
||||
if len(in.Findings) == 0 {
|
||||
b.WriteString("_이번에 확인된 취약점이 없습니다._\n\n")
|
||||
} else {
|
||||
fs := make([]findingView, 0, len(in.Findings))
|
||||
for _, n := range in.Findings {
|
||||
fs = append(fs, parseFinding(n))
|
||||
}
|
||||
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, "%s\n\n", nz(f.Summary, ""))
|
||||
if f.PoC != "" {
|
||||
fmt.Fprintf(&b, "**PoC / 증거:**\n\n```\n%s\n```\n\n", f.PoC)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func nz(s, d string) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return d
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// containsHan 은 문자열에 CJK 한자(중국어)가 섞여 있으면 true 를 돌려준다.
|
||||
// 한글(unicode.Hangul)·영문·숫자·기호는 한자가 아니므로 걸리지 않는다.
|
||||
func containsHan(s string) (rune, bool) {
|
||||
for _, r := range s {
|
||||
if unicode.Is(unicode.Han, r) {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func sampleFindingNode(t *testing.T) *db.Node {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"vulnclass": "SQL Injection",
|
||||
"name": "로그인 폼 SQL 주입",
|
||||
"severity": "high",
|
||||
"summary": "로그인 파라미터에서 오류 기반 SQL 주입이 확인되었습니다.",
|
||||
"evidence": map[string]string{"poc": "' OR '1'='1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("payload 직렬화 실패: %v", err)
|
||||
}
|
||||
return &db.Node{ID: 1, Kind: "finding", Payload: payload}
|
||||
}
|
||||
|
||||
func sampleDBFinding() *db.DBFinding {
|
||||
return &db.DBFinding{
|
||||
ID: 123,
|
||||
VulnClass: "SQL Injection",
|
||||
Name: "로그인 폼 SQL 주입",
|
||||
Severity: "high",
|
||||
Summary: "로그인 파라미터에서 오류 기반 SQL 주입이 확인되었습니다.",
|
||||
Evidence: "' OR '1'='1",
|
||||
Status: "confirmed",
|
||||
Report: "상세 분석 본문.",
|
||||
CreatedAt: time.Date(2026, 10, 4, 2, 30, 0, 0, time.UTC),
|
||||
EvidenceVersion: 2,
|
||||
TaskDescription: "데모 대상 침투 테스트",
|
||||
TrafficBindings: []db.FindingTrafficBinding{
|
||||
{
|
||||
ID: 9,
|
||||
Role: "request",
|
||||
Note: "주입 페이로드 전송 지점",
|
||||
Snapshot: db.TrafficEvidenceSnapshot{
|
||||
Method: "POST",
|
||||
URL: "https://sandbox.local/login",
|
||||
Status: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportMarkdownLocalized 는 탐색 그래프 보고서(report.Markdown)의 골격이 한국어이고
|
||||
// 중국어 한자가 전혀 없음을 검증한다.
|
||||
func TestReportMarkdownLocalized(t *testing.T) {
|
||||
out := Markdown(Input{
|
||||
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)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"# 침투 테스트 보고서: 데모 작업",
|
||||
"- **작업 목표**: 샌드박스 전수 점검",
|
||||
"- **생성 시각**:",
|
||||
"## 요약",
|
||||
"- 확인된 취약점: **1**건",
|
||||
"## 취약점",
|
||||
"**PoC / 증거:**",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("보고서 골격에 %q 가 없다", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindingsMarkdownLocalized 는 취약점 요약/상세 Markdown 익스포트 골격과 심각도 라벨이
|
||||
// 한국어이고 중국어 한자가 없음을 검증한다.
|
||||
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)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"# 취약점 요약 보고서",
|
||||
"- **생성 시각**:",
|
||||
"- **취약점 총계**: 1건",
|
||||
"## 요약",
|
||||
"| 심각도 | 개수 |",
|
||||
"| 심각 |", "| 높음 |", "| 중간 |", "| 낮음 |", // 심각도 라벨(UI status.severity 와 동일)
|
||||
"## 취약점 상세",
|
||||
"- **유형**: SQL Injection",
|
||||
"- **상태**: confirmed",
|
||||
"- **소속 작업**: 데모 대상 침투 테스트",
|
||||
"- **발견 시각**:",
|
||||
"**증거:**",
|
||||
"**상세 보고서:**",
|
||||
"## 관련 트래픽 증거",
|
||||
"증거 버전: 2, 바인딩 개수: 1.",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("요약 보고서에 %q 가 없다", want)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"- **심각도**: high",
|
||||
"## 개요",
|
||||
"## 증거",
|
||||
"## 상세 보고서",
|
||||
"[요청 메시지](evidence/123/9/request.http)",
|
||||
"[응답 메시지](evidence/123/9/response.http)",
|
||||
} {
|
||||
if !strings.Contains(single, want) {
|
||||
t.Errorf("단건 보고서에 %q 가 없다", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindingsCSVLocalized 는 CSV 익스포트 헤더가 한국어이고 중국어 한자가 없음을 검증한다.
|
||||
// (BOM 뒤 헤더 행만 한국어면 된다. 데이터 값은 원본 그대로 둔다.)
|
||||
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"} {
|
||||
if !strings.Contains(header, col) {
|
||||
t.Errorf("CSV 헤더에 %q 열이 없다: %s", col, header)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user