Posted in

Go口令管理的5大致命漏洞:92%的Golang项目正在 silently 泄露用户凭证

第一章:Go口令管理的现状与风险全景

当前 Go 生态中,口令(密码)管理普遍存在“硬编码”“明文日志”“弱加密存储”三大典型反模式。大量开源项目与企业内部服务仍将数据库连接凭据、API密钥直接写入 config.yaml.env 文件,且未启用环境隔离或运行时解密机制;更严重的是,部分 HTTP 中间件在调试日志中无意输出 r.FormValue("password"),导致敏感字段泄露至文件或 ELK 日志系统。

常见高危实践示例

  • 使用 os.Getenv("DB_PASSWORD") 直接暴露原始字符串,未做内存清理
  • 依赖 golang.org/x/crypto/bcrypt 但调用 bcrypt.GenerateFromPassword([]byte(raw), 4) —— 成本因子过低(应 ≥12)
  • 将加密密钥硬编码于代码中,而非通过 KMS 或 Vault 动态注入

口令生命周期中的关键风险点

阶段 典型问题 后果
生成 rand.Intn(999999) 生成6位数字码 熵值不足,易被暴力枚举
存储 SQLite 中以 TEXT 类型存 bcrypt 哈希 若哈希被拖库,仍可离线爆破
传输 REST API 使用 application/json 提交明文密码 中间节点可截获

实际修复片段:安全的口令哈希流程

// 使用高成本因子生成 bcrypt 哈希(推荐 cost=12)
hash, err := bcrypt.GenerateFromPassword([]byte(userInput), 12)
if err != nil {
    log.Fatal("密码哈希失败:", err) // 实际场景应返回 HTTP 400 并清空输入缓冲区
}
// ⚠️ 注意:必须立即覆盖原始密码字节切片
for i := range userInput {
    userInput[i] = 0
}

// 验证时使用恒定时间比较,避免时序攻击
if err := bcrypt.CompareHashAndPassword(hash, []byte(input)); err != nil {
    // 密码错误或哈希格式异常
}

此外,Go 标准库 crypto/rand 应替代 math/rand 生成口令——后者不具备密码学安全性。生产环境务必禁用 GODEBUG=gcstoptheworld=1 等调试标志,防止 GC 暂停期间敏感内存驻留时间延长。

第二章:硬编码凭证——最隐蔽的“明文炸弹”

2.1 Go语言中硬编码凭证的常见场景与静态分析识别

常见硬编码场景

  • 数据库连接字符串(含用户名/密码)
  • API密钥、OAuth令牌直写在constvar
  • 测试用例中为简化流程嵌入生产环境凭证

典型危险代码模式

// ❌ 高风险:明文凭证硬编码
const dbURL = "postgres://admin:secret123@db.example.com:5432/app?sslmode=disable"
var apiKey = "sk_live_abcXYZ789" // 生产密钥泄露风险极高

该代码将敏感信息直接暴露于源码,任何代码仓库克隆或二进制反编译均可提取;dbURLsslmode=disable还隐含中间人攻击面。

静态分析识别维度

检测目标 正则模式示例 匹配强度
AWS密钥 AKIA[0-9A-Z]{16}
JWT Token ey[A-Za-z0-9_-]{20,}
基础认证凭据 (?i)basic\s+[A-Za-z0-9+/=]{20,}

检测流程示意

graph TD
    A[源码扫描] --> B{匹配敏感模式?}
    B -->|是| C[上下文校验<br>如变量名含“key”/“pwd”]
    B -->|否| D[跳过]
    C --> E[标记高危节点并定位文件行号]

2.2 使用go:embed与sealed-secrets实现配置解耦实践

静态资源嵌入:go:embed 的轻量级方案

import "embed"

//go:embed config/*.yaml
var configFS embed.FS

func loadConfig() ([]byte, error) {
    return configFS.ReadFile("config/app.yaml") // 编译时固化,零运行时依赖
}

go:embed 将 YAML 文件直接打包进二进制,规避环境变量或挂载卷的部署耦合;config/*.yaml 支持通配符,路径需为相对 go:embed 指令所在文件的路径。

密钥安全封装:SealedSecrets 工作流

# 本地加密(需集群公钥)
kubeseal --format=yaml < secret.yaml > sealed-secret.yaml
kubectl apply -f sealed-secret.yaml

SealedSecrets 在客户端完成非对称加密,仅控制器能解密——密钥永不离开集群。

对比选型

方案 安全性 构建时可见 运行时依赖
go:embed ⚠️ 明文
SealedSecrets ✅ 加密 ✅ (Controller)

graph TD
A[开发者本地] –>|明文YAML| B(go:embed打包)
A –>|私钥加密| C(SealedSecret)
C –> D[集群SealedSecrets Controller]
D –> E[解密为Secret]

2.3 基于AST遍历的自动化硬编码检测工具开发(go/ast实战)

Go 的 go/ast 包为源码静态分析提供了坚实基础。硬编码常量(如密码、API密钥、IP地址)可通过 AST 节点类型精准定位。

核心检测策略

  • 遍历 *ast.BasicLit 节点,筛选 token.STRINGtoken.INT
  • 结合上下文(如赋值左侧变量名含 "key""pwd")提升准确率
  • 排除测试文件与 vendor 目录

关键代码片段

func (v *HardcodeVisitor) Visit(node ast.Node) ast.Visitor {
    if lit, ok := node.(*ast.BasicLit); ok && lit.Kind == token.STRING {
        if isLikelySecret(lit.Value) {
            v.results = append(v.results, fmt.Sprintf("hardcoded string at %s", lit.Pos()))
        }
    }
    return v
}

isLikelySecret() 对字符串做正则匹配(如 ^".{16,}"$ 或 Base64 模式),lit.Pos() 提供精确行列定位,便于 IDE 集成跳转。

检测覆盖范围对比

类型 支持 说明
字符串字面量 含敏感关键词或长度阈值
数值字面量 ⚠️ 仅检测明显端口/状态码
变量重赋值 需结合 SSA 分析,暂不支持
graph TD
A[Parse Go source] --> B[Build AST]
B --> C[Traverse with Visitor]
C --> D{Is *ast.BasicLit?}
D -->|Yes| E[Apply heuristic filters]
D -->|No| C
E --> F[Report location & context]

2.4 环境变量注入的陷阱:os.Getenv()未校验与default fallback风险

默认回退的隐蔽失效

os.Getenv("DB_PORT") 返回空字符串时,若直接 port := os.Getenv("DB_PORT"); if port == "" { port = "5432" },看似安全——但空字符串不等于未设置,可能掩盖配置缺失或拼写错误(如 DB_POTR)。

危险的 fallback 模式

// ❌ 危险:无法区分 unset 与 empty
port := os.Getenv("DB_PORT")
if port == "" {
    port = "5432" // 错误假设:用户本意是“未设即用默认”
}

逻辑分析:os.Getenv() 对未定义键返回空字符串,无类型/存在性标识;此处将语义上“配置缺失”与“显式设为空”混为一谈,导致调试困难。参数 DB_PORT 若被误清空(如 CI 脚本覆盖),fallback 会静默接管,掩盖真实问题。

安全替代方案对比

方式 区分 unset/empty 类型安全 推荐度
os.Getenv() ⚠️ 仅用于非关键场景
os.LookupEnv() ✅(返回 value, ok ✅ 基础推荐
自定义 MustGetEnv() ✅(panic 或 error) ✅✅ 关键服务
// ✅ 推荐:显式检查存在性
if port, ok := os.LookupEnv("DB_PORT"); !ok {
    log.Fatal("missing required env: DB_PORT")
} else if port == "" {
    log.Fatal("DB_PORT cannot be empty")
}

逻辑分析:os.LookupEnv() 返回 (string, bool)ok==false 明确表示环境变量未设置,避免空值歧义;后续对 port == "" 的单独校验,可拦截非法空值,实现双重防护。

2.5 构建时凭证剥离:利用Go build tags与ldflags实现零敏感信息编译

为什么编译时需剥离凭证?

硬编码密钥、API Token 或数据库连接字符串会污染二进制,违背最小权限与安全交付原则。构建期注入而非运行时读取,可确保最终产物不含任何敏感字面量。

go:build tags 实现条件编译

// config_prod.go
//go:build prod
// +build prod

package config

var APIKey = "prod-key-xxx" // 仅 prod 构建时包含
// config_dev.go
//go:build !prod
// +build !prod

package config

var APIKey = "" // 开发环境为空,强制校验逻辑兜底

逻辑分析://go:build 指令由 Go 1.17+ 原生支持,prod tag 控制文件参与编译;!prod 确保开发环境排除敏感文件。go build -tags prod 才会加载 config_prod.go

-ldflags 动态注入(无文件残留)

go build -ldflags "-X main.apiKey=$API_KEY -X main.env=prod" .
参数 说明
-X main.apiKey=... 将字符串赋值给 main.apiKey 变量(需为 var apiKey string
$API_KEY 从 CI 环境变量读取,不落盘、不提交

安全构建流程示意

graph TD
    A[源码:无硬编码] --> B[CI 环境注入 env]
    B --> C[go build -tags prod -ldflags ...]
    C --> D[产出二进制:无敏感字符串]

第三章:内存泄露——运行时口令的“幽灵残留”

3.1 Go字符串不可变性与[]byte可变性的安全边界剖析

Go 中 string 是只读字节序列,底层结构包含指向底层数组的指针和长度;而 []byte 持有相同数据但允许原地修改——二者共享内存时即触发安全边界问题。

共享底层数组的风险场景

s := "hello"
b := []byte(s) // 创建新底层数组副本(安全)
b[0] = 'H'
fmt.Println(s, string(b)) // "hello" "Hello" —— 无影响

该转换调用 runtime.stringBytes,强制拷贝,避免别名冲突。参数说明:s 为只读头,b 为独立可写切片。

不安全的零拷贝转换(需谨慎)

// ⚠️ 非标准做法:通过 unsafe 绕过拷贝(仅限受控场景)
b2 := *(*[]byte)(unsafe.Pointer(&struct{ string; int }{s, len(s)}))
b2[0] = 'X' // 可能破坏字符串常量池或引发未定义行为

逻辑分析:此操作伪造 []byte 头部,使 b2s 共享底层数组,违反 string 不可变契约。

安全边界对照表

场景 是否共享底层数组 是否安全 典型用途
[]byte(s) 否(深拷贝) 通用转换
string(b) 否(深拷贝) 输出/日志
unsafe 强转 高性能网络解析
graph TD
    A[string s] -->|只读语义| B[编译器/运行时保护]
    C[[]byte b] -->|可写语义| D[内存重用优化]
    B -.-> E[禁止写入]
    D -->|越界/并发写| F[未定义行为]

3.2 http.Request.Body、net/http.Header中的凭证残留与及时清零实践

HTTP 请求中敏感凭证可能残留在 req.Body(如表单/JSON)或 req.Header(如 AuthorizationCookie)中,若未及时清理,易被日志、中间件或错误处理逻辑意外暴露。

风险高发场景

  • 中间件链中复用 *http.Request 实例
  • io.Copyjson.NewDecoder 读取 Body 后未重置
  • 日志框架自动打印 req.Header 全量内容

清零实践示例

// 清除敏感 Header 字段(保留必要元信息)
for _, key := range []string{"Authorization", "X-API-Key", "Cookie"} {
    req.Header.Del(key)
}
// 安全读取并丢弃 Body(避免内存泄漏)
if req.Body != nil {
    io.Copy(io.Discard, req.Body) // 必须调用 Close() 前完成
    req.Body.Close()
    req.Body = nil // 彻底切断引用
}

io.Copy(io.Discard, req.Body) 确保 Body 流被完全消费,防止后续读取返回空或 panic;req.Body = nil 切断潜在引用,配合 GC 及时回收。

推荐清理时机

时机 说明
中间件入口 在认证/鉴权前统一剥离凭证
错误日志前 防止 fmt.Printf("%+v", req) 泄露 Header
请求转发前 如反向代理场景,避免下游重复接收敏感头
graph TD
    A[HTTP Request] --> B{是否含敏感Header?}
    B -->|是| C[Header.Del 批量清除]
    B -->|否| D[跳过]
    A --> E{Body 是否已读?}
    E -->|未读| F[io.Copy io.Discard + Close]
    E -->|已读| G[req.Body = nil]
    C --> H[安全传递至下一环节]
    F --> H
    G --> H

3.3 GC不可控下的内存驻留:使用sync.Pool与unsafe.ZeroMemory安全擦除

内存驻留的隐性风险

Go 的 GC 不保证立即回收,敏感数据(如密码、密钥)可能在堆上残留数秒甚至更久,构成侧信道泄露风险。

sync.Pool 的双刃剑特性

var bufferPool = sync.Pool{
    New: func() interface{} {
        b := make([]byte, 1024)
        // 注意:New 不初始化内容,旧对象可能含残留数据
        return &b
    },
}

sync.Pool 复用对象提升性能,但不自动清零——取出的 []byte 可能携带前次使用残留字节。

安全擦除的必要步骤

  • 获取对象后必须显式擦除:
    • 优先用 bytes.Equal 验证擦除效果
    • 禁止仅 b = b[:0](仅重置长度,底层数组未清零)

unsafe.ZeroMemory 的精准控制

import "unsafe"

func secureWipe(b []byte) {
    if len(b) == 0 {
        return
    }
    // 将底层内存强制置零,绕过 GC 引用计数干扰
    unsafe.ZeroMemory(unsafe.SliceData(b), uintptr(len(b)))
}

unsafe.ZeroMemory 直接写零到内存地址,比 memset 更轻量且无 CGO 依赖;参数 unsafe.SliceData(b) 获取首字节地址,uintptr(len(b)) 指定字节数。

擦除策略对比

方法 是否同步 是否覆盖所有字节 GC 友好性 适用场景
bytes.Repeat([]byte{0}, n) 小量临时擦除
copy(b, zeros) 已预分配零缓冲
unsafe.ZeroMemory 极高 敏感数据即时擦除
graph TD
    A[获取 Pool 对象] --> B{是否含敏感数据?}
    B -->|是| C[调用 secureWipe]
    B -->|否| D[直接使用]
    C --> E[ZeroMemory 底层内存]
    E --> F[业务逻辑处理]
    F --> G[Put 回 Pool]

第四章:日志与监控——被忽视的凭证广播通道

4.1 结构化日志中敏感字段自动脱敏(zap/slog中间件实现)

在微服务日志中,user_idphoneid_card 等字段需默认脱敏,避免泄露。Zap 和 slog 均支持中间件式日志处理器,可统一拦截并重写字段。

脱敏策略配置

  • 支持正则匹配(如 ^1[3-9]\d{9}$
  • 支持字段路径匹配(如 user.contact.phone
  • 支持掩码模式:***••• 或哈希截断

Zap 中间件示例

func SanitizeFields() zapcore.Core {
    return zapcore.WrapCore(func(enc zapcore.Encoder, level zapcore.Level, fields []zapcore.Field) {
        for i := range fields {
            if isSensitiveField(fields[i].Key) {
                fields[i].String = maskPhone(fields[i].String)
            }
        }
    })
}

isSensitiveField 判断键名是否命中预设敏感列表;maskPhone13812345678138****5678,确保可读性与安全性平衡。

支持的敏感字段类型

字段类型 示例值 掩码规则
手机号 13812345678 138****5678
邮箱 a@b.com a***@b.com
身份证 1101011990... 110101******
graph TD
A[Log Entry] --> B{Field Key in Sensitive List?}
B -->|Yes| C[Apply Mask Rule]
B -->|No| D[Pass Through]
C --> E[Encode & Output]
D --> E

4.2 HTTP中间件层凭证过滤:基于http.Handler的请求体审计与redaction

HTTP中间件在请求链中承担敏感数据防护职责,需在不破坏语义的前提下实现动态红action。

审计与过滤双阶段设计

  • 审计阶段:解析Content-Type,识别JSON/form-data等结构化体
  • 过滤阶段:递归遍历键路径,匹配预设敏感字段(如password, token, api_key

敏感字段映射策略

字段类型 匹配模式 替换方式
密码类 (?i)pass(word)? ***REDACTED***
Token类 ^ey[A-Za-z0-9_\-]{20,} [TOKEN]
func RedactMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        body, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "read body failed", http.StatusInternalServerError)
            return
        }
        // 基于Content-Type选择解析器:JSON优先,fallback为raw扫描
        redacted := redactBody(body, r.Header.Get("Content-Type"))
        r.Body = io.NopCloser(bytes.NewReader(redacted))
        next.ServeHTTP(w, r)
    })
}

该中间件拦截原始r.Body,执行无损重写后注入新ReadCloserredactBody函数根据MIME类型路由至JSON路径过滤器或正则扫描器,确保application/json中嵌套"auth": {"password": "123"}被精准替换为"password": "***REDACTED***",同时保留结构完整性。

graph TD
    A[HTTP Request] --> B{Content-Type}
    B -->|application/json| C[JSON Unmarshal → Map → Key Walk]
    B -->|multipart/form-data| D[Parse Form → Field Scan]
    B -->|text/plain| E[Regex Scan]
    C --> F[Redact Sensitive Values]
    D --> F
    E --> F
    F --> G[Re-serialize & Inject Body]

4.3 Prometheus指标标签滥用导致的凭证暴露(label cardinality与token截断策略)

标签爆炸引发敏感信息泄露

当用户将 OAuth token、API key 等动态凭证直接作为 label 值(如 auth_token="abc123..."),会触发高基数(high cardinality)问题,并使凭证明文暴露在 /metrics 端点中。

截断策略失效场景

Prometheus 不主动截断 label 值,但部分 exporter 或中间件(如 nginx_exporter 的自定义采集脚本)错误地对 label_value 做前缀截取:

# 错误示例:截取前8位token作为label,仍具可枚举性
def sanitize_token(token):
    return token[:8]  # ❌ 危险!前缀碰撞率高,易暴力还原

该逻辑未考虑熵值衰减——16位 hex token 的前8位仅提供 32 bit 熵,远低于安全阈值(≥80 bit)。

安全实践对比

方式 是否暴露原始凭证 label 基数风险 推荐指数
直接使用完整 token 极高 ⚠️
截取前缀 是(部分)
使用哈希摘要(salted)

防御流程

graph TD
A[原始凭证] --> B{是否必需暴露?}
B -->|否| C[移除label,改用静态标识]
B -->|是| D[SHA-256+随机salt哈希]
D --> E[用哈希值作label]
E --> F[监控哈希碰撞率]

4.4 分布式追踪(OpenTelemetry)中Span属性的凭证过滤与上下文净化

在微服务链路中,未经处理的请求头(如 AuthorizationX-API-Key)或业务字段(如 user_emailid_card)可能被自动注入为 Span 属性,造成敏感信息泄露。

常见需过滤的敏感字段

  • AuthorizationCookieX-Api-Key
  • passwordtokenjwtsecret
  • 用户身份标识类:id_numberphoneemail

OpenTelemetry SDK 过滤示例

from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace.sampling import ParentBasedTraceIdRatioSampler

def sanitize_attributes(attributes):
    # 屏蔽敏感键名(不区分大小写)
    return {
        k: "[REDACTED]" if k.lower() in {"authorization", "cookie", "x-api-key", "password"} else v
        for k, v in attributes.items()
    }

# 自定义 Span 处理器实现上下文净化
class SanitizingSpanProcessor(SimpleSpanProcessor):
    def on_start(self, span, parent_context=None):
        # 在 Span 启动时净化属性
        span._attributes = sanitize_attributes(span.attributes)
        super().on_start(span, parent_context)

逻辑说明:该处理器在 on_start 阶段拦截 Span 初始化,对原始 attributes 字典执行键名匹配与值替换。k.lower() 确保大小写不敏感匹配;[REDACTED] 是通用脱敏标记,符合 OpenTelemetry 社区推荐实践。

推荐的过滤策略对比

策略 实施位置 实时性 可维护性
SDK 层拦截(如上) 应用进程内 ⚡ 高(Span 创建即净化) ✅ 易配置、易测试
Collector 配置过滤 OTel Collector ⏱ 中(依赖网络传输后处理) 🛠 需 YAML 维护
Exporter 后处理 导出前 ⚠ 低(部分属性已序列化) ❌ 易遗漏、难调试
graph TD
    A[HTTP Request] --> B[Auto-instrumented Span]
    B --> C{SanitizingSpanProcessor.on_start}
    C -->|匹配敏感键| D[Replace value with [REDACTED]]
    C -->|非敏感键| E[保留原始值]
    D & E --> F[Export via OTLP]

第五章:构建安全口令管理的Go工程化范式

密码学原语选型与合规对齐

在金融级口令系统中,我们严格遵循NIST SP 800-63B与国密GM/T 0005-2021标准。实际工程中采用golang.org/x/crypto/argon2替代传统bcrypt——其可调参的内存与时间成本(如time=3, memory=64*1024, threads=4)有效抵御GPU暴力破解。同时集成SM3哈希与SM4-GCM加密模块,通过github.com/tjfoc/gmsm实现国密算法无缝嵌入,确保密码派生与密文存储双合规。

零信任凭证生命周期管理

口令从创建到销毁全程受控:用户注册时生成Argon2ID密钥派生值(Salt长度32字节,随机性由crypto/rand.Read保障),并存入PostgreSQL的password_hash字段;登录验证前强制校验PBKDF2兼容性标识位;密码重置触发自动轮换策略——旧哈希立即标记为revoked并写入审计日志表。以下为关键状态迁移逻辑:

状态 触发条件 持久化操作 审计事件
pending 注册提交 写入临时凭证表 USER_REG_INIT
active 邮箱验证完成 迁移至主密码表 CREDENTIAL_ACTIVATED
rotated 密码修改 旧哈希加盐归档 PASSWORD_ROTATED

安全上下文隔离设计

所有密码操作封装于独立auth包,禁止跨包直接访问原始密码字符串。核心结构体PasswordManager通过接口抽象实现:

type PasswordManager interface {
    Hash(plain string) (string, error)
    Verify(plain, hash string) bool
    GenerateRecoveryToken() (string, error)
}

生产环境注入Argon2PasswordManager实例,测试环境则使用MockPasswordManager模拟低开销哈希,避免单元测试阻塞。

自动化密钥轮转机制

基于etcd分布式锁实现集群级密钥同步:当主密钥kms_master_key_v2启用时,所有节点每72小时执行一次密钥轮转。流程如下:

graph TD
    A[检测密钥过期阈值] --> B{是否需轮转?}
    B -->|Yes| C[生成新AES-256密钥]
    C --> D[批量解密旧密文]
    D --> E[用新密钥重加密]
    E --> F[更新密钥版本号]
    F --> G[广播密钥变更事件]
    B -->|No| H[跳过轮转]

审计日志不可篡改保障

密码操作日志直连Loki日志系统,每条记录包含user_idip_hash(SHA256(IP+salt))、operation_typetrace_id。关键字段经HMAC-SHA256签名后附加到日志末尾,签名密钥由Vault动态注入,杜绝日志伪造风险。

多因子绑定强化策略

口令必须与硬件令牌(YubiKey)或TOTP应用协同生效。MFAEnforcer中间件在认证链中插入校验:若用户启用MFA,则Verify()方法返回ErrMFARequired,强制跳转至OTP输入页。绑定流程要求二次确认——用户需在30秒内输入当前TOTP码并点击“确认绑定”,否则临时密钥自动失效。

故障熔断与降级方案

当Argon2计算耗时超过800ms(P99阈值),PasswordManager自动切换至预计算缓存模式:从Redis读取近期哈希结果(TTL=15分钟),同时上报Prometheus指标auth_password_hash_slow_total。缓存命中率低于95%时触发告警,运维人员可手动启用fast_mode=true配置快速恢复服务。

容器化部署安全加固

Dockerfile明确声明非root用户运行:

RUN addgroup -g 1001 -f auth && adduser -s /bin/sh -u 1001 -G auth -f auth
USER auth:auth

挂载目录权限设为0700,密码配置文件通过Kubernetes Secret Volume注入,禁止任何环境变量暴露密钥材料。

渗透测试验证闭环

每月执行OWASP ZAP自动化扫描,重点覆盖密码重置逻辑漏洞。历史发现的/api/v1/reset?token=xxx路径参数注入问题,已通过validator库强制校验token格式(正则^[a-zA-Z0-9]{32}$)并添加速率限制(IP维度5次/小时)。所有修复均经Burp Suite手工复测验证。

专治系统慢、卡、耗资源,让服务飞起来。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注