1057 lines
51 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""用户敏感信息管理(secret vault)—— 敏感信息入库 + 会话占位符注入 + 入站自动侦测。
设计目标(2026-09-17 用户需求):
1) 敏感信息入库:RC4 密文存 pipeline_user_secrets,按 org_id/user_id 隔离。
2) 会话中可"从表里选一个敏感信息插入会话":agent 只见元数据(名称/类型/前缀/长度),
插入的是占位符 `@@sec:NAME@@`,真值永不进模型上下文。
3) 入站自动侦测:用户消息/工具输出里出现明文敏感信息 → 自动入库 + 原文替换为占位符。
安全不变量(写死在代码,不靠 LLM 自觉):
- **真值只在执行边界以「子进程环境变量」形式出现**,绝不拼进命令字符串
(拼进命令 = 进 tool_calls 落库 = 进模型上下文 = 每轮上行供应商)。
- 占位符语法 `@@sec:NAME@@`,刻意避开 `${X}$`(sqlor 参数化 / ahserver ArgsConvert
都用它,复用会被误展开)与 `$VAR`(bash 会就地展开,绕过我们的边界)。
- 任何对外返回/日志/审计都只带元数据,不带 encrypted_value 与明文。
- 未知占位符**原样保留**,绝不替换成空串(空串会静默改变命令语义,
实测 `rm -rf ${TARGET_DIR}$/` → `rm -rf /`)。
三个已修复的实现陷阱(挂调实锤,勿回退):
A. 子串碰撞:长短两个 secret 共存时按插入序替换 → 长值被短值吃掉前缀、尾部明文裸露。
修复:替换前按 value 长度**降序**排序(_sorted_secrets)。
B. 占位符别名劫持:secret 的值本身长得像占位符 → 二次 token 化改写既有占位符。
修复:入库拒绝值内含占位符语法(_PLACEHOLDER_IN_VALUE);tokenize 幂等(已是占位符不重扫)。
C. 未知占位符吞空:见上,detokenize 一律原样保留。
"""
import hashlib
import logging
import math
import os
import re
from collections import Counter
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("pipeline.secret_vault")
# ══════════════════════ 常量 ══════════════════════
TABLE = "pipeline_user_secrets"
#: 占位符语法(模型可见、可回放;执行边界换成环境变量引用)
PLACEHOLDER_PREFIX = "@@sec:"
PLACEHOLDER_SUFFIX = "@@"
#: 变量名白名单:只认大写字母开头的标识符(堵 eval/注入面、堵 shell 元字符)。
#: ⚠️ _PLACEHOLDER_RE 必须与本正则的**长度下界一致**(都是 1 字符起)——
#: 曾写成 {1,62}(即最少 2 字符)导致单字符名能入库、但 find_placeholders/
#: detokenize 识别不出 → 未知占位符漏报。改名字规则时两处同步(挂调实锤)。
_NAME_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,62}$")
_PLACEHOLDER_RE = re.compile(r"@@sec:([A-Z][A-Z0-9_]{0,62})@@")
#: 执行边界注入子进程的环境变量前缀(agent 写 $PIPELINE_SEC_GITHUB_TOKEN)
ENV_PREFIX = "PIPELINE_SEC_"
#: 占位符在值里出现即拒绝入库(防陷阱 B)
_PLACEHOLDER_IN_VALUE = re.compile(r"@@sec:")
#: 已知凭据前缀(高置信度,直接判定为敏感;与 Hermes agent/redact.py 同源思路)
KNOWN_PREFIXES = [
(r"sk-[A-Za-z0-9_\-]{10,}", "openai_style_key"),
(r"github_pat_[A-Za-z0-9_]{10,}", "github_pat"),
(r"ghp_[A-Za-z0-9]{10,}", "github_token"),
(r"gh[osur]_[A-Za-z0-9]{10,}", "github_oauth"),
(r"glpat-[A-Za-z0-9_\-]{10,}", "gitlab_pat"),
(r"xox[baprs]-[A-Za-z0-9\-]{10,}", "slack_token"),
(r"AIza[A-Za-z0-9_\-]{30,}", "google_api_key"),
(r"AKIA[A-Z0-9]{16}", "aws_access_key"),
(r"sk_live_[A-Za-z0-9]{10,}", "stripe_live_key"),
(r"sk_test_[A-Za-z0-9]{10,}", "stripe_test_key"),
(r"hf_[A-Za-z0-9]{10,}", "huggingface_token"),
(r"npm_[A-Za-z0-9]{10,}", "npm_token"),
(r"pypi-[A-Za-z0-9_\-]{10,}", "pypi_token"),
(r"plk-[A-Za-z0-9]{8,}", "platform_short_token"),
(r"rak-[A-Za-z0-9]{8,}", "platform_api_key"),
(r"-----BEGIN[A-Z ]*PRIVATE KEY-----", "private_key"),
]
_KNOWN_RES = [(re.compile(p), t) for p, t in KNOWN_PREFIXES]
#: 关键词邻域形态:`apikey: xxx` / `api_key=xxx` / `token:xxx` / `Authorization: Bearer ***`
#: 中英文冒号都认(实测 ASCII `=` 能命中而中文 `:` 漏网是 Hermes 侧的真实缺口,这里补齐)
_KEYWORD_RE = re.compile(
r"(api[_\-\s]?key|apikey|access[_\-\s]?token|secret[_\-\s]?key|auth[_\-\s]?token"
r"|bearer|passwd|password|private[_\-\s]?key|credential)"
r"\s*[::=]?\s*[\"']?([A-Za-z0-9_\-.+/]{12,128})[\"']?",
re.IGNORECASE,
)
#: 低熵/示例值黑名单(防把 `password: 123456`、`sk-xxx` 占位符当凭据吞掉)
_JUNK_VALUE_RE = re.compile(
r"^(?:x{3,}|\*{3,}|\.{3}|xxx+|your[_\-]?[\w]*|example[\w]*|placeholder|dummy|test[\w]*"
r"|none|null|true|false|\d{1,8}|[a-z]{1,12}|[\w]*\[[\w]*\]?)$",
re.IGNORECASE,
)
#: 自动侦测的熵门限:低于此值不自动入库(宁可漏也不误吞日常文本)
ENTROPY_THRESHOLD = 3.0
#: 自动入库的最短长度
MIN_AUTO_LEN = 12
#: 类型猜测用
_TYPE_GUESS = [
("github", "github_token"), ("gitlab", "gitlab_token"), ("slack", "slack_token"),
("aws", "aws_key"), ("stripe", "stripe_key"), ("openai", "openai_key"),
("dashscope", "dashscope_key"), ("aliyun", "aliyun_key"), ("db", "db_password"),
("mysql", "db_password"), ("redis", "db_password"), ("ssh", "ssh_key"),
("token", "token"), ("key", "api_key"), ("secret", "secret"),
("pass", "password"), ("pwd", "password"),
]
# ══════════════════════ 加解密(AES,2026-09-17 用户指定替换 RC4) ══════════════
#
# 为什么不用 ahserver 的 password_encode/password_decode:实测 globalEnv.py:61
# `from appPublic.rc4 import password, unpassword` —— 它们就是 RC4 的薄封装,
# 换过去等于没换。真正的 AES 在 appPublic.aes(aes_encode_b64 / aes_decode_b64,
# AES-ECB + PKCS7 + base64),与 DB 连接密码(config.json databases.*.password)同套。
#
# ⚠️ aes.py 内部有**两处方向相反**的 iso-8859-1,只有一处需要包装(2026-09-17 实证):
# ① 密文 bytes → str(aes.py:42-43, 46-47):**必须** iso-8859-1,不能换 utf-8。
# 实测 200 组 AES 密文用 utf-8 解码失败 200 组(100%)——密文是二进制,
# 不符合 UTF-8 编码规范(首字节 0xef 即 invalid continuation byte)。
# 这处是历史正确设计(曾用 utf-8 撞编解码错误后改的),**不要动**。
# ② 明文 str → bytes(aes.py:29, 37):iso-8859-1 表示不了非 ASCII,
# 实测「密码是中文Abc123」「🔑key123」直接 UnicodeEncodeError。
# 凭据含非 ASCII 很常见(中文口令、含中文的备注型凭据),所以在**调用方**包装:
# str → UTF-8 bytes → 按 latin-1 逐字节还原成等长 str 传入(latin-1 是字节保真
# 映射,0x00-0xFF 一一对应),aes.py:29 再 encode('iso-8859-1') 即还原成
# 原始 UTF-8 字节流 = 字节保真空操作;出库反向还原。
# 不改 aes.py(appPublic 是多宿主共享基础模块,改它影响 DB 密码等全部调用方)。
# 包装后实测 9/9 往返一致:ASCII / GitHub PAT / 中文 / emoji / 中英混合+符号 /
# 私钥含换行 / 512 长值 / 特殊字符 p@$$w0rd!#%^&*() / 含 @@sec: 语法的值。
#
# 密文加版本前缀 AES1$ —— 因为 base64 密文没有固定形态特征,无法像 RC4 的
# QUZVcX 那样靠"前缀嗅探"判断是否已加密;显式版本标记同时为将来算法迁移留路。
#: 密文版本前缀(AES-ECB + UTF-8 包装 + base64)
_AES_PREFIX = "AES1$"
#: 历史 RC4 密文前缀(本平台 password() 产出;用于向后兼容读取,不再写入)
_RC4_PREFIX = "QUZVcX"
def _password_key() -> str:
"""宿主 password_key(pipeline-app 默认值兜底)。"""
try:
from appPublic.jsonConfig import getConfig
return getConfig().password_key or "QRIVSRHrthhwyjy176556332"
except Exception:
return "QRIVSRHrthhwyjy176556332"
def encrypt_secret(plain: str) -> str:
"""AES 加密 + UTF-8 包装,产出带版本前缀的密文。
幂等保护:已是密文形态(AES1$ 或历史 RC4 QUZVcX)则原样返回,防多层叠加——
多层加密是历史真实事故(llm.api_key 叠 2~3 层 → 解出来还是密文 → 上游 401)。
"""
if not plain:
return ""
if looks_encrypted(plain):
return plain
from appPublic.aes import aes_encode_b64
body = aes_encode_b64(_password_key(), plain.encode("utf-8").decode("iso-8859-1"))
return _AES_PREFIX + body
def decrypt_secret(enc: str) -> str:
"""解密。按前缀分派:AES1$ → AES;QUZVcX → 历史 RC4 兜底;其余 → 原文返回。
失败返回原文(不抛异常):调用方拿到不可用值会自然失败并报错,
比抛异常中断整条会话链更可控。
"""
if not enc:
return ""
key = _password_key()
try:
if enc.startswith(_AES_PREFIX):
from appPublic.aes import aes_decode_b64
raw = aes_decode_b64(key, enc[len(_AES_PREFIX):])
return raw.encode("iso-8859-1").decode("utf-8")
if enc.startswith(_RC4_PREFIX):
# 历史 RC4 密文(本模块早期版本或 llm 表迁移过来的值)
from appPublic.rc4 import unpassword
return unpassword(enc, key)
return enc
except Exception as e:
logger.warning("decrypt_secret failed: %s", str(e)[:120])
return enc
def looks_encrypted(value: str) -> bool:
"""是否已是密文形态(AES1$ 版本前缀,或历史 RC4 的 QUZVcX 前缀)。"""
if not value:
return False
return value.startswith(_AES_PREFIX) or value.startswith(_RC4_PREFIX)
# ══════════════════════ 纯函数:指纹 / 熵 / 类型 ══════════════════════
def fingerprint(plain: str) -> str:
"""明文 sha256(用于精确去重 + 入站精确匹配,比正则可靠)。"""
return hashlib.sha256((plain or "").encode("utf-8", "replace")).hexdigest()
def shannon_entropy(s: str) -> float:
if not s:
return 0.0
c = Counter(s)
n = len(s)
return -sum((v / n) * math.log2(v / n) for v in c.values())
def guess_type(name_hint: str = "", value: str = "") -> str:
"""按值形态/名称猜类型(只做展示分类,不做安全判定)。
**值形态优先于名称关键词**:值的前缀是硬证据(`sk-proj-…` 就是 OpenAI 形态),
名称提示是软线索。反序会让精确类型被泛化词覆盖——实测 name_hint
`openai_style_key` 里的 `key` 命中关键词表,把类型降级成 `api_key`(挂调实锤)。
"""
v = value or ""
for rx, t in _KNOWN_RES:
if rx.search(v):
return t
hay = ((name_hint or "") + " " + v[:24]).lower()
for kw, t in _TYPE_GUESS:
if kw in hay:
return t
return "other"
def mask_preview(plain: str) -> Tuple[str, int]:
"""返回 (前缀提示, 长度)。**只取前 4 字符**——够人类辨认是哪个凭据,
不足以被拼接复用(实测 GitHub PAT 前缀 `gith`/`ghp_` 是公开固定值,零熵)。"""
if not plain:
return "", 0
return plain[:4], len(plain)
def normalize_name(raw: str) -> str:
"""变量名归一:转大写、非标识符字符转下划线、限长。不合法返回空串。"""
n = re.sub(r"[^A-Za-z0-9_]", "_", (raw or "").strip()).upper()
n = re.sub(r"_+", "_", n).strip("_")[:63]
if not n:
return ""
if not n[0].isalpha():
n = "S_" + n
return n if _NAME_RE.match(n) else ""
def make_placeholder(name: str) -> str:
return PLACEHOLDER_PREFIX + name + PLACEHOLDER_SUFFIX
# ══════════════════════ 侦测(纯函数,无 DB) ══════════════════════
def detect_candidates(text: str) -> List[Dict]:
"""扫描文本,返回疑似敏感信息候选(去重,按置信度排序)。
三条通道:
① 已知凭据前缀(高置信度,无视熵)
② 关键词邻域 + 熵门限(`apikey: xxx`、`Authorization: Bearer xxx`)
③ 不做无上下文裸串侦测(误报会静默毁数据,比漏更糟)
返回项:{value, name_suggest, secret_type, confidence, source, entropy}
"""
if not text or not isinstance(text, str):
return []
out: Dict[str, Dict] = {}
def _add(value: str, conf: str, source: str, name_hint: str = ""):
value = (value or "").strip().strip("\"'`,;")
if len(value) < MIN_AUTO_LEN or len(value) > 256:
return
if _JUNK_VALUE_RE.match(value):
return
ent = shannon_entropy(value)
# 已知前缀是强证据,熵门限只对关键词通道生效
if source != "known_prefix" and ent < ENTROPY_THRESHOLD:
return
fp = fingerprint(value)
if fp in out:
# 同一值多通道命中 → 提升置信度
if conf == "high":
out[fp]["confidence"] = "high"
return
out[fp] = {
"value": value,
"fingerprint": fp,
"entropy": round(ent, 2),
"confidence": conf,
"source": source,
"name_suggest": normalize_name(name_hint) or _suggest_name(value, name_hint),
"secret_type": guess_type(name_hint, value),
}
# ① 已知前缀
for rx, t in _KNOWN_RES:
for m in rx.finditer(text):
v = m.group(0)
# 私钥块整段不入 value(太长),只标记类型
if v.startswith("-----BEGIN"):
continue
_add(v, "high", "known_prefix", t)
# ② 关键词邻域
for m in _KEYWORD_RE.finditer(text):
kw, val = m.group(1), m.group(2)
if not val:
continue
_add(val, "high" if shannon_entropy(val) >= 3.5 else "medium", "keyword", kw)
ranked = sorted(out.values(), key=lambda d: (d["confidence"] != "high", -d["entropy"]))
return ranked
def _suggest_name(value: str, hint: str = "") -> str:
"""从关键词/值形态派生变量名(确定性,同名不同值时调用方负责加序号)。"""
base = normalize_name(hint) if hint else ""
if not base:
base = normalize_name(guess_type("", value))
if not base or base == "OTHER":
base = "SECRET"
if not base.startswith("SEC_"):
base = "SEC_" + base
return base[:63]
# ══════════════════════ 占位符替换(tokenize / detokenize) ══════════════════════
def _sorted_secrets(secrets: Dict[str, str]) -> List[Tuple[str, str]]:
"""陷阱 A 修复:按值长度降序,长值先替换,防短值是长值子串时吃掉前缀。"""
return sorted(((n, v) for n, v in secrets.items() if v), key=lambda kv: -len(kv[1]))
def tokenize(text: str, secrets: Dict[str, str]) -> Tuple[str, List[str]]:
"""把文本里出现的**已知 secret 明文**替换成占位符。
Args:
text: 待净化文本(用户消息 / 工具输出)
secrets: {NAME: 明文值}(只应传当前用户可见的 secret)
Returns:
(净化后文本, 命中的 NAME 列表)
幂等:已是占位符的片段不含明文,自然不会被再处理(陷阱 B 的一半)。
"""
if not text:
return text, []
hits: List[str] = []
for name, value in _sorted_secrets(secrets):
if value in text:
text = text.replace(value, make_placeholder(name))
hits.append(name)
return text, hits
def detokenize(text: str, secrets: Dict[str, str], *, mode: str = "env") -> Tuple[str, List[str], List[str]]:
"""把占位符还原成**可执行形态**。
mode="env"(默认,推荐):占位符 → `$PIPELINE_SEC_<NAME>`(shell 变量引用),
真值不进字符串,由调用方通过环境变量注入子进程。
mode="value"(仅限非 shell 场景,如 HTTP header 构造):占位符 → 明文值。
⚠️ 用这个模式的调用方必须保证结果不落库、不进模型上下文、不打日志。
Returns:
(替换后文本, 命中的 NAME 列表, 未知占位符列表)
陷阱 C 修复:未知占位符**原样保留**(返回在第三个元素里让调用方决定报错),
绝不替换成空串。
"""
if not text:
return text, [], []
hits: List[str] = []
unknown: List[str] = []
def _repl(m):
name = m.group(1)
if name not in secrets:
unknown.append(name)
return m.group(0) # 原样保留
hits.append(name)
if mode == "value":
return secrets[name]
return "$" + ENV_PREFIX + name
return _PLACEHOLDER_RE.sub(_repl, text), hits, unknown
def find_placeholders(text: str) -> List[str]:
"""列出文本中引用的占位符名(未知与否都列,供权限校验)。"""
return [m.group(1) for m in _PLACEHOLDER_RE.finditer(text or "")]
def resolve_env(secrets: Dict[str, str], names: List[str]) -> Dict[str, str]:
"""为子进程构造环境变量字典(只含被引用到的 secret,最小暴露面)。"""
env: Dict[str, str] = {}
for n in names:
if n in secrets:
env[ENV_PREFIX + n] = secrets[n]
return env
# ══════════════════════ DB 层 ══════════════════════
def _get_db():
from sqlor.dbpools import DBPools
db = DBPools()
if not db.databases:
from appPublic.jsonConfig import getConfig
cfg = getConfig()
if cfg.databases:
db.databases = cfg.databases
return db
_META_COLS = ("id, name, label, secret_type, fingerprint, prefix_hint, length_hint, "
"org_id, user_id, source, status, remark, use_count, last_used_at, "
"created_at, updated_at")
async def list_secrets(sor, org_id: str = "", user_id: str = "",
only_active: bool = True) -> List[Dict]:
"""列出可见 secret 的**元数据**(绝不返回 encrypted_value / 明文)。
可见范围:本人(user_id 归属)+ 本机构共享(org_id 归属且 user_id 为空的机构级条目)。
"""
sql = ("SELECT " + _META_COLS + " FROM " + TABLE +
" WHERE (user_id=${u}$ OR (org_id=${o}$ AND user_id='')) ")
if only_active:
sql += "AND status='active' "
sql += "ORDER BY created_at DESC LIMIT 200"
recs = await sor.sqlExe(sql, {"u": user_id or "", "o": org_id or ""})
return [_rec_to_meta(r) for r in (recs or [])]
def _rec_to_meta(r) -> Dict:
d = dict(r)
d.pop("encrypted_value", None) # 双保险:即使 SELECT 误带也不外泄
return d
def _assert_own(row: Optional[Dict], user_id: str) -> bool:
"""写操作前的归属硬门禁(纵深防御,2026-09-17)。
get_secret_row(own_only=True) 的 SQL WHERE 已按 user_id 精确过滤,正常情况下
机构共享行(user_id='')与他人行根本查不出来。但**不依赖单一防线**:
将来若有人改 SQL、加新查询路径、或复用 own_only=False 的读路径去写,
这层代码级校验仍能挡住越权 UPDATE/DELETE(不可逆操作,符合「入口硬门禁」铁律)。
返回 True = 该行确属调用者本人,允许写。
"""
if not row:
return False
owner = str(row.get("user_id") or "")
return bool(user_id) and owner == str(user_id)
async def get_secret_row(sor, secret_id: str = "", name: str = "",
org_id: str = "", user_id: str = "",
own_only: bool = False) -> Optional[Dict]:
"""按 id 或 name 取单行(含密文,内部用)。
权限范围(2026-09-17 修复越权写入漏洞):
- own_only=False(**读/用**路径):本人条目 + 本机构共享条目(user_id='')。
机构共享凭据就是给成员用的,读范围必须含它。
- own_only=True(**写**路径:save/delete/set_status):只匹配本人条目。
否则任意机构成员都能 UPDATE/DELETE 机构共享凭据(按名查到共享行就直接改),
等于全机构凭据可被任一成员覆盖或删除。机构共享条目由管理侧(CRUD 页/管理员)
维护,agent 工具与用户自助路径一律只能动自己的。
"""
scope = ("AND user_id=${u}$" if own_only
else "AND (user_id=${u}$ OR (org_id=${o}$ AND user_id=''))")
if secret_id:
sql = "SELECT * FROM " + TABLE + " WHERE id=${i}$ " + scope + " LIMIT 1"
ns = {"i": secret_id, "u": user_id or "", "o": org_id or ""}
elif name:
sql = "SELECT * FROM " + TABLE + " WHERE name=${n}$ " + scope + " LIMIT 1"
ns = {"n": name, "u": user_id or "", "o": org_id or ""}
else:
return None
recs = await sor.sqlExe(sql, ns)
return dict(recs[0]) if recs else None
async def _name_taken(sor, name: str, org_id: str, user_id: str,
exclude_fp: str = "") -> bool:
"""同名是否已被**不同值**占用(同值同名 = 幂等,不算冲突)。
只看本人命名空间(own_only):机构共享条目占用了某个名字,不应阻止用户建同名的
个人条目——个人条目优先级更高(load_visible_secrets 里同名会各自返回,但使用方
按名取到的是本人那份),且唯一索引 (org_id,user_id,name) 允许两者共存。
"""
sql = ("SELECT id, fingerprint FROM " + TABLE +
" WHERE name=${n}$ AND user_id=${u}$ LIMIT 1")
recs = await sor.sqlExe(sql, {"n": name, "u": user_id or ""})
if not recs:
return False
return (getattr(recs[0], "fingerprint", "") or "") != exclude_fp
async def unique_name(sor, base: str, org_id: str, user_id: str, fp: str) -> str:
"""名字冲突时加序号(SEC_APIKEY → SEC_APIKEY_2 …),确定性不随机。"""
name = normalize_name(base) or "SEC_SECRET"
for i in range(2, 50):
if not await _name_taken(sor, name, org_id, user_id, fp):
return name
name = normalize_name(base) [:58] + "_" + str(i)
return name[:58] + "_" + str(hashlib.sha1(fp.encode()).hexdigest()[:4]).upper()
async def save_secret(sor, *, name: str, value: str, org_id: str = "", user_id: str = "",
label: str = "", secret_type: str = "", source: str = "manual",
remark: str = "", who: str = "") -> Dict:
"""入库(新增或更新同指纹条目)。返回 {ok, name, action, message, meta}。
幂等:同 (org,user,name) 已存在且指纹相同 → 直接返回 existing,不重复写。
"""
from appPublic.uniqueID import getID
from .audit import record_audit
name = normalize_name(name)
if not name:
return {"ok": False, "message": "FAIL: 变量名不合法(须字母开头,只含大写字母/数字/下划线)"}
if not value:
return {"ok": False, "message": "FAIL: 值不能为空"}
if _PLACEHOLDER_IN_VALUE.search(value):
# 陷阱 B:值本身含占位符语法 → 二次 token 化会改写既有占位符
return {"ok": False, "message": "FAIL: 值内含占位符语法 @@sec:,已拒绝入库(会破坏替换)"}
fp = fingerprint(value)
prefix_hint, length_hint = mask_preview(value)
secret_type = secret_type or guess_type(name, value)
enc = encrypt_secret(value)
# own_only=True:只查本人条目。否则按名查到机构共享条目(user_id='')就直接 UPDATE,
# 任意机构成员都能覆盖全机构共用的凭据(2026-09-17 自我 review 抓出的越权漏洞)。
# 机构共享条目与本人同名时可共存(唯一索引含 user_id),本人那份优先。
existing = await get_secret_row(sor, name=name, org_id=org_id, user_id=user_id,
own_only=True)
# 纵深防御:即便查询返回了非本人行(SQL 被改/复用错路径),也不得 UPDATE
if existing and not _assert_own(existing, user_id):
logger.warning("save_secret blocked: name=%s row owner mismatch", name)
existing = None # 当作不存在 → 走 INSERT 本人新行,不碰他人/共享行
if existing:
if (existing.get("fingerprint") or "") == fp:
return {"ok": True, "action": "existing", "name": name,
"message": "该名称下已是同一份敏感信息,无需重复保存。",
"meta": {k: v for k, v in existing.items() if k != "encrypted_value"}}
await sor.sqlExe(
"UPDATE " + TABLE + " SET encrypted_value=${e}$, fingerprint=${f}$, "
"prefix_hint=${p}$, length_hint=${l}$, secret_type=${t}$, label=${lb}$, "
"remark=${rm}$, status='active', updated_at=NOW() WHERE id=${i}$",
{"e": enc, "f": fp, "p": prefix_hint, "l": length_hint, "t": secret_type,
"lb": label or existing.get("label") or name, "rm": remark or "",
"i": existing["id"]})
action, sid = "rotated", existing["id"]
else:
sid = getID()
await sor.sqlExe(
"INSERT INTO " + TABLE + " (id, name, label, secret_type, encrypted_value, "
"fingerprint, prefix_hint, length_hint, org_id, user_id, source, status, "
"remark, use_count, created_at, updated_at) VALUES "
"(${i}$, ${n}$, ${lb}$, ${t}$, ${e}$, ${f}$, ${p}$, ${l}$, ${o}$, ${u}$, "
"${s}$, 'active', ${rm}$, 0, NOW(), NOW())",
{"i": sid, "n": name, "lb": label or name, "t": secret_type, "e": enc,
"f": fp, "p": prefix_hint, "l": length_hint, "o": org_id or "",
"u": user_id or "", "s": source or "manual", "rm": remark or ""})
action = "created"
try:
await record_audit(org_id or user_id or "", TABLE, sid, "secret_" + action,
who=who or user_id or "agent", detail="name=" + name +
" type=" + secret_type + " source=" + (source or ""))
except Exception as e:
logger.warning("audit failed on save_secret: %s", str(e)[:120])
return {"ok": True, "action": action, "name": name, "id": sid,
"message": ("已保存敏感信息 " + name + "(密文入库,值不会显示给任何人)。"
if action == "created" else
"已更新 " + name + " 的值(旧值已覆盖)。"),
"meta": {"name": name, "secret_type": secret_type, "prefix_hint": prefix_hint,
"length_hint": length_hint, "source": source}}
async def delete_secret(sor, *, name: str = "", secret_id: str = "",
org_id: str = "", user_id: str = "", who: str = "") -> Dict:
"""删除(**仅本人条目**)。
own_only=True:机构共享条目不可由普通成员删除——原实现的 docstring 声称
「用 user_id 过滤天然满足」是错的,因为 SQL 的 scope 是
`user_id=${u}$ OR (org_id=${o}$ AND user_id='')`,机构共享行照样被查中并删掉。
"""
from .audit import record_audit
row = await get_secret_row(sor, secret_id=secret_id, name=name,
org_id=org_id, user_id=user_id, own_only=True)
if not row or not _assert_own(row, user_id):
# 纵深防御:DELETE 不可逆,归属校验失败一律拒绝(含机构共享条目)
if row:
logger.warning("delete_secret blocked: name=%s owner mismatch", name)
return {"ok": False, "message": "FAIL: 找不到该敏感信息(或不属于你本人)"}
await sor.sqlExe("DELETE FROM " + TABLE + " WHERE id=${i}$", {"i": row["id"]})
try:
await record_audit(org_id or user_id or "", TABLE, row["id"], "secret_deleted",
who=who or user_id or "agent", detail="name=" + row.get("name", ""))
except Exception:
pass
return {"ok": True, "message": "已删除 " + row.get("name", "")}
async def update_secret_meta(sor, *, name: str, label: str = "", remark: str = "",
org_id: str = "", user_id: str = "", who: str = "") -> Dict:
"""改标签/备注(**仅本人条目**;值不可改——改值走删除重存,避免指纹/审计歧义)。
管理页(2026-09-18)用;与 set_secret_status 同款 own_only + _assert_own 双防线。
"""
from .audit import record_audit
row = await get_secret_row(sor, name=name, org_id=org_id, user_id=user_id,
own_only=True)
if not row or not _assert_own(row, user_id):
return {"ok": False, "message": "FAIL: 找不到该敏感信息(或不属于你本人)"}
await sor.sqlExe(
"UPDATE " + TABLE + " SET label=${l}$, remark=${r}$, updated_at=NOW() WHERE id=${i}$",
{"l": label or "", "r": remark or "", "i": row["id"]})
try:
await record_audit(org_id or user_id or "", TABLE, row["id"], "secret_meta_update",
who=who or user_id or "agent", detail="name=" + name)
except Exception:
pass
return {"ok": True, "message": name + " 元数据已更新"}
async def set_secret_status(sor, *, name: str, status: str, org_id: str = "",
user_id: str = "", who: str = "") -> Dict:
"""启用/停用(active / disabled)。停用后不再注入执行环境。"""
from .audit import record_audit
if status not in ("active", "disabled"):
return {"ok": False, "message": "FAIL: status 须为 active 或 disabled"}
# own_only=True:停用机构共享凭据会影响全机构成员,只能管理侧操作
row = await get_secret_row(sor, name=name, org_id=org_id, user_id=user_id,
own_only=True)
if not row or not _assert_own(row, user_id):
# 纵深防御:停用机构共享凭据会影响全机构成员,归属不符一律拒绝
if row:
logger.warning("set_secret_status blocked: name=%s owner mismatch", name)
return {"ok": False, "message": "FAIL: 找不到该敏感信息(或不属于你本人)"}
await sor.sqlExe("UPDATE " + TABLE + " SET status=${s}$, updated_at=NOW() WHERE id=${i}$",
{"s": status, "i": row["id"]})
try:
await record_audit(org_id or user_id or "", TABLE, row["id"], "secret_" + status,
who=who or user_id or "agent", detail="name=" + name)
except Exception:
pass
return {"ok": True, "message": name + " 已" + ("启用" if status == "active" else "停用")}
async def load_visible_secrets(sor, org_id: str = "", user_id: str = "") -> Dict[str, str]:
"""加载可见 secret 的 {NAME: 明文}(**仅执行边界内部使用**,禁止返回给 LLM/落日志)。"""
recs = await sor.sqlExe(
"SELECT name, encrypted_value FROM " + TABLE +
" WHERE status='active' AND (user_id=${u}$ OR (org_id=${o}$ AND user_id='')) LIMIT 200",
{"u": user_id or "", "o": org_id or ""})
out: Dict[str, str] = {}
for r in (recs or []):
n = getattr(r, "name", "") or ""
e = getattr(r, "encrypted_value", "") or ""
if n and e:
out[n] = decrypt_secret(e)
return out
async def touch_usage(sor, names: List[str], org_id: str = "", user_id: str = "") -> None:
"""记录使用(use_count + last_used_at)。失败不阻断执行。"""
for n in names or []:
try:
await sor.sqlExe(
"UPDATE " + TABLE + " SET use_count=use_count+1, last_used_at=NOW(), "
"updated_at=NOW() WHERE name=${n}$ AND "
"(user_id=${u}$ OR (org_id=${o}$ AND user_id=''))",
{"n": n, "u": user_id or "", "o": org_id or ""})
except Exception:
pass
# ══════════════════════ 入站门禁(自动侦测 + 入库 + 替换) ══════════════════════
async def scan_and_capture(sor, text: str, *, org_id: str = "", user_id: str = "",
who: str = "", auto_store: bool = True) -> Dict:
"""入站净化:侦测文本中的敏感信息 → 自动入库 → 原文替换为占位符。
Returns:
{
"text": 净化后的文本(进模型上下文的就是它),
"actions": [人类可读的动作说明],
"captured": [{"name","secret_type","confidence","source"}],
"known_hits": [NAME], # 命中已入库 secret 的明文(不新增)
"unknown_names": [], # 文本里引用了不存在的占位符(提示用户)
}
两层替换,顺序固定:
① 已入库 secret 的明文 → 占位符(精确值匹配,零误报)
② 新侦测到的高置信度候选 → 自动入库 + 替换(熵门限 + 垃圾值黑名单)
"""
result = {"text": text or "", "actions": [], "captured": [],
"known_hits": [], "unknown_names": []}
if not text:
return result
# 文本里已有的占位符引用:先校验存在性(未知占位符要告诉用户,不静默)
referenced = find_placeholders(text)
if referenced:
known = set()
try:
known = set(await load_visible_secrets(sor, org_id, user_id))
except Exception as e:
logger.warning("scan_and_capture load failed: %s", str(e)[:120])
result["unknown_names"] = [n for n in referenced if n not in known]
if result["unknown_names"]:
result["actions"].append(
"引用了不存在的敏感信息:" + ", ".join(result["unknown_names"]) +
"(已原样保留,执行时不会替换成空值)")
# ① 已入库明文 → 占位符
try:
secrets = await load_visible_secrets(sor, org_id, user_id)
except Exception as e:
logger.warning("load_visible_secrets failed: %s", str(e)[:120])
secrets = {}
cleaned, hits = tokenize(result["text"], secrets)
result["text"] = cleaned
result["known_hits"] = hits
if hits:
result["actions"].append(
"检测到已入库的敏感信息明文,已替换为占位符:" + ", ".join(hits))
# ② 新候选 → 自动入库
if not auto_store:
return result
candidates = detect_candidates(result["text"])
for cand in candidates:
if cand["confidence"] != "high":
continue # 中低置信度不自动入库(只提示,见下)
val = cand["value"]
fp = cand["fingerprint"]
base = cand["name_suggest"] or _suggest_name(val)
try:
name = await unique_name(sor, base, org_id, user_id, fp)
r = await save_secret(sor, name=name, value=val, org_id=org_id,
user_id=user_id, secret_type=cand["secret_type"],
source="auto_detect", who=who,
remark="会话入站自动侦测(" + cand["source"] +
",熵 " + str(cand["entropy"]) + ")")
except Exception as e:
logger.warning("auto capture failed: %s", str(e)[:160])
continue
if not r.get("ok"):
result["actions"].append("自动保存 " + base + " 失败:" + r.get("message", ""))
continue
name = r.get("name", base)
# 用真值替换(此时 result["text"] 里仍是明文)
result["text"] = result["text"].replace(val, make_placeholder(name))
result["captured"].append({"name": name, "secret_type": cand["secret_type"],
"confidence": cand["confidence"], "source": cand["source"],
"action": r.get("action")})
result["actions"].append(
"自动侦测到敏感信息(类型 " + cand["secret_type"] + ")→ 已密文入库为 " +
name + " → 消息中已替换为占位符 @@sec:" + name + "@@。"
"执行命令时用 $" + ENV_PREFIX + name + " 引用,真值不会出现在对话里。")
# 中置信度候选只提示不自动入库(避免误吞;用户可显式 save_secret)
medium = [c for c in candidates if c["confidence"] != "high"]
if medium:
result["actions"].append(
"另有 " + str(len(medium)) + " 处疑似敏感信息置信度不足,未自动入库"
"(如需保存请说「把 XXX 存为敏感信息 YYY」)。")
return result
# ══════════════════════ 出站:工具输出净化 ══════════════════════
async def sanitize_tool_output(sor, text: str, *, org_id: str = "",
user_id: str = "") -> Tuple[str, List[str]]:
"""工具输出净化(需求2的"执行返回当输入处理")。
只做**已入库 secret 的精确值擦洗**——不做自动侦测入库(工具输出里的疑似串
误报代价高:会把哈希/base64 数据块当凭据吞掉,静默毁数据)。
高熵未知串只在日志里 WARN 提示,不改写。
"""
if not text:
return text, []
try:
secrets = await load_visible_secrets(sor, org_id, user_id)
except Exception as e:
logger.warning("sanitize_tool_output load failed: %s", str(e)[:120])
return text, []
cleaned, hits = tokenize(text, secrets)
if hits:
logger.info("tool output scrubbed secrets: %s", ",".join(hits))
return cleaned, hits
# ══════════════════════ 执行边界:占位符 → 环境变量 ══════════════════════
#: env 引用形态($PIPELINE_SEC_NAME / ${PIPELINE_SEC_NAME})——prompt 承诺的写法
_ENV_REF_RE = re.compile(r"\$\{?" + ENV_PREFIX + r"([A-Z][A-Z0-9_]*)\}?")
async def prepare_command(sor, command: str, *, org_id: str = "",
user_id: str = "") -> Tuple[str, Dict[str, str], List[str]]:
"""执行边界唯一入口:命令里的占位符 → `$PIPELINE_SEC_NAME` + 环境变量字典。
Returns:
(改写后的命令, 需注入子进程的环境变量, 未知占位符列表)
设计:真值**不进命令字符串**——命令里只有变量名,值走 env 注入子进程。
这样命令原文落库/进模型上下文都不含明文(对齐 Hermes 实测的安全形态:
`T=$(cat ~/.hermes/scripts/.github_token); curl -H "Authorization: Bearer ***"`)。
"""
if not command:
return command, {}, []
try:
secrets = await load_visible_secrets(sor, org_id, user_id)
except Exception as e:
logger.warning("prepare_command load failed: %s", str(e)[:120])
secrets = {}
rewritten, hits, unknown = detokenize(command, secrets, mode="env")
# env 引用形态补齐(2026-09-17 缺口修复,e2e 实锤):SECRET_PROMPT_BLOCK 承诺
# 「命令里写 $PIPELINE_SEC_X 平台执行瞬间注入真值」,但此前只认占位符形态——
# agent 照 prompt 写 env 引用时 hits 为空 → env 不注入 → 变量展开为空
# (实测 stdout `SEC_READY_` 空值)。两种写法从此等价:
# 已存在名称 → 进 hits(env 注入);不存在名称 → 进 unknown(拒绝执行,
# 与陷阱 C「绝不静默空值」同原则——空展开会静默改变命令语义)。
for m in _ENV_REF_RE.finditer(rewritten):
n = m.group(1)
if n in secrets:
if n not in hits:
hits.append(n)
elif n not in unknown:
unknown.append(n)
env = resolve_env(secrets, hits)
if hits:
try:
await touch_usage(sor, hits, org_id, user_id)
except Exception:
pass
return rewritten, env, unknown
# ══════════════════════ 会话工具(agent 可见) ══════════════════════
# handler 签名对齐 pipeline_service 能力包约定:async def h(sor, params, ctx) -> str
def _ctx_ids(ctx: Dict) -> Tuple[str, str]:
"""从 ctx 取 (org_id, user_id)。ctx 可能缺字段,一律兜空串。"""
return (ctx or {}).get("org_id") or "", (ctx or {}).get("user_id") or ""
def _fmt_meta_list(rows: List[Dict]) -> str:
if not rows:
return ("你还没有保存任何敏感信息。\n"
"用法:直接说「把 apikey xxx 存为 GITHUB_TOKEN」,或在消息里带上明文——"
"我会自动侦测、密文入库并替换成占位符。")
lines = ["可用敏感信息(值已加密,任何人包括你自己都看不到明文;用 $PIPELINE_SEC_<名称> 在命令里引用):"]
for r in rows:
lines.append(
"- " + str(r.get("name", "")) +
" [类型:" + str(r.get("secret_type", "") or "other") +
" 前缀:" + str(r.get("prefix_hint", "") or "-") +
" 长度:" + str(r.get("length_hint", 0)) +
" 来源:" + str(r.get("source", "") or "manual") +
" 用过:" + str(r.get("use_count", 0)) + "次" +
(" 备注:" + str(r.get("remark", "")) if r.get("remark") else "") + "]")
lines.append("")
lines.append("插入会话用占位符 @@sec:<名称>@@;执行命令时写 $PIPELINE_SEC_<名称>,")
lines.append("平台会在执行瞬间注入真值到子进程环境变量,对话与数据库里都不会出现明文。")
return "\n".join(lines)
async def h_list_secrets(sor, params: Dict, ctx: Dict) -> str:
org_id, user_id = _ctx_ids(ctx)
if not user_id:
return "FAIL: 当前会话没有用户身份(无人值守场景不提供敏感信息)"
rows = await list_secrets(sor, org_id=org_id, user_id=user_id,
only_active=str(params.get("all", "")) not in ("1", "true", "True"))
return _fmt_meta_list(rows)
async def h_save_secret(sor, params: Dict, ctx: Dict) -> str:
org_id, user_id = _ctx_ids(ctx)
if not user_id:
return "FAIL: 当前会话没有用户身份,拒绝保存敏感信息"
value = params.get("value") or params.get("secret") or ""
name = params.get("name") or ""
if not value:
return "FAIL: 需要 value(敏感信息明文,只在本次调用中使用,落库前即加密)"
name = normalize_name(name) if name else _suggest_name(value, params.get("label") or "")
if not name:
return "FAIL: name 不合法(须字母开头,只含字母/数字/下划线)"
r = await save_secret(sor, name=name, value=value, org_id=org_id, user_id=user_id,
label=params.get("label") or "", secret_type=params.get("secret_type") or "",
source=params.get("source") or "agent", remark=params.get("remark") or "",
who=user_id)
if not r.get("ok"):
return r.get("message", "FAIL: 保存失败")
return (r.get("message", "") + "\n占位符:@@sec:" + r["name"] +
"@@\n命令中引用:$" + ENV_PREFIX + r["name"])
async def h_use_secret(sor, params: Dict, ctx: Dict) -> str:
"""把选中的敏感信息插入会话(需求2)——返回占位符,绝不返回值。"""
org_id, user_id = _ctx_ids(ctx)
if not user_id:
return "FAIL: 当前会话没有用户身份"
name = normalize_name(params.get("name") or "")
if not name:
return "FAIL: 需要 name(用 list_secrets 查看可用名称)"
# own_only=False(默认):读/用路径必须能看到机构共享凭据——那正是共享的意义。
# 与写路径(save/delete/set_status 用 own_only=True)刻意分离。
row = await get_secret_row(sor, name=name, org_id=org_id, user_id=user_id)
if not row:
rows = await list_secrets(sor, org_id=org_id, user_id=user_id)
avail = ", ".join(str(x.get("name", "")) for x in rows[:20]) or "(空)"
return "FAIL: 找不到敏感信息 " + name + "。可用:" + avail
if row.get("status") != "active":
return "FAIL: " + name + " 已停用,先用 set_secret_status 启用"
return ("已插入会话。后续引用方式:\n"
"- 文本/消息里用占位符:@@sec:" + name + "@@\n"
"- shell 命令里用环境变量:$" + ENV_PREFIX + name + "\n"
"(元数据:类型 " + str(row.get("secret_type", "")) + ",前缀 " +
str(row.get("prefix_hint", "")) + ",长度 " + str(row.get("length_hint", 0)) +
"。明文不会显示给你,也不会进入对话记录。)")
async def h_delete_secret(sor, params: Dict, ctx: Dict) -> str:
org_id, user_id = _ctx_ids(ctx)
if not user_id:
return "FAIL: 当前会话没有用户身份"
r = await delete_secret(sor, name=normalize_name(params.get("name") or ""),
secret_id=params.get("id") or "", org_id=org_id,
user_id=user_id, who=user_id)
return r.get("message", "FAIL")
async def h_set_secret_status(sor, params: Dict, ctx: Dict) -> str:
org_id, user_id = _ctx_ids(ctx)
if not user_id:
return "FAIL: 当前会话没有用户身份"
r = await set_secret_status(sor, name=normalize_name(params.get("name") or ""),
status=(params.get("status") or "").strip(),
org_id=org_id, user_id=user_id, who=user_id)
return r.get("message", "FAIL")
async def h_detect_secret(sor, params: Dict, ctx: Dict) -> str:
"""只侦测不入库(用户想先看看会命中什么)。"""
text = params.get("text") or ""
if not text:
return "FAIL: 需要 text"
cands = detect_candidates(text)
if not cands:
return "未侦测到敏感信息(已知前缀 0 命中、关键词+熵门限 0 命中)。"
lines = ["侦测到 " + str(len(cands)) + " 处疑似敏感信息:"]
for c in cands:
v = c["value"]
lines.append("- " + v[:4] + "…(长度" + str(len(v)) + ") 建议名=" + c["name_suggest"] +
" 类型=" + c["secret_type"] + " 置信度=" + c["confidence"] +
" 来源=" + c["source"] + " 熵=" + str(c["entropy"]))
lines.append("(只显示前4字符与元数据,未回显完整值。用 save_secret 可入库。)")
return "\n".join(lines)
# 工具定义(ToolDefinition 由调用方按 core 的类构造;这里给声明数据,避免 core 反向依赖)
SECRET_TOOL_SPECS = [
{
"name": "list_secrets",
"description": "列出当前用户/机构可用的敏感信息(只返回名称/类型/前缀/长度等元数据,绝不返回明文值)。需要用凭据执行命令前先查这个。",
"parameters": {"all": "传 1 含已停用条目(可选)"},
"category": "secret",
"required": [],
},
{
"name": "save_secret",
"description": "把一份敏感信息密文入库(apikey/token/密码/私钥等)。用户说「把 X 存为 Y」「保存这个 key」时调用。入库后返回占位符与环境变量名。",
"parameters": {"name": "变量名(大写字母数字下划线,缺省自动派生)", "value": "明文值(仅本次调用使用,落库即加密)",
"label": "显示名(可选)", "secret_type": "类型(可选,自动猜)", "remark": "备注(可选)"},
"category": "secret",
"required": ["value"],
},
{
"name": "use_secret",
"description": "从敏感信息表中选一个插入当前会话(返回占位符 @@sec:NAME@@ 与环境变量名,不返回明文)。用户说「用我的 github token」「插入那个 apikey」时调用。",
"parameters": {"name": "变量名(用 list_secrets 查)"},
"category": "secret",
"required": ["name"],
},
{
"name": "delete_secret",
"description": "删除一条敏感信息(仅本人/本机构条目)。",
"parameters": {"name": "变量名", "id": "记录ID(可选,与name二选一)"},
"category": "secret",
"required": [],
},
{
"name": "set_secret_status",
"description": "启用/停用一条敏感信息(停用后不再注入执行环境,但保留记录)。",
"parameters": {"name": "变量名", "status": "active 或 disabled"},
"category": "secret",
"required": ["name", "status"],
},
{
"name": "detect_secret",
"description": "只侦测文本中的疑似敏感信息不入库(返回前4字符+类型+置信度)。用户问「这段有没有泄露风险」时调用。",
"parameters": {"text": "待检测文本"},
"category": "secret",
"required": ["text"],
},
]
SECRET_HANDLERS = {
"list_secrets": h_list_secrets,
"save_secret": h_save_secret,
"use_secret": h_use_secret,
"delete_secret": h_delete_secret,
"set_secret_status": h_set_secret_status,
"detect_secret": h_detect_secret,
}
#: v1 角色 agent 的工具格式({"name","description","params","required"},与 AGENT_TOOLS 同构)。
#: v2 走 core 的 ToolDefinition(category="secret"),两套定义同源 SECRET_TOOL_SPECS,
#: 避免描述漂移——改一处两边同步。
SECRET_TOOLS_V1 = [
{"name": s["name"], "description": s["description"],
"params": dict(s.get("parameters") or {}),
"required": list(s.get("required") or [])}
for s in SECRET_TOOL_SPECS
]
#: 角色 agent 默认不挂「写」工具(save/delete/set_status)——无人值守场景下
#: LLM 自主增删凭据风险高于收益,只给读与用(list/use/detect)。
#: 会话 agent(有真人 user_id)挂全套六件。
SECRET_TOOLS_V1_READONLY = [
t for t in SECRET_TOOLS_V1
if t["name"] in ("list_secrets", "use_secret", "detect_secret")
]
#: system prompt 片段(会话 agent + 角色 agent 共用)
SECRET_PROMPT_BLOCK = """
## 敏感信息(凭据)使用铁律
平台有用户敏感信息库(apikey/token/密码/私钥),密文存储。**你自始至终看不到明文,这是设计如此。**
- 需要凭据时先 `list_secrets` 查有哪些(只给元数据:名称/类型/前缀/长度)。
- 在 shell 命令里引用凭据,一律写环境变量 `$PIPELINE_SEC_<名称>`,平台会在执行瞬间注入真值。
例:`curl -H "Authorization: Bearer $PIPELINE_SEC_GITHUB_TOKEN" https://api.github.com/user`
- **禁止**做这些事(会破坏保护):
- 试图把凭据明文打印出来(`echo $PIPELINE_SEC_X`、`printenv PIPELINE_SEC_X`)——输出会回到对话与数据库,等于泄露;
- 猜、编造、或要求用户提供明文值(用户给明文时平台会自动侦测入库,你只需继续用变量名);
- 把凭据写进文件、commit 进 git、或放进 URL 查询参数(会落到对方访问日志)。
- 用户在消息里直接贴了明文(如「apikey:key-xxxx」)时,平台入站门禁已自动密文入库并把原文替换成占位符 `@@sec:<名称>@@`——你会在消息里看到占位符而不是明文,直接用对应环境变量即可,不需要再问用户要。
- 引用了不存在的名称时占位符会原样保留(不会被替换成空值),命令会失败并报错——这时用 `list_secrets` 核对名称。
"""