🎁 福利专区全网大模型免费应用 + 新用户福利 + 注册活动入口,低成本玩转 AI

安全归档工具 yuehancn/dsh-tool-archive

DeepSeek Harness
yuehancn29 天前有提交已提供中文文档扫描:含敏感能力

打包解包目录并校验路径与哈希,防越权写入

30/100
综合分
0
GitHub 星标
—
npm 周下载
—/5
用户评分

数据截至 2026/10/3 · 周下载取自 npm 全网(含 CI / 镜像),非本站安装数;综合分由星标、活跃度与兼容性检查合成。

安装命令 · 需先装 dsh CLI 引擎
dsh plugin --profile web add yuehancn/dsh-tool-archive
该包不可直接 npm 安装,改从 GitHub 源安装(明细见下方安装检查)

状态速览

判定依据:实装验证 · 静态安装检查 · 自动扫描 · 仓库维护状态
信任档位:待复验
是什么
dsh 原生插件 · dev
装得上吗
曾在 dsh 0.2.0-rc.2 下真实安装成功,但当前 dsh 已更新至 0.2.1-rc.5,本站尚未在新版下复验
L4 · 真实安装等级说明 →
安全吗
本站尚未对该插件做风险分级
暂未覆盖,不等同于无风险
还在维护吗
活跃:最近一次提交在 29 天前
档位由哪些信号合成

本站实装验证(真实安装 L4 覆盖全站;L5 为运行时冒烟或实机截图,覆盖范围小于 L4) · 验证所用 dsh 版本 · 静态安装检查 · 风险分级 · 仓库维护状态。 下方各区块是它的证据明细。

验证判据与等级说明 →
社区实装还没有人打卡 · 装过的第一个就是你
安全扫描自动 · 静态分析源码
v2.7.0
🔵 扫描:含敏感能力尚无人工评估
展开查看扫描说明与证据 ↓

静态扫描发现插件具备触达本地系统的能力:执行外部命令、读写/删除本地文件、读取剪贴板或密钥类环境变量(至少其一)。这是能力清单,不是危险判定——具体能力项与证据见下方,安装前请自行判断是否需要。

读写/删除本地文件
查看扫描证据(共 15 处,展示前 12 条)
  • 读取本地文件
    lib/index.js:636
    const names = (await readdir(dir)).sort();
  • 读取本地文件
    lib/index.js:644
    const info = await lstat(full);
  • 读取本地文件
    lib/index.js:733
    const data = await readFile(member.source);
  • 写入本地文件
    lib/index.js:931
    await mkdir(member.target, { recursive: true });
  • 写入本地文件
    lib/index.js:935
    await mkdir(dirname(member.target), { recursive: true });
  • 读取本地文件
    lib/index.js:938
    const exists = await stat(member.target).then(() => true, () => false);
  • 写入本地文件
    lib/index.js:943
    await writeFile(member.target, data);
  • 读取本地文件
    lib/index.js:1040
    const data = await readFile(member.source);
  • 读取本地文件
    lib/index.js:1167
    const info = await stat(file);
  • 写入本地文件
    lib/index.js:1330
    await mkdir(archiveDir, { recursive: true });
  • 写入本地文件
    lib/index.js:1332
    await writeFile(file, packed.buffer);
  • 写入本地文件
    lib/index.js:1479
    await mkdir(dest, { recursive: true });
扫描于 2026/10/31(0 天前) · 范围 入口闭包 + 发布文件清单 · 来源 GitHub 仓库

静态扫描只反映源码中出现的调用,不构成安全背书;人工评估与自动扫描相互独立、互不覆盖。

依赖的 DSH / Cordis 模块
@deepseek-ai/schemastery@deepseek-ai/cordis@deepseek-ai/dsh-tools
给这个插件打分还没有人投票↓
用户评分
还没有人投票,来当第一个

功能亮点

  • 打包解包目录
  • 校验路径与哈希
  • 防越权写入

README

由 DeepSeek 最新模型翻译生成
dsh-tool-archive DeepSeek Harness 的归档工具箱:把目录打成确定性的 tar、不解包就能读清单、解包时逐个成员做路径校验、以及用 SHA-256 验证「解出来的东西 == 打进去的东西」。 零外部依赖 —— tar 格式在这个插件里自己写、自己读。 | 工具 | 做什么 | | archive_status | 报告写哪种 tar、拒绝哪些成员类型、路径要满足什么条件、输出去哪 | | archive_pack | 把目录打成 tar,返回每个文件的 SHA-256 清单 + 整树 digest | | archive_unpack | 列内容 / 解包;绝对路径、..、符号链接、硬链接、设备节点一律拒绝 | | archive_verify | 不解包就核对:给目录比目录,给 digest 比 digest | 为什么需要它 「解个包」是 shell 里的一行,也是模型能被要求做的最危险的操作之一。一个 tar 条目的名字可以是 ../../.ssh/authorized_keys、可以是绝对路径、可以是指向树外的符号链接、可以是指向别的文件的硬链接。任何一个都把「解包」变成「往这台机器的任何地方写」。
展开完整文档(README 剩余 1 节)
dsh-tool-archive

DeepSeek Harness 的归档工具箱:把目录打成确定性的 tar、不解包就能读清单、解包时逐个成员做路径校验、以及用 SHA-256 验证「解出来的东西 == 打进去的东西」。

零外部依赖 —— tar 格式在这个插件里自己写、自己读。

| 工具 | 做什么 |
| archive_status | 报告写哪种 tar、拒绝哪些成员类型、路径要满足什么条件、输出去哪 |
| archive_pack | 把目录打成 tar,返回每个文件的 SHA-256 清单 + 整树 digest |
| archive_unpack | 列内容 / 解包;绝对路径、..、符号链接、硬链接、设备节点一律拒绝 |
| archive_verify | 不解包就核对:给目录比目录,给 digest 比 digest |

为什么需要它

「解个包」是 shell 里的一行,也是模型能被要求做的最危险的操作之一。一个 tar 条目的名字可以是 ../../.ssh/authorized_keys、可以是绝对路径、可以是指向树外的符号链接、可以是指向别的文件的硬链接。任何一个都把「解包」变成「往这台机器的任何地方写」。

而 tar 没有中央索引:名字、大小、类型散落在与数据交错的 512 字节头里,所以「这个包里有什么」是一次遍历而不是一次查表 —— 这就是 archive_status 存在的原因,也是它能不落盘就回答的原因。

快速开始

- id: tool-archive
name: dsh-tool-archive
config:
workDir: C:/work/project
archiveDir: C:/work/project/archive-output
extractDir: C:/work/project/archive-extract

// 打包 await archive_pack({ source: "src" }) // → { file, bytes, files, directories, contentBytes, overheadPercent, // format: "ustar", treeDigest, manifest: [...], skipped: [] }

// 不落盘先看看里面有什么 await archive_unpack({ file: "pack.tar", listOnly: true })

// 解包(危险成员会被拒;strict 默认 true,一旦有拒绝就整体不写) await archive_unpack({ file: "pack.tar", dest: "run-1" })

// 验证 await archive_verify({ file: "pack.tar", against: "src" }) // → { ok: true, treeDigest, unchanged: 5, added: [], removed: [], changed: [] }

确定性:同一个树 → 同一个哈希

这是这个插件最容易被低估的部分,也是它和「随手 tar 一下」最大的区别。

目录遍历的顺序是文件系统给的,不是内容给的。readdir 这次把 b 排在 a 前面,下次可能反过来;uid/gid/mtime 取决于哪台机器、哪个时刻打的包;权限位取决于 umask。任何一项进入哈希,同一个树就会产出不同的归档,于是清单比对就失去了意义 —— 你无法区分「内容变了」和「只是换了台机器」。

所以打包时:

- 成员按路径排序(不是按名字,嵌套树也要稳定)
- uid / gid 固定为 0
- 文件模式固定 0644,目录固定 0755
- treeDigest = 对「类型 + 路径 + 大小 + 内容哈希」这一串做 SHA-256

结果是:同一份内容,无论在哪台机器、以什么顺序创建,digest 都一样,归档字节也逐字节相同。测试里直接断言了这一点:两个用相反顺序创建的目录,包里字节完全相同。

treeDigest 描述的是内容,不是打包过程。所以改一个文件的权限不会让 digest 变化 —— 刻意如此,否则它就没法用来跨机器比对。

路径校验:唯一的边界

所有到达文件系统的成员名字都要过 resolveWithin / resolveEntry,这是这个插件的安全边界。

拒绝条件:

| 情况 | 例子 | 判定 |
| 绝对路径 | /etc/passwd、C:/Windows/x、\\\\srv\\share | absolute |
| .. 组件(含埋在中段) | ../x、a/../../b | traversal |
| 归一化后为空 | .、./、././. | empty |
| 空组件 | a//b | empty-component |
| 含 NUL | a\0b | nul |
| 符号链接 / 硬链接 | flag 2 / 1 | 拒绝,并报出目标 |
| 设备 / FIFO | flag 3 / 4 / 6 | 拒绝,并报出类型 |
| 未知类型标记 | flag Z | 拒绝 |
| 头部 checksum 不符 | — | 拒绝(大小不可信) |

两条容易被忽略的实现细节:

一、输出路径只取最后一段。 path.resolve(dir, name) 会尊重绝对路径的第二参 —— resolve("/out", "C:/Windows/x.png") 真的返回 Windows 路径,写文件能逃出配置目录。所以路由分量在 resolve 看到之前就被丢干净,输入输出两侧都丢。

二、././. 的归一化要循环到不动点。 单次 replace(/^\.\//, "") 把 ././. 变成 ./.,再走一次才变成 .。少了这一步,././. 会通过空名检查并解析成解包根目录本身。

三、. / 开头的正常名字不能误伤。 ./a.txt、./././x.txt 归一化之后是安全的普通名字,必须放行 —— 很多正常工具产出的包就长这样。测试里把它和真正的逃逸攻击并列断言,钉住这个区分。

读取比写入宽容,是有意的

写出去的只有 ustar(POSIX.1-1988):512 字节块、6 位八进制 checksum、长路径拆进 name + prefix 两个字段、不发 GNU long-name / PAX 扩展。
读的时候接受三种方言:标准 ustar(magic 与 version 分开写)、缺 version 字节的、以及完全没有 magic 但按传统偏移排布的。不合规范的头部会记警告但继续读,因为「别人用老工具打的包」是正常输入。

但有一条不宽容:任何名字长到纯 ustar 头放不下就报错,绝不为了塞进去而截断或用扩展。截断一个名字等于把文件写到别的地方去。

三个真 bug(测试抓出来的)

1. 截断的归档读起来「没问题」

while (offset + BLOCK <= buffer.length) 在剩余字节不足一个完整块时直接退出循环。已经读到的成员个个 checksum 正确,于是循环安静结束、调用方被告知归档完好。

一个 3072 字节的包砍到 1027 字节:ok: true。半个文件报「成功」,比报错更糟 —— 报错会有人去看,成功不会。

修复是跟踪是否真的走到了归档结束标记。没走到就说明归档被截断了,报出「读到了 N 个成员后归档就没了,没有结束标记」;尾部剩下的不足 512 字节也一并说明。

测试在每一个块边界上切一刀,逐个断言:凡是被判为「已结束」的切口,它读到的最后一个成员必须是完整的;凡是没走到结束标记的,必须发出声音。恰好只有一个切口(trailer 的第一个零块)是合法的结束点 —— GNU tar 接受单个零块作为结束,所以那个切口产出的确实是一个正确的、只含前若干成员的包。

2. 符号链接被当成普通文件打进包里

Windows 上 lstat 对一个符号链接可能报成普通文件,而 readFile 读它拿不到有用的内容。结果:链接以零字节文件的形式进入归档,带着空字符串的哈希。

归档看起来完全健康,里面却有一个和磁盘上不是一回事的文件。这是静默数据丢失。

修复有两层:

1. 不信任 stat 标志。readlink 独立查一次,任一个信号命中就跳过并报出目标。
2. 列目录的大小和读出来的字节数必须一致。不一致说明文件在遍历途中变了、或者是链接解析到了别处 —— 打包出去会写一个「头部声明的大小和内容不符」的成员,每个读取器都会相信它,没有一个能发现它。所以直接丢弃并说明原因。

顺带修正了 contentBytes:它原本来自遍历,会把被丢弃的成员也算进「归档持有的内容」。改成从 manifest 求和。

3. 大小写冲突会静默吞掉一个文件

在大小写不敏感的文件系统(NTFS、HFS+、默认配置的 APFS)上,README.md 和 readme.md 是同一个文件。而一个在大小写敏感系统上打出来的包,可以合法地同时含有这两个名字 —— 在这里解包时会合成一个,后写的覆盖先写的,调用方拿到一棵少了一个文件的树,全程没有任何报错。

检测放在 planExtraction,因为这两个名字在归档里同时存在,也就是文件系统已经丢掉的信息还留着的地方。检出即告警,并说明会互相覆盖。

(顺带一提:一开始我把这个检测放在打包侧,它永远不可能触发 —— 因为本地 readdir 根本列不出两个只差大小写的名字。测试跑出来是「没命中」,那个失败是对的。)

--force-local:一个会伪装成「环境不支持」的坑

测试里用系统 GNU tar 独立验证产物,这是唯一的外部检查 —— 自己验自己一定会漏。但如果直接写:

execFileSync("tar", ["-tf", "C:/work/x.tar"])

GNU tar 会把 C: 当成远程主机去开 rsh 连接,报 Cannot connect to C: resolve failed。这个错误看起来像「本机没有 tar」,于是检查被安静跳过 —— 唯一的外部验证就这么没了。

必须加 --force-local。另外路径里的反斜杠对原生 Windows 二进制是转义字符,C:\…\out 传进去会变成 C\:… 并报 Cannot open,所以路径要先统一成正斜杠。

测试

node _test/run-all.mjs

636 条断言,4 个套件全绿。

| 套件 | 断言 | 覆盖 |
| test-logic.mjs | 245 | 八进制字段、ustar name/prefix 拆分、checksum、块遍历、截断检测、路径守卫、digest、清单 diff |
| test-integration.mjs | 233 | 四个工具经真实 context、编译后的 schema 形状、注入 seam、打包/解包/验证往返、逃逸攻击 |
| test-e2e.mjs | 120 | 真目录真字节、二进制载荷、空文件、空目录、CJK 文件名、系统 GNU tar 互操作、恶意归档 |
| regression-published.mjs | 38 | 不改一字地从磁盘 import 入口:package.json 元数据、真 schemastery 默认值、真 dsh-tools 编译 schema、register → execute 端到端 |

前三个套件把插件的 import 剥掉、用 new Function 重建,好让白盒断言能碰到内部函数 —— 但那样从来没有真正加载过这个包。第四个套件什么都不剥:import 磁盘上那份 lib/index.js,让真的 @deepseek-ai/dsh-tools 编译 schema,再真的注册、真的执行一次。它排在最后:如果它红了,说明内部逻辑是对的而产物不对。

夹具的 tar 由 harness 里一个独立、朴素、不支持 prefix 的写入器生成,不用被测代码。自己造夹具给自己读,会掩盖写入器本身的 bug;两者一致才有意义。

e2e 的恶意归档用文件系统断言而不是只看返回对象:被拒绝的成员必须没有留下任何痕迹。一个「报了拒绝但仍然写了文件」的实现,只断言对象是发现不了的。

配置

| 键 | 默认 | 说明 |
| workDir | . | 相对路径解析基准 |
| archiveDir | archive-output | 归档读写目录 |
| extractDir | archive-extract | 解包根目录 |
| exclude | node_modules/.git/.DS_Store/Thumbs.db | 末段名跳过 |
| maxFileBytes | 0 | 单文件上限;0 = 不限 |
| maxEntries | 20000 | 超出会告警(不静默截断) |
| maxTotalBytes | 2 GiB | 解包总量上限,也是读入内存的上限 |
| maxNameBytes | 1000 | 成员名字节上限 |
| hash | true | 打包时算 SHA-256 |
| noOverwrite | false | 解包时拒绝覆盖 |
| status/pack/unpack/verify | true | 分别开关四个工具 |

说清楚三件它不做的事

- 不写 GNU long-name / PAX 扩展。 名字长到纯 ustar 放不下就报错。要打极深路径的包,这个插件不合适。
- 不解符号链接和硬链接。 不是「暂不支持」,是刻意拒绝:链接的目标在本机是另一个意思,解出来就是把树的出口交给归档作者。
- 不压缩。 输出是纯 .tar。压缩交给别的工具,这个插件的职责是把「哪些字节、按什么顺序、用什么名字」说准。

许可

MIT
预览分享卡片长什么样 ↓yuehancn/dsh-tool-archive
分享 · DeepSeek Harness Hub
yuehancn/dsh-tool-archive

打包解包目录并校验路径与哈希,防越权写入

回到顶部