@dsh-fly/dsh-plugin-fly-mode 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/cordis.patch.yml +0 -1
- package/package.json +2 -2
- package/tools/dsh-plugin-fly-artifact/index.js +21 -2
- package/tools/dsh-plugin-fly-binary/index.js +110 -22
- package/tools/dsh-plugin-fly-blackbox/index.js +23 -4
- package/tools/dsh-plugin-fly-diag/index.js +177 -28
- package/tools/dsh-plugin-fly-pwn/index.js +91 -9
- package/tools/dsh-plugin-fly-recon/index.js +117 -16
- package/tools/dsh-plugin-fly-reverse/index.js +132 -18
package/README.md
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
- **Fly 标准模式** `preset-fly-standard`(行表 = **上游 `standard` 的行表** + 一行 smart,33 行)——
|
|
12
12
|
由生成器从**引擎自己的组合结果**里取上游行表(不手抄),所以引擎升级后行表跟着走、漂了 `--check` 会红。
|
|
13
13
|
其中
|
|
14
|
-
**
|
|
15
|
-
`tools/`**,行表用**本包子路径**引用它们 —— 装这个包**不会**在 npm 上再拉这
|
|
14
|
+
**8 个 Fly 工具**(binary / reverse / pwn / diag / recon / blackbox / artifact / guard)的源码**内联在包内
|
|
15
|
+
`tools/`**,行表用**本包子路径**引用它们 —— 装这个包**不会**在 npm 上再拉这 8 个包;
|
|
16
16
|
**`fly-smart` 单独一个 plugin**:它是本包 `dependencies` 里**唯一**一项,行表保持裸名,
|
|
17
17
|
由 npm 装进 profile 的 `node_modules`(它变化最快,且与工作台面板是一个整体,不适合内联)。
|
|
18
18
|
- **不是**:不带 `dsh.client`(模式是 agent 平面,没有浏览器半边);不带 Fly 工作台
|
|
@@ -76,7 +76,7 @@ dsh plugin --profile=web add @dsh-fly/dsh-plugin-fly-mode@0.1.3 --registry=https
|
|
|
76
76
|
|
|
77
77
|
## 仓库状态(2026-10-11)
|
|
78
78
|
|
|
79
|
-
- 形状定稿:一条 `- insert:` 声明 + 包内 `tools/`(
|
|
79
|
+
- 形状定稿:一条 `- insert:` 声明 + 包内 `tools/`(8 个工具内联,行表用本包子路径引用)+
|
|
80
80
|
`dsh-plugin-fly-smart` 作为**唯一** npm 依赖。声明由 `dsh-fly` 仓的
|
|
81
81
|
`scripts/build-fly-mode-bundle.mjs` 生成,门禁用 `--check` 断漂移(含多出来的文件)。
|
|
82
82
|
- `.github/workflows/{ci,release}.yml` **已适配本仓**(纯声明包:不装依赖、直接跑
|
package/cordis.patch.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-fly/dsh-plugin-fly-mode",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Fly 模式作为一个可安装的包:**两个 preset** —— Fly 安全模式(preset-fly,
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "Fly 模式作为一个可安装的包:**两个 preset** —— Fly 安全模式(preset-fly,8 个安全工具内联 + smart)与 Fly 标准模式(preset-fly-standard,上游 standard 的行表 + smart,无安全工具);依赖单独发布的 dsh-plugin-fly-smart。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -39,6 +39,12 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
39
39
|
*
|
|
40
40
|
* 两个入口都在 P1 的"结论要能回指源码位置"那条要求下:清单的每条成员给**相对路径**,
|
|
41
41
|
* 边界的每条命中给 **file:line**。
|
|
42
|
+
*
|
|
43
|
+
* **起因**(为什么这个包存在):`morluto/rea` 那边有 APK/JADX、固件/Binwalk、.NET、**JS/ASAR 与 HAR**
|
|
44
|
+
* 这些"看得懂打包产物"的能力,而 fly 这边此前只有原生二进制三件套 —— 一个 Electron 应用的
|
|
45
|
+
* 150MB `app.asar` 摆在面前,只能靠 `bin_strings` 碰运气。安全模式的定位是客户端黑盒排查,
|
|
46
|
+
* 所以这一面必须在拆仓之前补上(规划 §3.2):先把"清单 + 字面量边界"两层做出来,
|
|
47
|
+
* 再谈 AST 与 CDP(P2)。**不抄代码,只在设计上对齐**(分层、覆盖率、bounds 三件)。
|
|
42
48
|
*/
|
|
43
49
|
|
|
44
50
|
export const name = 'fly-artifact'
|
|
@@ -59,6 +65,19 @@ function shq(value) {
|
|
|
59
65
|
* version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
|
|
60
66
|
* names a seam method the executor does not have.
|
|
61
67
|
*/
|
|
68
|
+
/**
|
|
69
|
+
* 路径参数规范化:以 `-` 开头的路径加 `./` 前缀(2026-10-13)。
|
|
70
|
+
*
|
|
71
|
+
* 为什么不插 `--`:实测本机(macOS,发布目标平台)**objdump(-h/-d/-T)/ base64 -i / otool -hv 都不支持
|
|
72
|
+
* `--`**,插进去直接失败;而 `file -b --version` 会打印 `file-5.41`、`nm -u --version` 会打印
|
|
73
|
+
* `llvm-nm, …`,两者都 **exit 0** ⇒ 工具会把版本串当**数据**返回。加 `./` 之后它就是一个普通相对路径:
|
|
74
|
+
* 不存在就正常报错,存在就是那个文件。
|
|
75
|
+
*/
|
|
76
|
+
function safePath(value) {
|
|
77
|
+
const text = String(value ?? '')
|
|
78
|
+
return text.startsWith('-') ? './' + text : text
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
async function runCmd(shell, argv, exec, opts = {}) {
|
|
63
82
|
const result = await (await shell.execute(shell.resolve({
|
|
64
83
|
command: argv.map(shq).join(' '),
|
|
@@ -632,7 +651,7 @@ export function apply(ctx) {
|
|
|
632
651
|
},
|
|
633
652
|
output: stringOutput,
|
|
634
653
|
async execute(args, exec) {
|
|
635
|
-
const path = typeof args.path === 'string' ? args.path.trim() : ''
|
|
654
|
+
const path = safePath(typeof args.path === 'string' ? args.path.trim() : '')
|
|
636
655
|
if (path === '') throw new Error('artifact_asar_inventory 需要 path(.asar 的路径)')
|
|
637
656
|
const top = readTop(args.top, 'artifact_asar_inventory')
|
|
638
657
|
|
|
@@ -770,7 +789,7 @@ export function apply(ctx) {
|
|
|
770
789
|
},
|
|
771
790
|
output: stringOutput,
|
|
772
791
|
async execute(args, exec) {
|
|
773
|
-
const path = typeof args.path === 'string' ? args.path.trim() : ''
|
|
792
|
+
const path = safePath(typeof args.path === 'string' ? args.path.trim() : '')
|
|
774
793
|
if (path === '') throw new Error('artifact_js_boundaries 需要 path(.asar 的路径,或解包后的 JS 目录)')
|
|
775
794
|
const top = readTop(args.top, 'artifact_js_boundaries')
|
|
776
795
|
const scope = args.scope === undefined ? 'app' : args.scope
|
|
@@ -5,6 +5,14 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
5
5
|
* Registers model-facing analysis tools on ctx.tools. All command execution
|
|
6
6
|
* goes through the host `shell` capability seam so the sandbox confines it;
|
|
7
7
|
* a tool never shells out via node:child_process directly.
|
|
8
|
+
*
|
|
9
|
+
* **失败面的口径是"大声"**(Q5):缺 `readelf`/`objdump`/`strings`/`file`/`wc` 时**不静默降级** ——
|
|
10
|
+
* · 能在仓库内补的就补:没有 GNU readelf 就用**仓库内置的原生解析器**算头部/段/动态(并在
|
|
11
|
+
* 结果的 `limitations` 里明说"这是内置解析器算的,不是 readelf 的输出");
|
|
12
|
+
* · 补不了的就**抛**:`requireOk` 让缺命令以 `CommandError` 报出来(历史教训:`pwn_checksec`
|
|
13
|
+
* 曾在缺工具时静默回退,对**任何**二进制都报 `pie=no`);
|
|
14
|
+
* · "没有"要**说成没有**:`bin_strings` 一条都没匹配时返回"no printable strings matching …",
|
|
15
|
+
* 绝不返回空串(空串会被读成"这个文件没有字符串");节表读不到时同理。
|
|
8
16
|
*/
|
|
9
17
|
export const name = 'fly-binary'
|
|
10
18
|
export const inject = ['tools', 'shell']
|
|
@@ -24,6 +32,19 @@ function shq(value) {
|
|
|
24
32
|
* version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
|
|
25
33
|
* names a seam method the executor does not have.
|
|
26
34
|
*/
|
|
35
|
+
/**
|
|
36
|
+
* 路径参数规范化:以 `-` 开头的路径加 `./` 前缀(2026-10-13)。
|
|
37
|
+
*
|
|
38
|
+
* 为什么不插 `--`:实测本机(macOS,发布目标平台)**objdump(-h/-d/-T)/ base64 -i / otool -hv 都不支持
|
|
39
|
+
* `--`**,插进去直接失败;而 `file -b --version` 会打印 `file-5.41`、`nm -u --version` 会打印
|
|
40
|
+
* `llvm-nm, …`,两者都 **exit 0** ⇒ 工具会把版本串当**数据**返回(`bin_sections { path: '--version' }`
|
|
41
|
+
* 真发生过)。加 `./` 之后它就是一个普通相对路径:不存在就正常报错,存在就是那个文件。
|
|
42
|
+
*/
|
|
43
|
+
function safePath(value) {
|
|
44
|
+
const text = String(value ?? '')
|
|
45
|
+
return text.startsWith('-') ? './' + text : text
|
|
46
|
+
}
|
|
47
|
+
|
|
27
48
|
async function runCmd(shell, argv, exec, opts = {}) {
|
|
28
49
|
const result = await (await shell.execute(shell.resolve({
|
|
29
50
|
command: argv.map(shq).join(' '),
|
|
@@ -54,9 +75,19 @@ async function runCmd(shell, argv, exec, opts = {}) {
|
|
|
54
75
|
*/
|
|
55
76
|
function requireOk(run, what) {
|
|
56
77
|
if (run.exitCode === 0) return run.stdout
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
78
|
+
// 沙箱拒绝的形状是**结果正文里的标记**(`[sandbox: file access denied under <mode> mode]`,
|
|
79
|
+
// 抄自 @deepseek-ai/dsh-sandbox/index.js:65)—— 它落在 stdout 上;只读 stderr 就会把它丢掉,
|
|
80
|
+
// 于是模型读到的是「命令失败」,而不是「你没有被授权」。所以标记必须原样带进错误文案。
|
|
81
|
+
const sandboxMark = /^\[sandbox: .*\]$/mu.exec(run.stdout)
|
|
82
|
+
const detail = [sandboxMark?.[0], ...run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean)].slice(0, 3).join(' | ')
|
|
83
|
+
const notInstalled = run.exitCode === 127 || /command not found/iu.test(run.stderr)
|
|
84
|
+
const missing = notInstalled ? ' — the command is not installed on this host' : ''
|
|
85
|
+
// 失败文案要对**读它的模型**可行动(Q6):不是裸 stderr,但也不许臆断原因 ——
|
|
86
|
+
// 只给「这条 exit 属于哪一类」与「下一步可以试什么」,具体判断仍留给读的人。
|
|
87
|
+
const next = notInstalled
|
|
88
|
+
? 'next: this host lacks that command — look for a built-in fallback in this package, or read the file another way'
|
|
89
|
+
: 'next: check the path, and that the file is what this tool expects (bin_file shows what it is); the line above is the command own reason'
|
|
90
|
+
const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}\n${next}`}`)
|
|
60
91
|
error.name = 'CommandError'
|
|
61
92
|
throw error
|
|
62
93
|
}
|
|
@@ -432,10 +463,23 @@ export function apply(ctx) {
|
|
|
432
463
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
433
464
|
},
|
|
434
465
|
async execute(args, exec) {
|
|
435
|
-
const
|
|
466
|
+
const path = safePath(args.path)
|
|
467
|
+
const wc = await runCmd(shell, ['wc', '-c', path], exec)
|
|
436
468
|
const size = requireOk(wc, 'wc -c').trim().split(/\s+/)[0] ?? '0'
|
|
437
|
-
const identify = await runCmd(shell, ['file', '-b',
|
|
438
|
-
|
|
469
|
+
const identify = await runCmd(shell, ['file', '-b', path], exec)
|
|
470
|
+
// 证据形状(规划 §5.1):信封 JSON,原文本原样进 observed.dump。
|
|
471
|
+
const text = `size=${size}\n${requireOk(identify, 'file -b').trim()}`
|
|
472
|
+
return JSON.stringify({
|
|
473
|
+
tool: 'bin_file',
|
|
474
|
+
target: args.path,
|
|
475
|
+
operation: 'one wc -c + one file -b (no parsing of the file content)',
|
|
476
|
+
observed: { bytes: Number(size), dump: text },
|
|
477
|
+
confidence: 'observed',
|
|
478
|
+
limitations: [
|
|
479
|
+
'只报 wc -c 的字节数与 file(1) 的判断:**不解析文件内部**(要字段另用 bin_readelf / bin_sections)',
|
|
480
|
+
'file(1) 的判断来自魔数与片段匹配:损坏或被裁剪的文件也可能被认成某种格式',
|
|
481
|
+
],
|
|
482
|
+
}, null, 2)
|
|
439
483
|
},
|
|
440
484
|
}))
|
|
441
485
|
|
|
@@ -453,18 +497,39 @@ export function apply(ctx) {
|
|
|
453
497
|
},
|
|
454
498
|
async execute(args, exec) {
|
|
455
499
|
const min = String(args.min ?? 4)
|
|
456
|
-
const
|
|
500
|
+
const path = safePath(args.path)
|
|
501
|
+
const run = await runCmd(shell, ['strings', '-n', min, path], exec, { stdoutMaxBytes: 1048576 })
|
|
457
502
|
let lines = requireOk(run, `strings -n ${min}`).split('\n').filter(Boolean)
|
|
458
503
|
if (args.grep) {
|
|
459
504
|
const g = args.grep.toLowerCase()
|
|
460
505
|
lines = lines.filter((l) => l.toLowerCase().includes(g))
|
|
461
506
|
}
|
|
462
507
|
if (lines.length === 0) {
|
|
463
|
-
return
|
|
464
|
-
|
|
465
|
-
:
|
|
508
|
+
return JSON.stringify({
|
|
509
|
+
tool: 'bin_strings',
|
|
510
|
+
target: args.path,
|
|
511
|
+
operation: `strings -n ${min}` + (args.grep === undefined ? '' : ` filtered by ${JSON.stringify(args.grep)}`),
|
|
512
|
+
observed: { matched: 0, min: Number(min), grep: args.grep ?? null, dump: args.grep === undefined
|
|
513
|
+
? `no printable strings of length ${min} or more in ${args.path}`
|
|
514
|
+
: `no printable strings matching ${JSON.stringify(args.grep)} (min length ${min}) in ${args.path}` },
|
|
515
|
+
confidence: 'observed',
|
|
516
|
+
limitations: [
|
|
517
|
+
'一条都没匹配上 ⇒ 这是「没有这样的字符串」,不是「这个文件里没有字符串」',
|
|
518
|
+
'strings(1) 只看可打印字节序列:加密/压缩/UTF-16 里的字符串它看不到',
|
|
519
|
+
],
|
|
520
|
+
}, null, 2)
|
|
466
521
|
}
|
|
467
|
-
return
|
|
522
|
+
return JSON.stringify({
|
|
523
|
+
tool: 'bin_strings',
|
|
524
|
+
target: args.path,
|
|
525
|
+
operation: `strings -n ${min}` + (args.grep === undefined ? '' : ` filtered by ${JSON.stringify(args.grep)}`),
|
|
526
|
+
observed: { matched: lines.length, shown: Math.min(lines.length, 400), min: Number(min), grep: args.grep ?? null, dump: lines.slice(0, 400).join('\n') },
|
|
527
|
+
confidence: 'observed',
|
|
528
|
+
limitations: [
|
|
529
|
+
lines.length > 400 ? '只给前 400 条(observed.matched 是匹配总数):要更多就加 grep 缩小范围' : '这次没有截断(匹配数 ≤ 400)',
|
|
530
|
+
'strings(1) 只看可打印字节序列:加密/压缩/UTF-16 里的字符串它看不到',
|
|
531
|
+
],
|
|
532
|
+
}, null, 2)
|
|
468
533
|
},
|
|
469
534
|
}))
|
|
470
535
|
|
|
@@ -480,26 +545,48 @@ export function apply(ctx) {
|
|
|
480
545
|
render: (_args, value) => [{ type: 'text', text: value }],
|
|
481
546
|
},
|
|
482
547
|
async execute(args, exec) {
|
|
548
|
+
// 证据形状(规划 §5.1):四个分支都产出同一个信封;两条方法(GNU readelf / 仓库内置解析器)
|
|
549
|
+
// 必须写进 limitations,否则"本机没有 readelf"会被读成"这份 ELF 就是这样"。
|
|
550
|
+
const envelope = (observed, limitations) => JSON.stringify({
|
|
551
|
+
tool: 'bin_readelf',
|
|
552
|
+
target: args.path,
|
|
553
|
+
operation: `read ELF ${mode} (${flag}) and report it verbatim or via the built-in reader)`,
|
|
554
|
+
observed,
|
|
555
|
+
confidence: 'observed',
|
|
556
|
+
limitations,
|
|
557
|
+
}, null, 2)
|
|
483
558
|
const mode = args.mode === 'segments' || args.mode === 'dynamic' ? args.mode : 'headers'
|
|
484
559
|
const flag = mode === 'segments' ? '-l' : mode === 'dynamic' ? '-d' : '-h'
|
|
485
560
|
// GNU binutils is not part of macOS. When readelf is absent (or refuses the
|
|
486
561
|
// file) the ELF is parsed here instead: the previous revision threw
|
|
487
562
|
// "command not found" on every Mac, which made four of the five binary
|
|
488
563
|
// tools useless on the platform this app ships for.
|
|
489
|
-
const
|
|
490
|
-
|
|
491
|
-
|
|
564
|
+
const path = safePath(args.path)
|
|
565
|
+
const real = await runCmd(shell, ['readelf', flag, path], exec)
|
|
566
|
+
if (real.exitCode === 0 && real.stdout.trim() !== '') {
|
|
567
|
+
return envelope({ method: 'readelf', dump: real.stdout }, [
|
|
568
|
+
'GNU readelf 的输出原样返回,本工具不改写它',
|
|
569
|
+
])
|
|
570
|
+
}
|
|
571
|
+
const elf = await elfProbe(rawRun(shell, exec), path)
|
|
492
572
|
if (elf === null) {
|
|
493
|
-
const kind = requireOk(await runCmd(shell, ['file', '-b',
|
|
494
|
-
return [
|
|
573
|
+
const kind = requireOk(await runCmd(shell, ['file', '-b', path], exec), 'file -b').trim()
|
|
574
|
+
return envelope({ method: null, dump: [
|
|
495
575
|
`readelf ${flag} is unavailable on this host (${real.exitCode === 127 ? 'not installed' : `exit ${real.exitCode}`})`,
|
|
496
576
|
`${args.path} is not ELF, so a native ELF reader has nothing to report either — file(1) says: ${kind}`,
|
|
497
577
|
'for Mach-O use rev_disasm / rev_imports (objdump, otool and nm ship with macOS)',
|
|
498
|
-
].join('\n')
|
|
578
|
+
].join('\n') }, [
|
|
579
|
+
'本机没有可用的 readelf,而这个文件也不是 ELF ⇒ 两条路都给不出读数(observed.dump 里是原因)',
|
|
580
|
+
])
|
|
499
581
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
582
|
+
// 本机没有 GNU readelf ⇒ 下面三支都是**仓库内置解析器**算的,limitations 要说清。
|
|
583
|
+
const native = [
|
|
584
|
+
'本机没有 GNU readelf(macOS 不带 binutils)⇒ 这份读数是仓库内置原生解析器算的,不是 readelf 的输出',
|
|
585
|
+
'内置解析器只覆盖本工具声明的字段;readelf 的其它开关(--notes/--relocs 等)它不做',
|
|
586
|
+
]
|
|
587
|
+
if (mode === 'segments') return envelope({ method: 'native', dump: renderElfSegments(elf) }, native)
|
|
588
|
+
if (mode === 'dynamic') return envelope({ method: 'native', dump: await renderElfDynamic(elf) }, native)
|
|
589
|
+
return envelope({ method: 'native', dump: renderElfHeader(elf) }, native)
|
|
503
590
|
},
|
|
504
591
|
}))
|
|
505
592
|
|
|
@@ -517,11 +604,12 @@ export function apply(ctx) {
|
|
|
517
604
|
// 证据形状(规划 §5.1 的第一个试点):返回值是**信封 JSON**,不再是裸表格文本。
|
|
518
605
|
// 真值判据不变(observed.dump 里是 objdump 原文),新增 confidence 与 limitations ——
|
|
519
606
|
// 逼这个工具回答"这个结论看不见什么"(本仓最贵的那条教训)。
|
|
520
|
-
const
|
|
607
|
+
const path = safePath(args.path)
|
|
608
|
+
const run = await runCmd(shell, ['objdump', '-h', path], exec)
|
|
521
609
|
const ok = run.exitCode === 0 && run.stdout.trim() !== ''
|
|
522
610
|
// GNU binutils is not part of macOS. Instead of an opaque "command not found",
|
|
523
611
|
// say which method failed and what to use instead (same shape as bin_readelf).
|
|
524
|
-
const kind = ok ? null : await runCmd(shell, ['file', '-b',
|
|
612
|
+
const kind = ok ? null : await runCmd(shell, ['file', '-b', path], exec)
|
|
525
613
|
return JSON.stringify({
|
|
526
614
|
tool: 'bin_sections',
|
|
527
615
|
target: args.path,
|
|
@@ -306,6 +306,19 @@ function assertNotPrivileged(command) {
|
|
|
306
306
|
* 这一点(本仓既有五个插件的 `runCmd` 正是这个写法;门禁桩现在按真接缝的形状发布
|
|
307
307
|
* `result()`,所以这类"桩和真接缝不同形"的假绿会在这一层被抓住)。
|
|
308
308
|
*/
|
|
309
|
+
/**
|
|
310
|
+
* 路径参数规范化:以 `-` 开头的路径加 `./` 前缀(2026-10-13)。
|
|
311
|
+
*
|
|
312
|
+
* 为什么不插 `--`:实测本机(macOS,发布目标平台)**objdump(-h/-d/-T)/ base64 -i / otool -hv 都不支持
|
|
313
|
+
* `--`**,插进去直接失败;而 `file -b --version` 会打印 `file-5.41`、`nm -u --version` 会打印
|
|
314
|
+
* `llvm-nm, …`,两者都 **exit 0** ⇒ 工具会把版本串当**数据**返回。加 `./` 之后它就是一个普通相对路径:
|
|
315
|
+
* 不存在就正常报错,存在就是那个文件。
|
|
316
|
+
*/
|
|
317
|
+
function safePath(value) {
|
|
318
|
+
const text = String(value ?? '')
|
|
319
|
+
return text.startsWith('-') ? './' + text : text
|
|
320
|
+
}
|
|
321
|
+
|
|
309
322
|
async function runCmd(shell, argv, exec, opts = {}) {
|
|
310
323
|
const command = typeof argv === 'string' ? argv : argv.map(shq).join(' ')
|
|
311
324
|
assertNotPrivileged(command)
|
|
@@ -968,11 +981,17 @@ export function readHarDocument(buffer, path) {
|
|
|
968
981
|
try {
|
|
969
982
|
parsed = JSON.parse(payload.toString('utf8').replace(/^\uFEFF/u, ''))
|
|
970
983
|
} catch (error) {
|
|
971
|
-
|
|
984
|
+
// Q6(2026-10-13):失败文案要对读它的模型可行动 —— what + why + **what to try next**,
|
|
985
|
+
// 且不臆断具体原因(写错的「为什么」比没有更糟)。
|
|
986
|
+
throw new Error(`${path} 不是合法 JSON${gzip ? '(已按 gzip magic 解压)' : ''}:${error.message}`
|
|
987
|
+
+ ' —— 修法:确认这是 HAR 导出(浏览器 DevTools 的 Save all as HAR,或 mitmproxy 的硬拷贝转 HAR);'
|
|
988
|
+
+ '只做普通 JSON 校验的话,先看它是不是别的格式。')
|
|
972
989
|
}
|
|
973
990
|
const entries = parsed?.log?.entries
|
|
974
991
|
if (!Array.isArray(entries)) {
|
|
975
|
-
throw new Error(`${path} 不像 HAR:log.entries 不是数组(HAR 1.2 的条目都在 log.entries 下)。`
|
|
992
|
+
throw new Error(`${path} 不像 HAR:log.entries 不是数组(HAR 1.2 的条目都在 log.entries 下)。`
|
|
993
|
+
+ ' —— 修法:重新导出一份 HAR,或确认导出的是 HAR 而不是本仓自己的 mitmproxy dump'
|
|
994
|
+
+ '(`.mitm` 那种用 bx_flows 读)。')
|
|
976
995
|
}
|
|
977
996
|
const creator = parsed.log.creator
|
|
978
997
|
return {
|
|
@@ -1696,7 +1715,7 @@ export function apply(ctx) {
|
|
|
1696
1715
|
const root = workspaceRoot(exec)
|
|
1697
1716
|
const state = readState(root)
|
|
1698
1717
|
const action = String(args.action ?? '')
|
|
1699
|
-
const dump = args.path ?? state?.dump ?? join(root, '.fly-blackbox', 'flows.mitm')
|
|
1718
|
+
const dump = safePath(args.path ?? state?.dump ?? join(root, '.fly-blackbox', 'flows.mitm'))
|
|
1700
1719
|
|
|
1701
1720
|
const size = await statSizeVia(shell, exec, dump, 'dump')
|
|
1702
1721
|
if (size > 32 * 1024 * 1024) {
|
|
@@ -1773,7 +1792,7 @@ export function apply(ctx) {
|
|
|
1773
1792
|
},
|
|
1774
1793
|
output: stringOutput,
|
|
1775
1794
|
async execute(args, exec) {
|
|
1776
|
-
const path = typeof args.path === 'string' ? args.path.trim() : ''
|
|
1795
|
+
const path = safePath(typeof args.path === 'string' ? args.path.trim() : '')
|
|
1777
1796
|
if (path === '') throw new Error('bx_har 需要 path(.har 或 .har.gz 的路径)')
|
|
1778
1797
|
const groupBy = args.groupBy ?? 'host'
|
|
1779
1798
|
if (!HAR_GROUPINGS.includes(groupBy)) {
|
|
@@ -30,7 +30,26 @@ function shq(value) {
|
|
|
30
30
|
* `TypeError: shell.run is not a function` on every call, in every engine
|
|
31
31
|
* version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
|
|
32
32
|
* names a seam method the executor does not have.
|
|
33
|
+
*
|
|
34
|
+
* **失败面的口径是"大声"**(Q5):这一包全是**活读数**,"没量到"绝不能读成"没有" ——
|
|
35
|
+
* · 缺 `ps` / `lsof` / `netstat` / `ping` 时各自走已登记的路径:能换方法的换(`lsof` 不通换
|
|
36
|
+
* `netstat`,并在结果里说清"netstat 的表更粗、没有进程名/PID"),换不了就 `requireOk` 抛错;
|
|
37
|
+
* · "成功却没输出"是一等状态:`diag_procs` 会明说"ps 成功却没有任何输出 ⇒ 这张表是被读空的";
|
|
38
|
+
* · `diag_net_watch` 读到**半个样本**就跳过并**报出跳过数**(静默丢数据比丢数据更糟),
|
|
39
|
+
* 停止是**异步**的(写完信号文件就返回),这些都在各支的 `limitations` 里。
|
|
33
40
|
*/
|
|
41
|
+
/**
|
|
42
|
+
* 路径参数规范化:以 `-` 开头的路径加 `./` 前缀(2026-10-14)。
|
|
43
|
+
*
|
|
44
|
+
* 与六个安全包里的同名助手同源(§5.4.1/§5.4.3):不插 `--`(本机 objdump/base64/otool 不支持),
|
|
45
|
+
* 而是把它变成一个普通相对路径。这里用于 `diag_net_watch` 的 `dir` —— 同一族的**无伤**版本
|
|
46
|
+
* (裸 `-o/tmp/x` 进 `mkdir` 只会响亮报错),本笔是**一致性收尾**,不是安全修复。
|
|
47
|
+
*/
|
|
48
|
+
function safePath(value) {
|
|
49
|
+
const text = String(value ?? '')
|
|
50
|
+
return text.startsWith('-') ? './' + text : text
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
async function runCmd(shell, argv, exec, opts = {}) {
|
|
35
54
|
const result = await (await shell.execute(shell.resolve({
|
|
36
55
|
command: argv.map(shq).join(' '),
|
|
@@ -60,9 +79,19 @@ async function runCmd(shell, argv, exec, opts = {}) {
|
|
|
60
79
|
*/
|
|
61
80
|
function requireOk(run, what) {
|
|
62
81
|
if (run.exitCode === 0) return run.stdout
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
82
|
+
// 沙箱拒绝的形状是**结果正文里的标记**(`[sandbox: file access denied under <mode> mode]`,
|
|
83
|
+
// 抄自 @deepseek-ai/dsh-sandbox/index.js:65)—— 它落在 stdout 上;只读 stderr 就会把它丢掉,
|
|
84
|
+
// 于是模型读到的是「命令失败」,而不是「你没有被授权」。所以标记必须原样带进错误文案。
|
|
85
|
+
const sandboxMark = /^\[sandbox: .*\]$/mu.exec(run.stdout)
|
|
86
|
+
const detail = [sandboxMark?.[0], ...run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean)].slice(0, 3).join(' | ')
|
|
87
|
+
const notInstalled = run.exitCode === 127 || /command not found/iu.test(run.stderr)
|
|
88
|
+
const missing = notInstalled ? ' — the command is not installed on this host' : ''
|
|
89
|
+
// 失败文案要对**读它的模型**可行动(Q6):不是裸 stderr,但也不许臆断原因 ——
|
|
90
|
+
// 只给「这条 exit 属于哪一类」与「下一步可以试什么」,具体判断仍留给读的人。
|
|
91
|
+
const next = notInstalled
|
|
92
|
+
? 'next: this host lacks that command — look for a built-in fallback in this package, or read the file another way'
|
|
93
|
+
: 'next: check the path, and that the file is what this tool expects (bin_file shows what it is); the line above is the command own reason'
|
|
94
|
+
const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}\n${next}`}`)
|
|
66
95
|
error.name = 'CommandError'
|
|
67
96
|
throw error
|
|
68
97
|
}
|
|
@@ -382,8 +411,7 @@ export function apply(ctx) {
|
|
|
382
411
|
async execute(_args, exec) {
|
|
383
412
|
exec.signal.throwIfAborted()
|
|
384
413
|
const mem = os.totalmem()
|
|
385
|
-
|
|
386
|
-
{
|
|
414
|
+
const hostSnapshot = {
|
|
387
415
|
hostname: os.hostname(),
|
|
388
416
|
platform: os.platform(),
|
|
389
417
|
release: os.release(),
|
|
@@ -394,10 +422,18 @@ export function apply(ctx) {
|
|
|
394
422
|
freeMemGB: +(os.freemem() / 1024 ** 3).toFixed(2),
|
|
395
423
|
loadavg: os.loadavg().map((n) => +n.toFixed(2)),
|
|
396
424
|
uptimeHours: +(os.uptime() / 3600).toFixed(2),
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
425
|
+
}
|
|
426
|
+
return JSON.stringify({
|
|
427
|
+
tool: 'diag_host',
|
|
428
|
+
target: 'this host',
|
|
429
|
+
operation: 'read local host identity, CPU and memory counters (read-only, no probing)',
|
|
430
|
+
observed: hostSnapshot,
|
|
431
|
+
confidence: 'observed',
|
|
432
|
+
limitations: [
|
|
433
|
+
'只看得到本机:没有对端/邻居的信息(对端要用 recon_* 或 diag_net_rtt 单独量)',
|
|
434
|
+
'负载与内存是瞬时读数:不说明趋势,也不说明是不是刚刚才变(趋势要用 diag_net_watch 那类记录仪)',
|
|
435
|
+
],
|
|
436
|
+
}, null, 2)
|
|
401
437
|
},
|
|
402
438
|
}))
|
|
403
439
|
|
|
@@ -412,11 +448,24 @@ export function apply(ctx) {
|
|
|
412
448
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
413
449
|
},
|
|
414
450
|
async execute(args, exec) {
|
|
451
|
+
// 证据形状(规划 §5.1):两支都产出信封。
|
|
452
|
+
const envelope = (observed, limitations) => JSON.stringify({
|
|
453
|
+
tool: 'diag_procs',
|
|
454
|
+
target: 'this host',
|
|
455
|
+
operation: 'take one ps aux snapshot and return the top rows by %CPU',
|
|
456
|
+
observed,
|
|
457
|
+
confidence: 'observed',
|
|
458
|
+
limitations,
|
|
459
|
+
}, null, 2)
|
|
415
460
|
const limit = Math.max(1, Math.min(50, Number(args.limit) || 15))
|
|
416
461
|
const stdout = requireOk(await runCmd(shell, ['ps', 'aux'], exec, { stdoutMaxBytes: 1048576 }), 'ps aux')
|
|
417
462
|
const lines = stdout.split('\n').filter(Boolean)
|
|
418
463
|
const [header, ...rest] = lines
|
|
419
|
-
if (header === undefined)
|
|
464
|
+
if (header === undefined) {
|
|
465
|
+
return envelope({ method: 'ps', rows: 0, dump: 'ps aux produced no output (the command succeeded, which is suspicious)' }, [
|
|
466
|
+
'ps 成功却没有任何输出 ⇒ 这张表是被读空的:**不能**读成「本机没有进程」',
|
|
467
|
+
])
|
|
468
|
+
}
|
|
420
469
|
const body = rest
|
|
421
470
|
.sort((a, b) => {
|
|
422
471
|
const pa = parseFloat(a.split(/\s+/)[2] || '0')
|
|
@@ -424,7 +473,10 @@ export function apply(ctx) {
|
|
|
424
473
|
return pb - pa
|
|
425
474
|
})
|
|
426
475
|
.slice(0, limit)
|
|
427
|
-
return [header, ...body].join('\n')
|
|
476
|
+
return envelope({ method: 'ps', limit, rows: body.length, dump: [header, ...body].join('\n') }, [
|
|
477
|
+
'ps aux 的表格原样返回,本工具只做两件事:按第 3 列(%CPU)降序、取前 limit 行',
|
|
478
|
+
'这是**一次快照**:不说明进程的历史、持续时间或趋势(要趋势得自己隔一段时间再取一次)',
|
|
479
|
+
])
|
|
428
480
|
},
|
|
429
481
|
}))
|
|
430
482
|
|
|
@@ -437,12 +489,33 @@ export function apply(ctx) {
|
|
|
437
489
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
438
490
|
},
|
|
439
491
|
async execute(_args, exec) {
|
|
492
|
+
// 证据形状(规划 §5.1):三支都产出信封。两条方法(lsof / netstat)与"什么都没读到"都要说清。
|
|
493
|
+
const envelope = (observed, limitations) => JSON.stringify({
|
|
494
|
+
tool: 'diag_net',
|
|
495
|
+
target: 'this host',
|
|
496
|
+
operation: 'snapshot TCP listening sockets (read-only, no probing)',
|
|
497
|
+
observed,
|
|
498
|
+
confidence: 'observed',
|
|
499
|
+
limitations,
|
|
500
|
+
}, null, 2)
|
|
440
501
|
const lsof = await runCmd(shell, ['lsof', '-nP', '-iTCP', '-sTCP:LISTEN'], exec)
|
|
441
|
-
if (lsof.exitCode === 0)
|
|
502
|
+
if (lsof.exitCode === 0) {
|
|
503
|
+
return envelope({ method: 'lsof', dump: lsof.stdout.slice(0, 8000), truncated: lsof.stdout.length > 8000 }, [
|
|
504
|
+
'lsof 的输出原样返回(截断到 8000 字符;truncated=true 表示后面还有没给出来的内容)',
|
|
505
|
+
'这是**一次快照**:不说明套接字的历史,也不说明谁在连它',
|
|
506
|
+
])
|
|
507
|
+
}
|
|
442
508
|
const listeners = requireOk(await runCmd(shell, ['netstat', '-an'], exec), 'netstat -an')
|
|
443
509
|
.split('\n').filter((l) => l.includes('LISTEN')).slice(0, 80)
|
|
444
|
-
if (listeners.length > 0)
|
|
445
|
-
|
|
510
|
+
if (listeners.length > 0) {
|
|
511
|
+
return envelope({ method: 'netstat', dump: listeners.join('\n'), truncated: true }, [
|
|
512
|
+
'lsof 没跑通,这份读数是 netstat -an 里含 LISTEN 的行(只取前 80 行)',
|
|
513
|
+
'netstat 的表格比 lsof 粗:没有进程名/PID,只说「有东西在这个地址上听」',
|
|
514
|
+
])
|
|
515
|
+
}
|
|
516
|
+
return envelope({ method: null, dump: '' }, [
|
|
517
|
+
'lsof 与 netstat 都没有给出 LISTEN 行 —— 这句话的前提是**两条命令都真的跑过了**;否则「没量到」会被读成「没有」',
|
|
518
|
+
])
|
|
446
519
|
},
|
|
447
520
|
}))
|
|
448
521
|
|
|
@@ -538,7 +611,7 @@ export function apply(ctx) {
|
|
|
538
611
|
: delta > 30
|
|
539
612
|
? `外网比网关多 ${delta.toFixed(1)}ms:这段差值来自光猫/路由器的排队与路径,跟 Wi-Fi 空口无关。`
|
|
540
613
|
: `外网与网关只差 ${delta.toFixed(1)}ms,路径干净。此时若仍感到卡,更可能在空口 —— 用 diag_wifi_link 看协商速率与抖动。`
|
|
541
|
-
|
|
614
|
+
const snapshot = {
|
|
542
615
|
gateway: { target: gateway, ...gw, line: pingLine(`网关 ${gateway}`, gw) },
|
|
543
616
|
wan: { target: wanTarget, ...wan, line: pingLine(`外网 ${wanTarget}`, wan) },
|
|
544
617
|
deltaMs: delta,
|
|
@@ -546,6 +619,20 @@ export function apply(ctx) {
|
|
|
546
619
|
jitterNote: gw.stddevMs !== null && gw.stddevMs > 2
|
|
547
620
|
? `网关抖动 ${gw.stddevMs}ms 偏大:空口在被抢,看 diag_wifi_channels 里有没有同频邻居。`
|
|
548
621
|
: null,
|
|
622
|
+
}
|
|
623
|
+
// 证据形状(规划 §5.1):信封 JSON —— 一次 RTT 采样(网关 + 外网)。
|
|
624
|
+
// limitations 必须说清"这几个数字各自是什么、以及它们看不见什么"。
|
|
625
|
+
return JSON.stringify({
|
|
626
|
+
tool: 'diag_net_rtt',
|
|
627
|
+
target: gateway + ' (gateway) + ' + wanTarget + ' (wan)',
|
|
628
|
+
operation: 'send ICMP echoes to both targets and report min/avg/max, loss and jitter',
|
|
629
|
+
observed: snapshot,
|
|
630
|
+
confidence: 'observed',
|
|
631
|
+
limitations: [
|
|
632
|
+
'回环 / 网关 / 外网是三个不同的东西:别把回环的 0.1ms 当成\u201c网好\u201d',
|
|
633
|
+
'ICMP 会被中间设备限速或直接丢弃 ⇒ 丢包不等于链路断(要用 TCP 层再证一次)',
|
|
634
|
+
'一次采样的 min/avg/max 不说明抖动趋势 —— 要趋势得用 diag_net_watch 那类记录仪',
|
|
635
|
+
],
|
|
549
636
|
}, null, 2)
|
|
550
637
|
},
|
|
551
638
|
}))
|
|
@@ -587,7 +674,7 @@ export function apply(ctx) {
|
|
|
587
674
|
}
|
|
588
675
|
const aggregateBps = measured.reduce((sum, sample) => sum + sample.bps, 0)
|
|
589
676
|
const totalBytes = samples.reduce((sum, sample) => sum + sample.bytes, 0)
|
|
590
|
-
|
|
677
|
+
const snapshot = {
|
|
591
678
|
url,
|
|
592
679
|
streams,
|
|
593
680
|
seconds,
|
|
@@ -597,6 +684,20 @@ export function apply(ctx) {
|
|
|
597
684
|
totalBytes,
|
|
598
685
|
totalMiB: Number((totalBytes / 1048576).toFixed(1)),
|
|
599
686
|
note: '默认按「跑满」配置(8 流 × 10 秒);totalBytes 是本次实际消耗的流量。测出来的是「无线链路 + 外网」合起来的上限,不是链路理论值。',
|
|
687
|
+
}
|
|
688
|
+
// 证据形状(规划 §5.1):信封 JSON。这个工具是七个里**唯一会产生真实流量**的,
|
|
689
|
+
// 所以 limitations 要讲清"这次花了你多少流量"与"速率读数看不见什么"。
|
|
690
|
+
return JSON.stringify({
|
|
691
|
+
tool: 'diag_net_throughput',
|
|
692
|
+
target: url,
|
|
693
|
+
operation: 'run ' + streams + ' parallel curl streams for up to ' + seconds + 's each and report throughput',
|
|
694
|
+
observed: snapshot,
|
|
695
|
+
confidence: 'observed',
|
|
696
|
+
limitations: [
|
|
697
|
+
'这是一次**真实流量**测量:observed.totalBytes 就是本次消耗的字节数(默认档位是 8 流 × 10 秒)',
|
|
698
|
+
'读的是链路**容量**:不说明时延、丢包或抖动 —— 那些用 diag_net_rtt / diag_net_watch 量',
|
|
699
|
+
'聚合速率受对端与服务端共同限制:它只证明"这条路径此刻能跑到多少",不区分是谁的限制',
|
|
700
|
+
],
|
|
600
701
|
}, null, 2)
|
|
601
702
|
},
|
|
602
703
|
}))
|
|
@@ -616,11 +717,21 @@ export function apply(ctx) {
|
|
|
616
717
|
},
|
|
617
718
|
output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
|
|
618
719
|
async execute(args, exec) {
|
|
619
|
-
|
|
720
|
+
// undefined 必须保持 undefined(否则默认目录就没了):只规范化"给了且以 - 开头"的那种。
|
|
721
|
+
const paths = watchPaths(args.dir === undefined ? undefined : safePath(args.dir))
|
|
620
722
|
const action = String(args.action ?? 'status').trim() === '' ? 'status' : String(args.action).trim()
|
|
621
723
|
const gateway = requireHost(args.gateway, '192.168.1.1')
|
|
622
724
|
const wan = requireHost(args.wan, '223.5.5.5')
|
|
623
725
|
const count = clampInteger(args.count, 8, 4, 30)
|
|
726
|
+
// 证据形状(规划 §5.1):逐支迁移 —— 本轮只把 status 支包上(没有消费方读它的裸文本)。
|
|
727
|
+
const envelope = (observed, limitations) => JSON.stringify({
|
|
728
|
+
tool: 'diag_net_watch',
|
|
729
|
+
target: paths.dir,
|
|
730
|
+
operation: 'long-running link/RTT recorder: ' + action,
|
|
731
|
+
observed,
|
|
732
|
+
confidence: 'observed',
|
|
733
|
+
limitations,
|
|
734
|
+
}, null, 2)
|
|
624
735
|
|
|
625
736
|
if (action === 'start') {
|
|
626
737
|
const interval = clampInteger(args.interval, 10, 3, 600)
|
|
@@ -635,7 +746,7 @@ export function apply(ctx) {
|
|
|
635
746
|
timeoutMs: 7 * 24 * 3600 * 1000,
|
|
636
747
|
signal: exec.signal,
|
|
637
748
|
}))
|
|
638
|
-
return [
|
|
749
|
+
return envelope({ method: 'start', log: paths.log, interval, linkEvery, count, gateway, wan, dump: [
|
|
639
750
|
`记录仪已启动`,
|
|
640
751
|
`日志:${paths.log}`,
|
|
641
752
|
`间隔 ${interval}s;每 ${linkEvery} 次采样取一次链路;每次每目标 ${count} 个包`,
|
|
@@ -644,7 +755,10 @@ export function apply(ctx) {
|
|
|
644
755
|
`卡住时立刻打标记:diag_net_watch action=mark message="电视卡了"`,
|
|
645
756
|
`停止:diag_net_watch action=stop`,
|
|
646
757
|
`回看:diag_net_watch action=report`,
|
|
647
|
-
].join('\n')
|
|
758
|
+
].join('\n') }, [
|
|
759
|
+
'这是**后台**记录仪:本工具返回的时候采样循环才刚开始 ⇒ 日志会一直长,随时用 action=report 回看',
|
|
760
|
+
'observed 里带着它的节奏(间隔 / 每 N 次取链路 / 每目标包数)—— 以后看报告时能对上它是按什么节奏记的',
|
|
761
|
+
])
|
|
648
762
|
}
|
|
649
763
|
|
|
650
764
|
if (action === 'stop') {
|
|
@@ -655,7 +769,10 @@ export function apply(ctx) {
|
|
|
655
769
|
{ timeoutMs: 20000 },
|
|
656
770
|
)
|
|
657
771
|
requireOk(run, '写停止文件')
|
|
658
|
-
return `停止信号已写入 ${paths.stop}\n采样循环会在当前这一轮 sleep 结束后退出,最多等一个采样间隔。用 action=status 确认。`
|
|
772
|
+
return envelope({ method: 'stop', stopFile: paths.stop, dump: `停止信号已写入 ${paths.stop}\n采样循环会在当前这一轮 sleep 结束后退出,最多等一个采样间隔。用 action=status 确认。` }, [
|
|
773
|
+
'这是**异步**停止:写完信号文件就返回了,采样循环要等当前这一轮 sleep 结束才退出',
|
|
774
|
+
'要确认它真的停了,用 action=status 看最后写入时间,或再 action=report 比样本数',
|
|
775
|
+
])
|
|
659
776
|
}
|
|
660
777
|
|
|
661
778
|
if (action === 'sample') {
|
|
@@ -666,7 +783,10 @@ export function apply(ctx) {
|
|
|
666
783
|
{ timeoutMs: 30000 },
|
|
667
784
|
)
|
|
668
785
|
requireOk(run, '采样')
|
|
669
|
-
return `已采样并追加到 ${paths.log}\n用 action=report 看,或 action=mark 标一下刚才发生了什么。`
|
|
786
|
+
return envelope({ method: 'sample', log: paths.log, dump: `已采样并追加到 ${paths.log}\n用 action=report 看,或 action=mark 标一下刚才发生了什么。` }, [
|
|
787
|
+
'这一次采样写进了日志;它的**内容**要看 action=report(这里只报"写成功了")',
|
|
788
|
+
'样本是往日志尾部**追加**的:这个动作不会覆盖历史,也不会改变记录仪是否在跑',
|
|
789
|
+
])
|
|
670
790
|
}
|
|
671
791
|
|
|
672
792
|
if (action === 'mark') {
|
|
@@ -683,7 +803,10 @@ export function apply(ctx) {
|
|
|
683
803
|
{ timeoutMs: 20000 },
|
|
684
804
|
)
|
|
685
805
|
requireOk(run, '写标记')
|
|
686
|
-
return `已标记「${message}」@ ${stampOf(Math.floor(Date.now() / 1000))}\n日志:${paths.log}`
|
|
806
|
+
return envelope({ method: 'mark', log: paths.log, dump: `已标记「${message}」@ ${stampOf(Math.floor(Date.now() / 1000))}\n日志:${paths.log}` }, [
|
|
807
|
+
'标记是你打的**锚点**,它本身不是测量 —— report 会把它对齐到最近的样本(误差 ≤ 一个采样间隔)',
|
|
808
|
+
'标记只往日志里追加一行,不改变采样节奏,也不影响记录仪是否在跑',
|
|
809
|
+
])
|
|
687
810
|
}
|
|
688
811
|
|
|
689
812
|
if (action === 'report') {
|
|
@@ -695,13 +818,27 @@ export function apply(ctx) {
|
|
|
695
818
|
{ timeoutMs: 30000, stdoutMaxBytes: 8388608 },
|
|
696
819
|
)
|
|
697
820
|
if (run.stdout.trim() === '') {
|
|
698
|
-
return
|
|
821
|
+
return envelope({ method: 'report', samples: 0, dump: '', log: paths.log }, [
|
|
822
|
+
'日志不存在或为空 ⇒ 这是「还没有样本」,不是「网络没问题」',
|
|
823
|
+
`先 action=start 起记录仪,或 action=sample 取一个(看的是 ${paths.log})`,
|
|
824
|
+
])
|
|
699
825
|
}
|
|
700
826
|
const { rows, marks, skipped } = readWatch(run.stdout)
|
|
701
827
|
if (rows.length === 0) {
|
|
702
|
-
return
|
|
703
|
-
|
|
704
|
-
:
|
|
828
|
+
return envelope({
|
|
829
|
+
method: 'report',
|
|
830
|
+
samples: 0,
|
|
831
|
+
skipped,
|
|
832
|
+
log: paths.log,
|
|
833
|
+
dump: skipped > 0
|
|
834
|
+
? `日志里有 ${skipped} 个样本还没写完(记录仪正在写),暂时没有完整的样本可看。稍后再试。`
|
|
835
|
+
: `日志里没解析出完整样本(${paths.log}):文件可能刚被截断。`,
|
|
836
|
+
}, [
|
|
837
|
+
'一个完整样本都没有 ⇒ 这份报告里**没有任何测量**,不是「这一段时间网络正常」',
|
|
838
|
+
skipped > 0
|
|
839
|
+
? '只有半截样本 —— 读到一半的记录会得出凭空的故障结论,所以它不算证据'
|
|
840
|
+
: '文件可能刚被截断,也可能记录仪根本没在跑 —— 这两种要分开查',
|
|
841
|
+
])
|
|
705
842
|
}
|
|
706
843
|
const first = rows[0]
|
|
707
844
|
const last = rows[rows.length - 1]
|
|
@@ -756,7 +893,13 @@ export function apply(ctx) {
|
|
|
756
893
|
+ ` 外网 ${nearest.wan.avgMs ?? '无回应'}ms(抖动 ${nearest.wan.stddevMs ?? '—'}ms)`
|
|
757
894
|
+ ` 链路 ${nearest.link.channel ?? '—'}/${nearest.link.widthMhz ?? '—'}MHz 协商 ${nearest.link.transmitRateMbps ?? '—'}Mbps`)
|
|
758
895
|
}
|
|
759
|
-
|
|
896
|
+
// 报告文本原样进 observed.dump(消费方都是子串正则,不需要改读法)。
|
|
897
|
+
return envelope({ method: 'report', samples: rows.length, skipped, dump: lines.join('\n') }, [
|
|
898
|
+
'报告覆盖的是**日志里记着的那段时间** —— 记录仪没起着的时段,它什么都不知道',
|
|
899
|
+
skipped > 0
|
|
900
|
+
? '有未写完的样本被跳过(数量见 observed.skipped):那些时刻只有半个样本,不能当证据'
|
|
901
|
+
: '这次没有跳过未写完的样本;但"没跳过"只说明这次读的时候没有半截样本',
|
|
902
|
+
])
|
|
760
903
|
}
|
|
761
904
|
|
|
762
905
|
// status(默认):永远给出可读结论,绝不返回空字符串。
|
|
@@ -784,7 +927,13 @@ export function apply(ctx) {
|
|
|
784
927
|
' echo "还没有日志文件"',
|
|
785
928
|
'fi',
|
|
786
929
|
].join('\n')], exec, { timeoutMs: 20000, stdoutMaxBytes: 262144 })
|
|
787
|
-
|
|
930
|
+
const statusText = run.stdout.trim()
|
|
931
|
+
// 「未知」与「没在跑」是两件事:状态命令没输出时不许说成没在跑。
|
|
932
|
+
return envelope({ method: 'status', dump: statusText, known: statusText !== '' }, [
|
|
933
|
+
statusText === ''
|
|
934
|
+
? '状态命令没有输出 ⇒ 这是「未知」,不是「没在跑」'
|
|
935
|
+
: '这是记录仪自己报的状态,不是从日志推断的;它只说这一刻,不说明采样准不准',
|
|
936
|
+
])
|
|
788
937
|
},
|
|
789
938
|
}))
|
|
790
939
|
|
|
@@ -18,7 +18,28 @@ function shq(value) {
|
|
|
18
18
|
* `TypeError: shell.run is not a function` on every call, in every engine
|
|
19
19
|
* version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
|
|
20
20
|
* names a seam method the executor does not have.
|
|
21
|
+
*
|
|
22
|
+
* **失败面的口径是"大声"**(Q5):
|
|
23
|
+
* · 没有 `checksec`(macOS 就是)⇒ 走**仓库内置的原生解析器**算 PIE/NX/RELRO/canary,并在
|
|
24
|
+
* `limitations` 里明说"这是内置解析器算的";**绝不**再出现"缺工具 ⇒ 静默回退 ⇒ 对所有文件
|
|
25
|
+
* 都报 pie=no"那种事故(2026-10-01 的原始缺陷正是它);
|
|
26
|
+
* · `pwn_cyclic` 的长度会被夹到 1..4096 —— 夹取是**读数**(`observed.clamped`),不是沉默;
|
|
27
|
+
* · `pwn_rop_hint` **不看文件**:它的清单用 `confidence: 'inferred'` 标出来,并在 `limitations`
|
|
28
|
+
* 里写清"前提必须先由 pwn_checksec / rev_* / bin_* 验过"。
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* 路径参数规范化:以 `-` 开头的路径加 `./` 前缀(2026-10-13)。
|
|
32
|
+
*
|
|
33
|
+
* 为什么不插 `--`:实测本机(macOS,发布目标平台)**objdump(-h/-d/-T)/ base64 -i / otool -hv 都不支持
|
|
34
|
+
* `--`**,插进去直接失败;而 `file -b --version` 会打印 `file-5.41`、`nm -u --version` 会打印
|
|
35
|
+
* `llvm-nm, …`,两者都 **exit 0** ⇒ 工具会把版本串当**数据**返回。加 `./` 之后它就是一个普通相对路径:
|
|
36
|
+
* 不存在就正常报错,存在就是那个文件。
|
|
21
37
|
*/
|
|
38
|
+
function safePath(value) {
|
|
39
|
+
const text = String(value ?? '')
|
|
40
|
+
return text.startsWith('-') ? './' + text : text
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
async function runCmd(shell, argv, exec, opts = {}) {
|
|
23
44
|
const result = await (await shell.execute(shell.resolve({
|
|
24
45
|
command: argv.map(shq).join(' '),
|
|
@@ -50,9 +71,19 @@ async function runCmd(shell, argv, exec, opts = {}) {
|
|
|
50
71
|
*/
|
|
51
72
|
function requireOk(run, what) {
|
|
52
73
|
if (run.exitCode === 0) return run.stdout
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
74
|
+
// 沙箱拒绝的形状是**结果正文里的标记**(`[sandbox: file access denied under <mode> mode]`,
|
|
75
|
+
// 抄自 @deepseek-ai/dsh-sandbox/index.js:65)—— 它落在 stdout 上;只读 stderr 就会把它丢掉,
|
|
76
|
+
// 于是模型读到的是「命令失败」,而不是「你没有被授权」。所以标记必须原样带进错误文案。
|
|
77
|
+
const sandboxMark = /^\[sandbox: .*\]$/mu.exec(run.stdout)
|
|
78
|
+
const detail = [sandboxMark?.[0], ...run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean)].slice(0, 3).join(' | ')
|
|
79
|
+
const notInstalled = run.exitCode === 127 || /command not found/iu.test(run.stderr)
|
|
80
|
+
const missing = notInstalled ? ' — the command is not installed on this host' : ''
|
|
81
|
+
// 失败文案要对**读它的模型**可行动(Q6):不是裸 stderr,但也不许臆断原因 ——
|
|
82
|
+
// 只给「这条 exit 属于哪一类」与「下一步可以试什么」,具体判断仍留给读的人。
|
|
83
|
+
const next = notInstalled
|
|
84
|
+
? 'next: this host lacks that command — look for a built-in fallback in this package, or read the file another way'
|
|
85
|
+
: 'next: check the path, and that the file is what this tool expects (bin_file shows what it is); the line above is the command own reason'
|
|
86
|
+
const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}\n${next}`}`)
|
|
56
87
|
error.name = 'CommandError'
|
|
57
88
|
throw error
|
|
58
89
|
}
|
|
@@ -534,9 +565,35 @@ export function apply(ctx) {
|
|
|
534
565
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
535
566
|
},
|
|
536
567
|
async execute(args, exec) {
|
|
537
|
-
const
|
|
538
|
-
|
|
539
|
-
|
|
568
|
+
const path = safePath(args.path)
|
|
569
|
+
const ck = await runCmd(shell, ['checksec', '--file=' + path], exec)
|
|
570
|
+
if (ck.exitCode === 0) {
|
|
571
|
+
return JSON.stringify({
|
|
572
|
+
tool: 'pwn_checksec',
|
|
573
|
+
target: args.path,
|
|
574
|
+
operation: 'read mitigations with the installed checksec',
|
|
575
|
+
observed: { method: 'checksec', dump: ck.stdout },
|
|
576
|
+
confidence: 'observed',
|
|
577
|
+
limitations: [
|
|
578
|
+
'checksec 的输出原样返回(本工具不改写它)',
|
|
579
|
+
'checksec 的判断基于它自己支持的格式/段:**没提到的项不代表不存在**(要逐条核就用 bin_readelf)',
|
|
580
|
+
],
|
|
581
|
+
}, null, 2)
|
|
582
|
+
}
|
|
583
|
+
// 本机没有 checksec(macOS 就是):这份读数来自仓库内置的原生解析器。
|
|
584
|
+
const nativeLines = (await mitigationLines(shell, path, exec)).join('\n')
|
|
585
|
+
return JSON.stringify({
|
|
586
|
+
tool: 'pwn_checksec',
|
|
587
|
+
target: args.path,
|
|
588
|
+
operation: 'compute mitigations from the file itself (no checksec on this host)',
|
|
589
|
+
observed: { method: 'native', dump: nativeLines },
|
|
590
|
+
confidence: 'observed',
|
|
591
|
+
limitations: [
|
|
592
|
+
'**本机没有 checksec** ⇒ 这份读数是**仓库内置解析器**算的,不是 checksec 的输出',
|
|
593
|
+
'内置解析器只覆盖本工具声明的字段(arch/pie/nx/relro/canary);checksec 的其它判断(如 fortify)它不做',
|
|
594
|
+
'它对 Mach-O 与 ELF 各有一套路子:看 method 与 dump 里的措辞,别把 macho 的 relro=n/a 读成"没开"',
|
|
595
|
+
],
|
|
596
|
+
}, null, 2)
|
|
540
597
|
},
|
|
541
598
|
}))
|
|
542
599
|
|
|
@@ -552,7 +609,19 @@ export function apply(ctx) {
|
|
|
552
609
|
},
|
|
553
610
|
async execute(args) {
|
|
554
611
|
const n = Math.max(1, Math.min(4096, Number(args.length) || 64))
|
|
555
|
-
|
|
612
|
+
// 证据形状(规划 §5.1):信封 JSON(模式本身进 observed,不塞进 dump 的文本里)。
|
|
613
|
+
return JSON.stringify({
|
|
614
|
+
tool: 'pwn_cyclic',
|
|
615
|
+
target: 'de Bruijn pattern',
|
|
616
|
+
operation: 'generate a cyclic pattern for offset discovery (pwntools-compatible alphabet)',
|
|
617
|
+
observed: { length: n, clamped: n !== (Number(args.length) || 64), dump: cyclic(n) },
|
|
618
|
+
confidence: 'observed',
|
|
619
|
+
limitations: [
|
|
620
|
+
'模式只帮你**定位**偏移:它不告诉你偏移在哪,也不说明那条路径可达(配合真实崩溃日志/调试器才有意义)',
|
|
621
|
+
'长度会被夹到 1..4096:传大了会**静默变短** ⇒ 用 observed.length(或 clamped=true)核对实际给的位数',
|
|
622
|
+
'字母表与 pwntools 的 cyclic 一致,但**只保证前缀相同**:拿别处的 offset 表来查时,先确认两边是同一套字母表',
|
|
623
|
+
],
|
|
624
|
+
}, null, 2)
|
|
556
625
|
},
|
|
557
626
|
}))
|
|
558
627
|
|
|
@@ -568,7 +637,12 @@ export function apply(ctx) {
|
|
|
568
637
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
569
638
|
},
|
|
570
639
|
async execute(args) {
|
|
571
|
-
|
|
640
|
+
// 证据形状(规划 §5.1):清单型工具 —— 清单本身就是它给出的东西(进 dump)。
|
|
641
|
+
return JSON.stringify({
|
|
642
|
+
tool: 'pwn_rop_hint',
|
|
643
|
+
target: args.binary,
|
|
644
|
+
operation: 'produce a ret2libc/ROP planning checklist (does not read the binary at all)',
|
|
645
|
+
observed: { binary: args.binary, goal: args.goal ?? 'shell', dump: [
|
|
572
646
|
`# ROP plan for ${args.binary}`,
|
|
573
647
|
`goal: ${args.goal ?? 'shell'}`,
|
|
574
648
|
'1. confirm overflow offset via cyclic + crash RIP',
|
|
@@ -577,7 +651,15 @@ export function apply(ctx) {
|
|
|
577
651
|
'4. second payload: pop rdi; ret → binsh → system (align stack with extra ret)',
|
|
578
652
|
'5. prefer local lab targets only; never against unauthorized hosts',
|
|
579
653
|
'note: this tool is a planning checklist only — it does not inspect the binary; pair it with pwn_checksec, rev_imports and rev_gadget_scan',
|
|
580
|
-
|
|
654
|
+
].join('\n') },
|
|
655
|
+
confidence: 'inferred',
|
|
656
|
+
limitations: [
|
|
657
|
+
'**这是清单,不是结论**:本工具**根本不看文件** —— 不读 ELF、不找 gadget、不算偏移',
|
|
658
|
+
'所以它的前提(PIE/NX/canary/RELRO、溢出偏移、libc 版本)必须先由 pwn_checksec / rev_* / bin_* 验过;没验过就不能按它动手',
|
|
659
|
+
'步骤顺序是**一种**常见打法(ret2libc),不是唯一解:目标有 seccomp、特殊 libc 或栈保护时整条路可能不成立',
|
|
660
|
+
'只用在与授权范围一致的目标上(本地 lab 优先)',
|
|
661
|
+
],
|
|
662
|
+
}, null, 2)
|
|
581
663
|
},
|
|
582
664
|
}))
|
|
583
665
|
}
|
|
@@ -12,6 +12,17 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
12
12
|
* Command execution goes through the host `shell` seam so the sandbox confines
|
|
13
13
|
* it (and the FLY_ALLOW_RECON gate is an ADDITIONAL authorization layer on top
|
|
14
14
|
* of the sandbox, not a replacement for it).
|
|
15
|
+
*
|
|
16
|
+
* **失败面的口径是"大声"**(Q5):
|
|
17
|
+
* · 没有 `nmap`(macOS 常态)⇒ 用系统自带的 `nc` 扫,并在结果里标明"这是 nc 扫的",且**明说
|
|
18
|
+
* `no-response` 分不清 closed 与 filtered**(要区分必须换 nmap);端口数超过内置回退的 64 上限时
|
|
19
|
+
* **一个都不扫**并说清上限与出路,绝不扫一半交差;
|
|
20
|
+
* · 目标 URL **先校验再用**(`requireHttpUrl`:只允许 http/https)—— 2026-10-13 补:此前这条只有
|
|
21
|
+
* `diag_net_throughput` 有,`recon_http_fingerprint` 会放行 `file://`,而 curl 能把本地文件读出来
|
|
22
|
+
* 回显进工具结果;Q5 那次我在本注释里先写了这条保护,**当时它并不成立**,现在它成立了;
|
|
23
|
+
* · `curl` 失败 ⇒ `requireOk` 抛 `CommandError`,不给"空指纹";
|
|
24
|
+
* · `recon_http_fingerprint` 只读响应头与 `<title>`:**不执行 JS** —— 由 JS 搭出来的界面在这把尺子里
|
|
25
|
+
* 等于不存在(AGENTS.md 里那台摄像头的负结论就是这么来的),这一点写进了它的 `limitations`。
|
|
15
26
|
*/
|
|
16
27
|
export const name = 'fly-recon'
|
|
17
28
|
export const inject = ['tools', 'shell']
|
|
@@ -58,11 +69,60 @@ async function runCmd(shell, argv, exec, opts = {}) {
|
|
|
58
69
|
* tools must say so instead of reporting an empty scan, because an empty scan
|
|
59
70
|
* reads as "target closed".
|
|
60
71
|
*/
|
|
72
|
+
/**
|
|
73
|
+
* 只接受主机名或 IP 字面量。2026-10-13 补:此前 `recon_ports` 的 `host` **没有**这道校验,
|
|
74
|
+
* 而 diag_* 三个工具都有 —— 于是 `-oN=/tmp/x` 这种"以 - 开头的目标"会被 nmap 当成**选项**
|
|
75
|
+
* (写出文件)。同族性质必须在**每个**工具上都有。
|
|
76
|
+
*/
|
|
77
|
+
function requireHost(value) {
|
|
78
|
+
const host = String(value ?? '').trim()
|
|
79
|
+
if (!/^[A-Za-z0-9](?:[A-Za-z0-9._:-]*[A-Za-z0-9])?$/u.test(host)) {
|
|
80
|
+
const error = new Error(
|
|
81
|
+
`拒绝目标 ${JSON.stringify(host)}:只接受主机名或 IP 字面量 ——`
|
|
82
|
+
+ ' 不许有空格或 shell 元字符(; | & $ ` 引号 括号),也不许以 "-" 开头'
|
|
83
|
+
+ '(会被当成命令参数:nmap、nc 会把 `-oN=file` 之类的选项当真)。',
|
|
84
|
+
)
|
|
85
|
+
error.name = 'ArgumentError'
|
|
86
|
+
throw error
|
|
87
|
+
}
|
|
88
|
+
return host
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 只允许 http/https:`file:` 会让 curl 把本地文件读出来回显进工具结果(2026-10-13 发现并补上)。 */
|
|
92
|
+
const ALLOWED_URL_SCHEMES = ['http:', 'https:']
|
|
93
|
+
function requireHttpUrl(value) {
|
|
94
|
+
const text = String(value ?? '').trim()
|
|
95
|
+
let parsed
|
|
96
|
+
try {
|
|
97
|
+
parsed = new URL(text)
|
|
98
|
+
} catch {
|
|
99
|
+
const error = new Error(`不是合法的 URL:${text}`)
|
|
100
|
+
error.name = 'ArgumentError'
|
|
101
|
+
throw error
|
|
102
|
+
}
|
|
103
|
+
if (!ALLOWED_URL_SCHEMES.includes(parsed.protocol)) {
|
|
104
|
+
const error = new Error(`拒绝 ${parsed.protocol} 目标:只允许 http/https(file: 会读本地文件)`)
|
|
105
|
+
error.name = 'ArgumentError'
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
return parsed.href
|
|
109
|
+
}
|
|
110
|
+
|
|
61
111
|
function requireOk(run, what) {
|
|
62
112
|
if (run.exitCode === 0) return run.stdout
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
113
|
+
// 沙箱拒绝的形状是**结果正文里的标记**(`[sandbox: file access denied under <mode> mode]`,
|
|
114
|
+
// 抄自 @deepseek-ai/dsh-sandbox/index.js:65)—— 它落在 stdout 上;只读 stderr 就会把它丢掉,
|
|
115
|
+
// 于是模型读到的是「命令失败」,而不是「你没有被授权」。所以标记必须原样带进错误文案。
|
|
116
|
+
const sandboxMark = /^\[sandbox: .*\]$/mu.exec(run.stdout)
|
|
117
|
+
const detail = [sandboxMark?.[0], ...run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean)].slice(0, 3).join(' | ')
|
|
118
|
+
const notInstalled = run.exitCode === 127 || /command not found/iu.test(run.stderr)
|
|
119
|
+
const missing = notInstalled ? ' — the command is not installed on this host' : ''
|
|
120
|
+
// 失败文案要对**读它的模型**可行动(Q6):不是裸 stderr,但也不许臆断原因 ——
|
|
121
|
+
// 只给「这条 exit 属于哪一类」与「下一步可以试什么」,具体判断仍留给读的人。
|
|
122
|
+
const next = notInstalled
|
|
123
|
+
? 'next: this host lacks that command — look for a built-in fallback in this package, or read the file another way'
|
|
124
|
+
: 'next: check the path, and that the file is what this tool expects (bin_file shows what it is); the line above is the command own reason'
|
|
125
|
+
const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}\n${next}`}`)
|
|
66
126
|
error.name = 'CommandError'
|
|
67
127
|
throw error
|
|
68
128
|
}
|
|
@@ -128,38 +188,59 @@ export function apply(ctx) {
|
|
|
128
188
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
129
189
|
},
|
|
130
190
|
async execute(args, exec) {
|
|
191
|
+
// 先校验再构造命令(同族性质:diag_* 三个工具早就有这道门)。
|
|
192
|
+
const host = requireHost(args.host)
|
|
193
|
+
// 证据形状(规划 §5.1):逐分支迁移 —— 本轮只把 nc 回退分支包上。
|
|
194
|
+
const envelope = (observed, limitations) => JSON.stringify({
|
|
195
|
+
tool: 'recon_ports',
|
|
196
|
+
target: args.host,
|
|
197
|
+
operation: 'TCP connect scan of the requested ports (authorized lab targets only)',
|
|
198
|
+
observed,
|
|
199
|
+
confidence: 'observed',
|
|
200
|
+
limitations,
|
|
201
|
+
}, null, 2)
|
|
131
202
|
const requested = args.ports === undefined || String(args.ports).trim() === '' ? '22,80,443,445,3389,8080' : String(args.ports)
|
|
132
203
|
const expanded = expandPorts(requested)
|
|
133
204
|
if (expanded === null) {
|
|
134
|
-
return `unparsable port spec ${JSON.stringify(requested)} — use a comma list with optional ranges, e.g. 22,80,8000-8010`
|
|
205
|
+
return envelope({ requested, dump: `unparsable port spec ${JSON.stringify(requested)} — use a comma list with optional ranges, e.g. 22,80,8000-8010` }, ['端口规格不合法 ⇒ 一个端口都没扫:observed.dump 里是解析失败的原因与可照抄的写法'])
|
|
206
|
+
}
|
|
207
|
+
const nmap = await runCmd(shell, ['nmap', '-Pn', '--open', '-p', expanded.join(','), host], exec, { timeoutMs: 60_000 })
|
|
208
|
+
if (nmap.exitCode === 0) {
|
|
209
|
+
return envelope({ method: 'nmap', dump: nmap.stdout }, [
|
|
210
|
+
'nmap 的表格原样返回,本工具不改写它;本轮参数只带 -Pn --open(没有版本探测)',
|
|
211
|
+
'open 只说明 TCP 连得上,不说明服务身份或版本',
|
|
212
|
+
])
|
|
135
213
|
}
|
|
136
|
-
const nmap = await runCmd(shell, ['nmap', '-Pn', '--open', '-p', expanded.join(','), args.host], exec, { timeoutMs: 60_000 })
|
|
137
|
-
if (nmap.exitCode === 0) return nmap.stdout
|
|
138
214
|
if (!portMissing(nmap)) return requireOk(nmap, 'nmap')
|
|
139
215
|
// `nmap` is not installed by default on macOS. Instead of failing outright
|
|
140
216
|
// (the previous behaviour) or returning an empty scan that reads as "target
|
|
141
217
|
// closed", scan with `nc`, which ships with the base system. The result is
|
|
142
218
|
// labelled: nc cannot tell closed from filtered, and does no fingerprinting.
|
|
143
219
|
if (expanded.length > 64) {
|
|
144
|
-
return [
|
|
220
|
+
return envelope({ method: 'nc', scanned: 0, cap: 64, requested: expanded.length, dump: [
|
|
145
221
|
`nmap is not installed on this host: the built-in fallback scans with the system nc and is capped at 64 ports per call, but ${expanded.length} were requested`,
|
|
146
222
|
'narrow the range, or install nmap for a full scan (no 64-port cap, service and version detection)',
|
|
147
|
-
].join('\n')
|
|
223
|
+
].join('\n') }, [
|
|
224
|
+
'请求超过内置回退的 64 端口上限 ⇒ **一个端口都没扫**(不是"扫了、没有开着的端口")',
|
|
225
|
+
])
|
|
148
226
|
}
|
|
149
227
|
const script = expanded
|
|
150
|
-
.map((port) => `if nc -z -G 1 -w 1 ${shq(
|
|
228
|
+
.map((port) => `if nc -z -G 1 -w 1 ${shq(host)} ${port} >/dev/null 2>&1; then echo '${port}/tcp open'; else echo '${port}/tcp no-response'; fi`)
|
|
151
229
|
.join('; ')
|
|
152
230
|
const scan = await runCmd(shell, ['/bin/sh', '-c', script], exec, { timeoutMs: 120_000 })
|
|
153
231
|
if (scan.exitCode !== 0 && scan.stdout.trim() === '') return requireOk(scan, 'nc -z')
|
|
154
232
|
const rows = scan.stdout.split('\n').map((l) => l.trim()).filter(Boolean)
|
|
155
|
-
return [
|
|
233
|
+
return envelope({ method: 'nc', scanned: expanded.length, dump: [
|
|
156
234
|
'nmap=not installed (scanned with the system nc: one TCP connect per port, no service or version detection)',
|
|
157
|
-
`host=${
|
|
235
|
+
`host=${host}`,
|
|
158
236
|
`ports=${expanded.length}`,
|
|
159
237
|
...rows,
|
|
160
238
|
`open=${rows.filter((l) => l.endsWith('/tcp open')).length}`,
|
|
161
239
|
'note: `no-response` means closed or filtered — nc cannot tell those two apart; install nmap for a real scan',
|
|
162
|
-
].join('\n')
|
|
240
|
+
].join('\n') }, [
|
|
241
|
+
'nmap 不在本机:这是系统 nc 的回退结果(每端口一次 TCP connect,无服务/版本探测)',
|
|
242
|
+
'**nc 分不清 closed 与 filtered**:no-response 两种都可能 —— 要区分必须换 nmap',
|
|
243
|
+
])
|
|
163
244
|
},
|
|
164
245
|
}))
|
|
165
246
|
|
|
@@ -174,11 +255,31 @@ export function apply(ctx) {
|
|
|
174
255
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
175
256
|
},
|
|
176
257
|
async execute(args, exec) {
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
258
|
+
// 证据形状(规划 §5.1):两支都产出信封。
|
|
259
|
+
const envelope = (observed, limitations) => JSON.stringify({
|
|
260
|
+
tool: 'recon_http_fingerprint',
|
|
261
|
+
target: args.url,
|
|
262
|
+
operation: 'one-shot HTTP fingerprint: response headers + <title> only',
|
|
263
|
+
observed,
|
|
264
|
+
confidence: 'observed',
|
|
265
|
+
limitations,
|
|
266
|
+
}, null, 2)
|
|
267
|
+
// 先校验再构造命令:non-http 目标在**命令构造之前**就被拒(不是事后清洗)。
|
|
268
|
+
const url = requireHttpUrl(args.url)
|
|
269
|
+
const headers = requireOk(await runCmd(shell, ['curl', '-sI', '-m', '8', url], exec), `curl -sI ${url}`)
|
|
270
|
+
const body = await runCmd(shell, ['curl', '-s', '-m', '8', url], exec)
|
|
271
|
+
if (body.exitCode !== 0) {
|
|
272
|
+
return envelope({ headers: headers.trim(), title: null, bodyFetched: false, dump: `${headers.trim()}\nTITLE: unknown (body fetch failed, exit ${body.exitCode})` }, [
|
|
273
|
+
'body 取不到 ⇒ 只有响应头;title 是**未知**,不是「这个页面没有标题」',
|
|
274
|
+
'curl -sI 不跟随重定向:看到的是**跳转前那一跳**的响应头',
|
|
275
|
+
])
|
|
276
|
+
}
|
|
180
277
|
const title = /<title[^>]*>([^<]*)<\/title>/iu.exec(body.stdout)?.[1]?.trim() ?? ''
|
|
181
|
-
return `${headers.trim()}\nTITLE: ${title === '' ? '(no <title> element in the body)' : title}`
|
|
278
|
+
return envelope({ headers: headers.trim(), title, bodyFetched: true, dump: `${headers.trim()}\nTITLE: ${title === '' ? '(no <title> element in the body)' : title}` }, [
|
|
279
|
+
'只读响应头与第一个 <title>:**不执行 JS** —— 由 JS 搭出来的界面(登录框、动态面板、JS 表格)在这把尺子里等于不存在',
|
|
280
|
+
'要判 JS 渲染出来的东西,得用真浏览器(仓库有 playwright-core),并断「页面实际请求了什么、状态码是什么」',
|
|
281
|
+
'标题取的是第一个 <title>;SPA 或没有 title 的页面会给 (no <title> element in the body)',
|
|
282
|
+
])
|
|
182
283
|
},
|
|
183
284
|
}))
|
|
184
285
|
}
|
|
@@ -18,7 +18,28 @@ function shq(value) {
|
|
|
18
18
|
* `TypeError: shell.run is not a function` on every call, in every engine
|
|
19
19
|
* version. `scripts/verify-security-tools.mjs` now fails loudly if a call site
|
|
20
20
|
* names a seam method the executor does not have.
|
|
21
|
+
*
|
|
22
|
+
* **失败面的口径是"大声"**(Q5):
|
|
23
|
+
* · `objdump -T` 读不出动态符号表(Mach-O 常态)⇒ 换 `nm -u` 这条**回退**,并把"这是回退路径、
|
|
24
|
+
* nm 只报未定义的名字、不说是从哪个库来的"写进结果的 `limitations`;
|
|
25
|
+
* · **两把尺子都没有** ⇒ 明说"没有可读的未定义符号",并附上两条命令的 exit 与 stderr,
|
|
26
|
+
* 绝不说成"这个程序不依赖任何外部符号"(静态链接/已 strip 正是这个形状);
|
|
27
|
+
* · `ROPgadget` 不在 ⇒ 用内置扫描器(只认 pop/leave + ret 的简单序列),并在结果里写明
|
|
28
|
+
* "**没找到 ≠ 没有**";`objdump` 缺失时 `requireOk` 抛错,不给空反汇编。
|
|
21
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* 路径参数规范化:以 `-` 开头的路径加 `./` 前缀(2026-10-13)。
|
|
32
|
+
*
|
|
33
|
+
* 为什么不插 `--`:实测本机(macOS,发布目标平台)**objdump(-h/-d/-T)/ base64 -i / otool -hv 都不支持
|
|
34
|
+
* `--`**,插进去直接失败;而 `file -b --version` 会打印 `file-5.41`、`nm -u --version` 会打印
|
|
35
|
+
* `llvm-nm, …`,两者都 **exit 0** ⇒ 工具会把版本串当**数据**返回(`bin_sections { path: '--version' }`
|
|
36
|
+
* 真发生过)。加 `./` 之后它就是一个普通相对路径:不存在就正常报错,存在就是那个文件。
|
|
37
|
+
*/
|
|
38
|
+
function safePath(value) {
|
|
39
|
+
const text = String(value ?? '')
|
|
40
|
+
return text.startsWith('-') ? './' + text : text
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
async function runCmd(shell, argv, exec, opts = {}) {
|
|
23
44
|
const result = await (await shell.execute(shell.resolve({
|
|
24
45
|
command: argv.map(shq).join(' '),
|
|
@@ -49,9 +70,19 @@ async function runCmd(shell, argv, exec, opts = {}) {
|
|
|
49
70
|
*/
|
|
50
71
|
function requireOk(run, what) {
|
|
51
72
|
if (run.exitCode === 0) return run.stdout
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
73
|
+
// 沙箱拒绝的形状是**结果正文里的标记**(`[sandbox: file access denied under <mode> mode]`,
|
|
74
|
+
// 抄自 @deepseek-ai/dsh-sandbox/index.js:65)—— 它落在 stdout 上;只读 stderr 就会把它丢掉,
|
|
75
|
+
// 于是模型读到的是「命令失败」,而不是「你没有被授权」。所以标记必须原样带进错误文案。
|
|
76
|
+
const sandboxMark = /^\[sandbox: .*\]$/mu.exec(run.stdout)
|
|
77
|
+
const detail = [sandboxMark?.[0], ...run.stderr.trim().split('\n').map(line => line.trim()).filter(Boolean)].slice(0, 3).join(' | ')
|
|
78
|
+
const notInstalled = run.exitCode === 127 || /command not found/iu.test(run.stderr)
|
|
79
|
+
const missing = notInstalled ? ' — the command is not installed on this host' : ''
|
|
80
|
+
// 失败文案要对**读它的模型**可行动(Q6):不是裸 stderr,但也不许臆断原因 ——
|
|
81
|
+
// 只给「这条 exit 属于哪一类」与「下一步可以试什么」,具体判断仍留给读的人。
|
|
82
|
+
const next = notInstalled
|
|
83
|
+
? 'next: this host lacks that command — look for a built-in fallback in this package, or read the file another way'
|
|
84
|
+
: 'next: check the path, and that the file is what this tool expects (bin_file shows what it is); the line above is the command own reason'
|
|
85
|
+
const error = new Error(`${what} failed (exit ${run.exitCode})${missing}: ${run.command}${detail === '' ? '' : `\n${detail}\n${next}`}`)
|
|
55
86
|
error.name = 'CommandError'
|
|
56
87
|
throw error
|
|
57
88
|
}
|
|
@@ -103,33 +134,67 @@ export function apply(ctx) {
|
|
|
103
134
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
104
135
|
},
|
|
105
136
|
async execute(args, exec) {
|
|
106
|
-
const
|
|
137
|
+
const path = safePath(args.path)
|
|
138
|
+
const kindRun = await runCmd(shell, ['file', '-b', path], exec)
|
|
107
139
|
const kind = kindRun.exitCode === 0 ? kindRun.stdout.trim() : ''
|
|
108
|
-
const dynamic = await runCmd(shell, ['objdump', '-T',
|
|
140
|
+
const dynamic = await runCmd(shell, ['objdump', '-T', path], exec)
|
|
109
141
|
const dynamicLines = dynamic.exitCode === 0
|
|
110
142
|
? dynamic.stdout.split('\n').filter((l) => l.includes('DF') && l.includes('*UND*')).map((l) => l.trim())
|
|
111
143
|
: []
|
|
112
|
-
if (dynamicLines.length > 0)
|
|
144
|
+
if (dynamicLines.length > 0) {
|
|
145
|
+
return JSON.stringify({
|
|
146
|
+
tool: 'rev_imports',
|
|
147
|
+
target: args.path,
|
|
148
|
+
operation: 'list undefined (imported) symbols: objdump -T (DF *UND*)',
|
|
149
|
+
observed: { method: 'objdump', shown: Math.min(dynamicLines.length, 300), dump: dynamicLines.slice(0, 300).join('\n') },
|
|
150
|
+
confidence: 'observed',
|
|
151
|
+
limitations: [
|
|
152
|
+
'只列**动态**符号表(.dynsym)里的未定义项:静态链接进来的、或本地定义的符号不在里面',
|
|
153
|
+
dynamicLines.length > 300 ? '只给前 300 条(要全量就自己按符号名筛)' : '这次没有截断(未定义符号 ≤ 300)',
|
|
154
|
+
],
|
|
155
|
+
}, null, 2)
|
|
156
|
+
}
|
|
113
157
|
// `objdump -T` exits 0 with a bare table header for Mach-O, and a stripped
|
|
114
158
|
// ELF may have no dynamic symbol table at all. `nm -u` is the portable
|
|
115
159
|
// second opinion (it ships with macOS and the Xcode CLT).
|
|
116
|
-
const nm = await runCmd(shell, ['nm', '-u',
|
|
160
|
+
const nm = await runCmd(shell, ['nm', '-u', path], exec, { stdoutMaxBytes: 4194304 })
|
|
117
161
|
const undefinedSymbols = nm.exitCode === 0
|
|
118
162
|
? nm.stdout.split('\n').map((l) => l.trim()).filter((l) => /^_?[A-Za-z_][\w$.@]*$/u.test(l))
|
|
119
163
|
: []
|
|
120
164
|
if (undefinedSymbols.length > 0) {
|
|
121
|
-
return
|
|
165
|
+
return JSON.stringify({
|
|
166
|
+
tool: 'rev_imports',
|
|
167
|
+
target: args.path,
|
|
168
|
+
operation: 'list undefined (imported) symbols: nm -u (objdump -T had no dynamic table)',
|
|
169
|
+
observed: { method: 'nm -u', format: kind === '' ? null : kind, shown: Math.min(undefinedSymbols.length, 300), dump: [
|
|
122
170
|
`${kind === '' ? 'format unknown' : kind}: no ELF dynamic symbol table to read, so these are the undefined symbols from nm -u (${undefinedSymbols.length}):`,
|
|
123
171
|
...undefinedSymbols.slice(0, 300),
|
|
124
|
-
|
|
172
|
+
].join('\n') },
|
|
173
|
+
confidence: 'observed',
|
|
174
|
+
limitations: [
|
|
175
|
+
'走的是 nm -u 这条**回退**路径:objdump -T 在这个文件上没有动态符号表(Mach-O 常见)',
|
|
176
|
+
'nm -u 只报"未定义"的名字,不告诉你是从哪个库来的(要库映射得看 otool -L / readelf -d)',
|
|
177
|
+
],
|
|
178
|
+
}, null, 2)
|
|
125
179
|
}
|
|
126
|
-
return
|
|
180
|
+
return JSON.stringify({
|
|
181
|
+
tool: 'rev_imports',
|
|
182
|
+
target: args.path,
|
|
183
|
+
operation: 'list undefined (imported) symbols: both objdump -T and nm -u came back empty',
|
|
184
|
+
observed: { method: null, format: kind === '' ? null : kind, shown: 0, dump: [
|
|
127
185
|
`no undefined symbols for ${args.path}: objdump -T (exit ${dynamic.exitCode}) and nm -u (exit ${nm.exitCode}) both came back empty`,
|
|
128
186
|
`format: ${kind === '' ? 'unknown (file(1) reported nothing)' : kind}`,
|
|
129
187
|
`nm -u stderr: ${nm.stderr.trim().slice(0, 200) || '(empty)'}`,
|
|
130
188
|
`objdump -T output follows (${dynamic.stdout.trim().length} chars), so the emptiness is inspectable:`,
|
|
131
189
|
dynamic.stdout.trim().slice(0, 2000),
|
|
132
|
-
|
|
190
|
+
].join('\n') },
|
|
191
|
+
confidence: 'observed',
|
|
192
|
+
limitations: [
|
|
193
|
+
'**两把尺子都没给出符号**:这句话的前提是 objdump -T 与 nm -u **都跑过了**(两条 exit 与 nm 的 stderr 就在 dump 里)',
|
|
194
|
+
'它说的是「这份文件里没有可读的未定义符号」,不是「这个程序不依赖任何外部符号」—— 静态链接或已 strip 的二进制正是这个形状',
|
|
195
|
+
'要继续追依赖,先换个体:bin_file 看格式、bin_readelf 看动态段(.dynamic / NEEDED)',
|
|
196
|
+
],
|
|
197
|
+
}, null, 2)
|
|
133
198
|
},
|
|
134
199
|
}))
|
|
135
200
|
|
|
@@ -146,10 +211,23 @@ export function apply(ctx) {
|
|
|
146
211
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
147
212
|
},
|
|
148
213
|
async execute(args, exec) {
|
|
149
|
-
const
|
|
214
|
+
const path = safePath(args.path)
|
|
215
|
+
const argv = ['-d', path]
|
|
150
216
|
if (args.start && args.stop) argv.splice(1, 0, `--start-address=${args.start}`, `--stop-address=${args.stop}`)
|
|
151
217
|
const stdout = requireOk(await runCmd(shell, ['objdump', ...argv], exec, { stdoutMaxBytes: 1048576 }), 'objdump -d')
|
|
152
|
-
|
|
218
|
+
// 证据形状(规划 §5.1):信封 JSON,objdump 输出原样进 observed.dump。
|
|
219
|
+
return JSON.stringify({
|
|
220
|
+
tool: 'rev_disasm',
|
|
221
|
+
target: args.path,
|
|
222
|
+
operation: 'objdump -d' + (args.start && args.stop ? ` --start-address=${args.start} --stop-address=${args.stop}` : ' (whole file)'),
|
|
223
|
+
observed: { window: args.start && args.stop ? { start: args.start, stop: args.stop } : null, truncated: stdout.length > 12000, dump: stdout.slice(0, 12000) },
|
|
224
|
+
confidence: 'observed',
|
|
225
|
+
limitations: [
|
|
226
|
+
stdout.length > 12000 ? '只给前 12000 字符(truncated=true)—— 要看后面请给 start/stop 缩小窗口' : '这次没有截断(输出 ≤ 12000 字符)',
|
|
227
|
+
'反汇编是**线性扫描**的结果:数据段夹在代码里时,那些字节也会被当成指令打出来(用 start/stop 框住函数能少踩这个)',
|
|
228
|
+
'不看控制流、不做函数边界识别(要那些得换真正的反编译器)',
|
|
229
|
+
],
|
|
230
|
+
}, null, 2)
|
|
153
231
|
},
|
|
154
232
|
}))
|
|
155
233
|
|
|
@@ -165,14 +243,50 @@ export function apply(ctx) {
|
|
|
165
243
|
render: (_a, v) => [{ type: 'text', text: v }],
|
|
166
244
|
},
|
|
167
245
|
async execute(args, exec) {
|
|
168
|
-
const
|
|
246
|
+
const path = safePath(args.path)
|
|
247
|
+
const argv = ['--binary', path, '--only', 'pop|ret|leave']
|
|
169
248
|
if (args.filter) argv.push('--filter', args.filter)
|
|
170
249
|
const rop = await runCmd(shell, ['ROPgadget', ...argv], exec)
|
|
171
|
-
if (rop.exitCode === 0)
|
|
172
|
-
|
|
250
|
+
if (rop.exitCode === 0) {
|
|
251
|
+
return JSON.stringify({
|
|
252
|
+
tool: 'rev_gadget_scan',
|
|
253
|
+
target: args.path,
|
|
254
|
+
operation: 'ROPgadget --only pop|ret|leave' + (args.filter ? ` --filter ${args.filter}` : ''),
|
|
255
|
+
observed: { method: 'ROPgadget', shown: null, truncated: rop.stdout.length > 8000, dump: rop.stdout.slice(0, 8000) },
|
|
256
|
+
confidence: 'observed',
|
|
257
|
+
limitations: [
|
|
258
|
+
'ROPgadget 的输出原样返回(本工具只做截断:>8000 字符时 truncated=true)',
|
|
259
|
+
'ROPgadget 自己会做指令边界识别与去重,但**只扫 --only pop|ret|leave 这几类**(要别的指令得改本工具)',
|
|
260
|
+
],
|
|
261
|
+
}, null, 2)
|
|
262
|
+
}
|
|
263
|
+
const disasm = requireOk(await runCmd(shell, ['objdump', '-d', path], exec, { stdoutMaxBytes: 1048576 }), 'objdump -d')
|
|
173
264
|
const gadgets = scanGadgets(disasm, args.filter)
|
|
174
|
-
if (gadgets.length > 0)
|
|
175
|
-
|
|
265
|
+
if (gadgets.length > 0) {
|
|
266
|
+
return JSON.stringify({
|
|
267
|
+
tool: 'rev_gadget_scan',
|
|
268
|
+
target: args.path,
|
|
269
|
+
operation: 'built-in scanner over objdump -d (ROPgadget not installed)' + (args.filter ? ` filtered by ${args.filter}` : ''),
|
|
270
|
+
observed: { method: 'builtin', shown: Math.min(gadgets.length, 80), truncated: gadgets.length > 80, dump: gadgets.slice(0, 80).join('\n') },
|
|
271
|
+
confidence: 'observed',
|
|
272
|
+
limitations: [
|
|
273
|
+
'走的是**内置扫描器**(本机没有 ROPgadget):它只在 objdump 的反汇编里找 pop/leave + 紧跟的 ret 这类**简单序列**',
|
|
274
|
+
'不做指令边界校验、不做 gadget 去重:地址可能重叠,长链 gadget(多指令 + 中间跳转)它也不会给',
|
|
275
|
+
gadgets.length > 80 ? '只给前 80 条(observed.truncated=true)' : '这次没有截断(≤ 80 条)',
|
|
276
|
+
],
|
|
277
|
+
}, null, 2)
|
|
278
|
+
}
|
|
279
|
+
return JSON.stringify({
|
|
280
|
+
tool: 'rev_gadget_scan',
|
|
281
|
+
target: args.path,
|
|
282
|
+
operation: 'built-in scanner over objdump -d: nothing matched',
|
|
283
|
+
observed: { method: 'builtin', shown: 0, dump: `no pop/leave/ret gadgets found in ${args.path} (install ROPgadget for a deeper scan)` },
|
|
284
|
+
confidence: 'observed',
|
|
285
|
+
limitations: [
|
|
286
|
+
'**没找到 ≠ 没有**:这条走的是内置扫描器(只认 pop/leave + ret 的简单序列),装 ROPgadget 后同一条命令可能给出几十条',
|
|
287
|
+
'也不排除这个文件根本没有可执行段(比如被测的是数据文件)—— 先用 bin_file 看它是什么',
|
|
288
|
+
],
|
|
289
|
+
}, null, 2)
|
|
176
290
|
},
|
|
177
291
|
}))
|
|
178
292
|
}
|