@logictan/dsh-config-manager 0.1.61 → 0.1.62
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/lib/adapters/credentials.d.ts +1 -1
- package/lib/client.d.ts +0 -3
- package/lib/client.js +262 -268
- package/lib/core/analyzer.js +5 -4
- package/lib/core/exporter.d.ts +1 -4
- package/lib/core/exporter.js +14 -45
- package/lib/core/importer.d.ts +1 -1
- package/lib/core/index.d.ts +1 -1
- package/lib/core/messages.d.ts +1 -5
- package/lib/core/messages.js +2 -10
- package/lib/core/types.d.ts +8 -12
- package/lib/index.d.ts +2 -2
- package/lib/index.js +4 -57
- package/lib/security/index.d.ts +1 -3
- package/lib/security/index.js +1 -3
- package/lib/sync/transport.d.ts +2 -2
- package/lib/sync/transport.js +1 -1
- package/lib/ui/i18n.d.ts +0 -3
- package/lib/ui/i18n.js +0 -6
- package/lib/ui/report.js +1 -2
- package/lib/ui/test-helpers.d.ts +0 -6
- package/lib/ui/test-helpers.js +1 -5
- package/lib/ui/types.d.ts +0 -13
- package/package.json +1 -1
- package/src/adapters/credentials.ts +1 -1
- package/src/client/config-manager.module.css +1 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/sync/SyncSettingsView.tsx +5 -5
- package/src/client/sync/sync-locales.ts +0 -1
- package/src/core/analyzer.ts +5 -4
- package/src/core/exporter.ts +15 -47
- package/src/core/importer.ts +1 -1
- package/src/core/index.ts +1 -1
- package/src/core/messages.ts +2 -10
- package/src/core/smoke.test.ts +5 -5
- package/src/core/types.ts +8 -12
- package/src/index.ts +5 -56
- package/src/security/index.ts +1 -3
- package/src/security/security.test.ts +0 -361
- package/src/sync/transport.ts +3 -3
- package/src/ui/i18n.ts +0 -6
- package/src/ui/report.ts +1 -2
- package/src/ui/test-helpers.ts +3 -7
- package/src/ui/types.ts +0 -10
- package/lib/security/encryption.d.ts +0 -85
- package/lib/security/encryption.js +0 -279
- package/src/security/encryption.ts +0 -335
package/lib/ui/types.d.ts
CHANGED
|
@@ -118,25 +118,12 @@ export interface RollbackView {
|
|
|
118
118
|
export interface ImportPort {
|
|
119
119
|
analyzeImport(zipPath: string): Promise<ImportAnalysis>;
|
|
120
120
|
createImportPlan(zipPath: string, decisions: ImportDecisions): Promise<ImportPlan>;
|
|
121
|
-
/**
|
|
122
|
-
* 解锁整体加密备份(只读,零写入):用备份密码解密上传的加密容器,得到明文 ZIP
|
|
123
|
-
* 写入受控临时目录并返回新的 zipPath,供 analyze/plan/execute 引用。
|
|
124
|
-
* 顺带返回解密覆盖的凭据 ref 名(非值)——导出时容器密码与内部 secrets.enc
|
|
125
|
-
* 密码同源,解锁即完成凭据解密验证,无需第二次密码校验。
|
|
126
|
-
* 密码仅内存,绝不落盘/落日志;解密后的明文 ZIP 亦为临时文件,导入结束后清理。
|
|
127
|
-
*/
|
|
128
|
-
decryptArchive(zipPath: string, password: string): Promise<{
|
|
129
|
-
zipPath: string;
|
|
130
|
-
refs: string[];
|
|
131
|
-
}>;
|
|
132
121
|
executeImportPlan(zipPath: string, plan: ImportPlan, opts: {
|
|
133
122
|
/** 用户确认(安全阀,非 true 拒绝执行) */
|
|
134
123
|
confirm: boolean;
|
|
135
124
|
secretInputs?: Record<string, string>;
|
|
136
125
|
/** 显式回滚策略:true=任一项失败整体回滚(场景 E);false=单项失败继续(§34.17) */
|
|
137
126
|
rollbackOnError: boolean;
|
|
138
|
-
/** 加密备份的解密密码(仅内存;core 拒绝加密备份无密码执行) */
|
|
139
|
-
decryptPassword?: string;
|
|
140
127
|
}): Promise<ImportResult>;
|
|
141
128
|
}
|
|
142
129
|
/** 选项构造辅助:从 PlanItem 提取稳定决策键(与 core analyzer.applyItemResolution 的 id 语义一致) */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@logictan/dsh-config-manager",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.62",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Sync your DeepSeek Harness (DSH) configuration between machines over a private git or WebDAV channel: settings, plugins, MCP servers, skills, agent presets, workspaces and provider credentials. DSH 配置远程同步插件(私有通道,明文自用)。",
|
|
6
6
|
"keywords": [
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 安全不变量:永不导出值(hasValue 恒 false);导入生成 MissingSecret 清单,
|
|
7
7
|
* 用户补录值经 ctx.secretInputs / decryptedCredentials(仅内存)→ credentials.set()。
|
|
8
|
-
* .credentials.yaml
|
|
8
|
+
* .credentials.yaml 文件字节由 Exporter 的文件级 vault 处理,本 adapter 不触碰。
|
|
9
9
|
*/
|
|
10
10
|
import type { CredentialStatus, CredentialsSection } from '../schema/types.ts';
|
|
11
11
|
import { msgOf, zhMsg } from '../core/messages.ts';
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* run-store 单测:模块级单例 store + sessionStorage 恢复(同步面板专属)。
|
|
3
3
|
*
|
|
4
4
|
* 覆盖:
|
|
5
|
-
* - 敏感字段(token/webdavPassword
|
|
5
|
+
* - 敏感字段(token/webdavPassword 等)绝不写入 sessionStorage(白名单剔除);
|
|
6
6
|
* - 内存瞬态(busy/savingConfig)不写入 sessionStorage,刷新后复位;
|
|
7
7
|
* - 非敏感状态序列化/反序列化往返(新实例 + 同存储 = 模拟刷新);
|
|
8
8
|
* - 损坏/版本不符数据回退默认并清除脏键;
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* dialogMask + dialogCard dialogWide + dialogHeaderRow + dialogClose +
|
|
9
9
|
* dialogBodyScroll,零新增样式);
|
|
10
10
|
* - **通道配置弹窗**:通道子 tab(GitHub(git)/ WebDAV)切换,两个通道的
|
|
11
|
-
*
|
|
11
|
+
* 配置表单、自动同步、同步模式、远端快照**各自独立**;关闭弹窗
|
|
12
12
|
* = 放弃本次操作(GitHub 登录流程进行中则一并取消,§8.12 约定);
|
|
13
13
|
* - GitHub 子 tab:repoUrl(必填)+ 认证 token(可选,写入 DSH credentials 的提示)
|
|
14
14
|
* + **GitHub OAuth device flow 登录**(登录块跟随 git 通道配置放在弹窗内:
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* 全部渲染模型来自 ./sync-view.ts 纯函数(node 单测覆盖),组件只做装配;
|
|
25
25
|
* 状态组件内自持(useState),同时经 toSyncStoreSlice() 镜像进模块级 runStore:
|
|
26
26
|
* 模块级单例保证「切 tab 不丢」,sessionStorage 白名单保证「刷新恢复」;
|
|
27
|
-
* token/webdav
|
|
27
|
+
* token/webdav 密码仅内存(state),成功后清空(已写入 DSH
|
|
28
28
|
* credentials),持久化白名单硬性剔除(含 byChannel 内密码类字段),刷新后
|
|
29
29
|
* 清空、需要时重新输入。
|
|
30
30
|
*/
|
|
@@ -89,7 +89,7 @@ interface SyncUiState {
|
|
|
89
89
|
webdavUsername: string
|
|
90
90
|
/** 仅内存:成功后清空(已写入 DSH credentials),绝不持久化/回显 */
|
|
91
91
|
webdavPassword: string
|
|
92
|
-
/** git/webdav 各自独立的设置状态(自动同步 / 同步模式 /
|
|
92
|
+
/** git/webdav 各自独立的设置状态(自动同步 / 同步模式 / 快照) */
|
|
93
93
|
byChannel: {
|
|
94
94
|
git: ChannelSyncState
|
|
95
95
|
webdav: ChannelSyncState
|
|
@@ -174,7 +174,7 @@ const initial: SyncUiState = {
|
|
|
174
174
|
|
|
175
175
|
/**
|
|
176
176
|
* 从 runStore 恢复上次的同步 UI 状态(切 tab 回 / 刷新后挂载)。
|
|
177
|
-
* 敏感字段(token/webdav
|
|
177
|
+
* 敏感字段(token/webdav 密码)只在内存切片里保留:切 tab 保留;
|
|
178
178
|
* 刷新后已被持久化白名单清空(applyPersisted 强制归零)→ 需要时重新输入。
|
|
179
179
|
* busy/savingConfig 为瞬态:切 tab 由模块级单例保留(切回仍显示进行中);
|
|
180
180
|
* 刷新后白名单剔除 → 回复空闲。
|
|
@@ -240,7 +240,7 @@ export function SyncSettingsView({ api, t }: SyncSettingsViewProps) {
|
|
|
240
240
|
})
|
|
241
241
|
/** 更新当前激活通道的 byChannel 状态。 */
|
|
242
242
|
const patchChannel = (p: Partial<ChannelSyncState>): void => patchChannelState(state.channel, p)
|
|
243
|
-
/**
|
|
243
|
+
/** 当前激活通道的设置状态(自动同步/模式/快照)。 */
|
|
244
244
|
const chState: ChannelSyncState = state.byChannel[state.channel]
|
|
245
245
|
/** GitHub 流程态(不进 store 切片;commit 的镜像写幂等无害)。 */
|
|
246
246
|
const patchGithub = (p: Partial<GithubUiState>): void => commit({
|
|
@@ -331,7 +331,6 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
331
331
|
'mode.defaultCount': 'Will sync {n} recommended section(s)',
|
|
332
332
|
'mode.sectionRecommended': 'Recommended',
|
|
333
333
|
'mode.persistHint': 'The mode and section selection are saved locally: both auto sync and manual push use this configuration (persists across restarts).',
|
|
334
|
-
// Encryption & secret export (manual push only; auto sync always pushes plain snapshots and skips encrypted ones)
|
|
335
334
|
'autosync.title': 'Auto Sync',
|
|
336
335
|
'autosync.description': 'When enabled, DSH keeps config in sync in the background: uploads locally-changed config automatically, pulls and overwrites only when it detects a new remote snapshot; the timer is just a fallback poll and no-ops when nothing changed. Local writes happen only when there are no conflicts or manual-decision items.',
|
|
337
336
|
'autosync.enable': 'Enable auto sync',
|
package/src/core/analyzer.ts
CHANGED
|
@@ -526,8 +526,9 @@ export class Analyzer {
|
|
|
526
526
|
throw new ImportNotConfirmedError(this.msg);
|
|
527
527
|
}
|
|
528
528
|
|
|
529
|
-
// 10b.
|
|
530
|
-
//
|
|
529
|
+
// 10b. 旧版加密备份的兼容不变量:本插件已无加密层,但历史产物仍可能带
|
|
530
|
+
// manifest.security.encrypted=true。这类备份的凭据必须由宿主解密后注入
|
|
531
|
+
// (decryptedCredentials);未注入(undefined)一律拒绝执行——
|
|
531
532
|
// 不允许把加密凭据静默降级为「缺凭据照常导入」,否则加密备份与普通备份无区别。
|
|
532
533
|
if (bundle.manifest.security.encrypted && opts.decryptedCredentials === undefined) {
|
|
533
534
|
throw new Error(this.msg('import.encryptedPasswordRequired'));
|
|
@@ -725,8 +726,8 @@ export class Analyzer {
|
|
|
725
726
|
// M1:导入成功 → 快照标记 done(元数据写失败只告警,不改变导入结论)
|
|
726
727
|
await this.markSnapshotStatus(snapshot.id, 'done');
|
|
727
728
|
|
|
728
|
-
// F1 vault
|
|
729
|
-
//
|
|
729
|
+
// F1 vault 回填:includeSecrets=false 的导出会把敏感文件(.credentials.yaml 等)镜像到
|
|
730
|
+
// <dataDir>/vault 而不进备份,导入成功后从本机 vault 回填 $DSH_HOME;vault 缺失
|
|
730
731
|
// (跨机恢复 / 从未镜像过)记入警告提示用户重填。尽力而为:失败仅警告,不影响导入结论。
|
|
731
732
|
try {
|
|
732
733
|
const vaultDataDir = path.join(this.ctx.homeDir, 'dsh-config-manager');
|
package/src/core/exporter.ts
CHANGED
|
@@ -3,11 +3,9 @@
|
|
|
3
3
|
* adapter 收集各分区 → Secret 过滤 → manifest → checksum → ZIP。
|
|
4
4
|
*
|
|
5
5
|
* 安全不变量:
|
|
6
|
-
* - Secret 值默认永不进入导出数据(结构化分区逐一过 SecretScanner
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* 时 secrets.enc 加密空内容占位,备份仍需要密码导入,但不含任何凭据值;
|
|
10
|
-
* - 加密密码/秘密值绝不写入 manifest 与日志。
|
|
6
|
+
* - Secret 值默认永不进入导出数据(结构化分区逐一过 SecretScanner 剥离值);
|
|
7
|
+
* - 备份恒为明文(encrypted=false / encryption=null):本插件不再有加密层;
|
|
8
|
+
* - 秘密值绝不写入 manifest 与日志。
|
|
11
9
|
*/
|
|
12
10
|
import fs from 'node:fs/promises';
|
|
13
11
|
import path from 'node:path';
|
|
@@ -21,7 +19,7 @@ import { msgOf } from './messages.ts';
|
|
|
21
19
|
import type { MsgFunc } from './messages.ts';
|
|
22
20
|
import type { Manifest, SectionId } from '../schema/types.ts';
|
|
23
21
|
import type {
|
|
24
|
-
ConfigAdapter,
|
|
22
|
+
ConfigAdapter, ExportOptions, ExportReport,
|
|
25
23
|
ExportSection, HostContext, SecretScanner, SensitiveHit,
|
|
26
24
|
} from './types.ts';
|
|
27
25
|
|
|
@@ -39,8 +37,6 @@ export interface ExporterOptions {
|
|
|
39
37
|
adapters: ConfigAdapter[];
|
|
40
38
|
/** Secret 扫描器;缺省用字段名黑名单剥离(m4 可注入强化版) */
|
|
41
39
|
scanner?: SecretScanner;
|
|
42
|
-
/** 加密提供者(m4 用 node:crypto 实现);includeSecrets 时必填;提供时备份标记 encrypted=true */
|
|
43
|
-
encryption?: EncryptionProvider | null;
|
|
44
40
|
/** 插件自身版本(manifest.exporter.version) */
|
|
45
41
|
exporterVersion?: string;
|
|
46
42
|
now?: () => Date;
|
|
@@ -176,7 +172,6 @@ export class Exporter {
|
|
|
176
172
|
private readonly ctx: HostContext;
|
|
177
173
|
private readonly adapters: ConfigAdapter[];
|
|
178
174
|
private readonly scanner: SecretScanner;
|
|
179
|
-
private readonly encryption: EncryptionProvider | null;
|
|
180
175
|
private readonly exporterVersion: string;
|
|
181
176
|
private readonly now: () => Date;
|
|
182
177
|
private readonly msg: MsgFunc;
|
|
@@ -187,7 +182,6 @@ export class Exporter {
|
|
|
187
182
|
this.ctx = opts.ctx;
|
|
188
183
|
this.adapters = opts.adapters;
|
|
189
184
|
this.scanner = opts.scanner ?? defaultSecretScanner();
|
|
190
|
-
this.encryption = opts.encryption ?? null;
|
|
191
185
|
this.exporterVersion = opts.exporterVersion ?? '0.1.0';
|
|
192
186
|
this.now = opts.now ?? (() => new Date());
|
|
193
187
|
this.msg = opts.msg ?? msgOf(opts.ctx);
|
|
@@ -201,9 +195,6 @@ export class Exporter {
|
|
|
201
195
|
*/
|
|
202
196
|
async export(options: ExportOptions): Promise<{ zipPath: string; manifest: Manifest; report: ExportReport }> {
|
|
203
197
|
const { includeSecrets, only } = options;
|
|
204
|
-
if (includeSecrets && !this.encryption) {
|
|
205
|
-
throw new Error(this.msg('export.encryptionRequired'));
|
|
206
|
-
}
|
|
207
198
|
|
|
208
199
|
// 1. 选定分区(only 过滤 + 默认包含)
|
|
209
200
|
const selected = this.adapters
|
|
@@ -214,6 +205,8 @@ export class Exporter {
|
|
|
214
205
|
const sections: ExportSection[] = [];
|
|
215
206
|
const warnings: string[] = [];
|
|
216
207
|
const redactedHits: SensitiveHit[] = [];
|
|
208
|
+
/** 文件类分区实扫到的命中数:只有它能让 containsSecrets 为真(结构化分区已被剥离) */
|
|
209
|
+
let fileSectionSecretHits = 0;
|
|
217
210
|
const included: ExportReport['included'] = [];
|
|
218
211
|
const excluded: SectionId[] = this.adapters.filter((a) => !selected.includes(a.id)).map((a) => a.id);
|
|
219
212
|
|
|
@@ -249,6 +242,7 @@ export class Exporter {
|
|
|
249
242
|
if (fileHits.length > 0) {
|
|
250
243
|
// redactedHits 是**报告统计**通道(非告警通道):仍计入全量命中(含同一文件的多行/多形态命中)。
|
|
251
244
|
redactedHits.push(...fileHits);
|
|
245
|
+
fileSectionSecretHits += fileHits.length;
|
|
252
246
|
// 告警按**文件**去重(G-09/H1):同一路径只告警一次。
|
|
253
247
|
// 修复前按 hit 计数,同一行同时命中「字段名」与「值形状」会产出两条同路径告警,
|
|
254
248
|
// 使少数文件就吃满上限,导致含真实明文凭据的其它文件被静默淹没(实测 redactedHits=8 而 5 条告警全属一个文件)。
|
|
@@ -278,12 +272,13 @@ export class Exporter {
|
|
|
278
272
|
warnings.push(...section.warnings);
|
|
279
273
|
}
|
|
280
274
|
|
|
281
|
-
// 4. 组装 ZIP 条目(JSON 分区 + 文件类分区 +
|
|
275
|
+
// 4. 组装 ZIP 条目(JSON 分区 + 文件类分区 + checksums + manifest)
|
|
282
276
|
const entries: { name: string; data: Uint8Array }[] = [];
|
|
283
277
|
const sectionFlags = buildSectionFlags(sections);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
278
|
+
// 备份恒为明文:本插件不再有加密层。containsSecrets 只认「文件类分区实扫到的命中」——
|
|
279
|
+
// 结构化分区的敏感值已被 scanner 剥离,不构成「含秘密」;文件类分区只报告不改写,
|
|
280
|
+
// 命中即代表归档里确有明文,必须如实标注。
|
|
281
|
+
const containsSecrets = fileSectionSecretHits > 0;
|
|
287
282
|
|
|
288
283
|
for (const section of sections) {
|
|
289
284
|
if (isFileSection(section.sectionId)) {
|
|
@@ -302,33 +297,8 @@ export class Exporter {
|
|
|
302
297
|
});
|
|
303
298
|
}
|
|
304
299
|
|
|
305
|
-
// secrets.enc:有加密提供者即生成。includeSecrets=true 时加密真实的凭据原文;
|
|
306
|
-
// 只勾选加密(不导出密钥)时加密空内容占位,备份仍标记 encrypted(导入需密码),
|
|
307
|
-
// 但绝不把凭据值放进去(containsSecrets 保持 false;安全不变量不破)。
|
|
308
|
-
if (this.encryption) {
|
|
309
|
-
const credentialsFile = path.join(this.ctx.homeDir, '.credentials.yaml');
|
|
310
|
-
let plaintext: string;
|
|
311
|
-
if (includeSecrets) {
|
|
312
|
-
try {
|
|
313
|
-
const raw = await this.ctx.fs.readFile(credentialsFile);
|
|
314
|
-
plaintext = Buffer.from(raw).toString('utf8');
|
|
315
|
-
} catch (err) {
|
|
316
|
-
warnings.push(this.msg('export.credentialsReadFailed', { reason: err instanceof Error ? err.message : String(err) }));
|
|
317
|
-
plaintext = '';
|
|
318
|
-
}
|
|
319
|
-
} else {
|
|
320
|
-
plaintext = '';
|
|
321
|
-
}
|
|
322
|
-
const result = await this.encryption.encrypt(plaintext);
|
|
323
|
-
entries.push({ name: 'security/secrets.enc', data: result.blob });
|
|
324
|
-
encryption = result.info;
|
|
325
|
-
containsSecrets = includeSecrets && plaintext !== '';
|
|
326
|
-
encrypted = true;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
300
|
// 4b. 文件级 vault(includeSecrets=false:敏感文件明文不进归档 → 镜像到本机 vault)。
|
|
330
|
-
// 尽力而为:任何失败仅记警告,不中断导出。
|
|
331
|
-
// 无需镜像(vault 只服务于「明文不进备份」的本机留存场景)。
|
|
301
|
+
// 尽力而为:任何失败仅记警告,不中断导出。
|
|
332
302
|
let vaultRefreshed = 0;
|
|
333
303
|
if (!includeSecrets) {
|
|
334
304
|
try {
|
|
@@ -357,8 +327,8 @@ export class Exporter {
|
|
|
357
327
|
arch: this.ctx.arch,
|
|
358
328
|
sections: sectionFlags,
|
|
359
329
|
containsSecrets,
|
|
360
|
-
encrypted,
|
|
361
|
-
encryption,
|
|
330
|
+
encrypted: false,
|
|
331
|
+
encryption: null,
|
|
362
332
|
exportedAt: this.now().toISOString(),
|
|
363
333
|
});
|
|
364
334
|
entries.push({ name: MANIFEST_FILE, data: Buffer.from(stringifyJsonSafe(manifest, { space: 2 }), 'utf8') });
|
|
@@ -375,7 +345,6 @@ export class Exporter {
|
|
|
375
345
|
sections: Object.keys(sectionFlags).filter((k) => sectionFlags[k as SectionId]),
|
|
376
346
|
redactedFields: redactedHits.length,
|
|
377
347
|
containsSecrets,
|
|
378
|
-
encrypted,
|
|
379
348
|
vaultRefreshed,
|
|
380
349
|
});
|
|
381
350
|
|
|
@@ -385,7 +354,6 @@ export class Exporter {
|
|
|
385
354
|
security: {
|
|
386
355
|
secretsExcluded: !includeSecrets,
|
|
387
356
|
containsSecrets,
|
|
388
|
-
encrypted,
|
|
389
357
|
redactedHits: redactedHits.length,
|
|
390
358
|
vaultRefreshed,
|
|
391
359
|
},
|
package/src/core/importer.ts
CHANGED
|
@@ -36,7 +36,7 @@ export interface ExecuteOptions {
|
|
|
36
36
|
confirm: boolean;
|
|
37
37
|
/** 用户补录的秘密值(仅内存) */
|
|
38
38
|
secretInputs?: Record<string, string>;
|
|
39
|
-
/**
|
|
39
|
+
/** 旧版加密备份的解密结果(仅内存;宿主解密后注入,本插件不再产生加密备份) */
|
|
40
40
|
decryptedCredentials?: Map<string, string>;
|
|
41
41
|
/** 任一项失败立即整体回滚(默认 false:单项失败如实记录并继续其余项) */
|
|
42
42
|
rollbackOnError?: boolean;
|
package/src/core/index.ts
CHANGED
|
@@ -47,7 +47,7 @@ export type {
|
|
|
47
47
|
ExportOptions, ExportSection, ValidationResult, HostContext,
|
|
48
48
|
SettingsFacade, CredentialsFacade, PluginsFacade, WorkspaceFacade,
|
|
49
49
|
PatchFileFacade, FileSystemFacade, NamespaceInfo, PluginInfo,
|
|
50
|
-
ConfigAdapter, Portability, SecretScanner, SensitiveHit,
|
|
50
|
+
ConfigAdapter, Portability, SecretScanner, SensitiveHit,
|
|
51
51
|
PlanItem, PlanItemKind, ItemResolution, GlobalConflictStrategy,
|
|
52
52
|
ImportAnalysis, ImportDecisions, ImportPlan, ImportResult, ExecutedItem,
|
|
53
53
|
ImportContext, SnapshotTarget, SnapshotEntry, Snapshot, SnapshotStore,
|
package/src/core/messages.ts
CHANGED
|
@@ -18,9 +18,7 @@ export type { MsgFunc, MsgParams } from './msg-types.ts';
|
|
|
18
18
|
|
|
19
19
|
export const zh = {
|
|
20
20
|
// ---------- 导出 ----------
|
|
21
|
-
'export.encryptionRequired': '导出包含秘密需要注入 EncryptionProvider(m4 实现),拒绝明文导出秘密',
|
|
22
21
|
'export.sectionFailed': '分区 {adapter} 导出失败: {reason}',
|
|
23
|
-
'export.credentialsReadFailed': '读取凭据文件失败,跳过秘密导出: {reason}',
|
|
24
22
|
'export.vaultRefreshed': '凭据明文未进入备份,已镜像到本机 vault({count} 个文件,恢复时可回填)',
|
|
25
23
|
'export.vaultRefreshSkipped': 'vault 镜像跳过 {rel}: {reason}',
|
|
26
24
|
'export.vaultRefreshFailed': 'vault 镜像刷新失败(不影响导出): {reason}',
|
|
@@ -53,9 +51,7 @@ export const zh = {
|
|
|
53
51
|
'import.secretMissingDesc': '凭据 {ref} 需要补录',
|
|
54
52
|
'import.secretNotProvided': '凭据未提供,需补录',
|
|
55
53
|
'import.notConfirmed': '导入未确认:必须显式 confirm 后才允许修改任何数据',
|
|
56
|
-
'import.encryptedPasswordRequired': '
|
|
57
|
-
'import.encryptedPasswordWrong': '解密密码错误,请重试',
|
|
58
|
-
'import.notEncryptedContainer': '该文件不是加密备份容器,无法解锁',
|
|
54
|
+
'import.encryptedPasswordRequired': '该备份含上游历史加密凭据(security.encrypted=true),本插件无解密能力:须由宿主解密后注入凭据(decryptedCredentials)才能导入',
|
|
59
55
|
'import.userSkipped': '用户跳过(导入中点击「跳过当前插件」)',
|
|
60
56
|
'import.userSkippedDetail': '插件 {name} 已由用户跳过,未安装',
|
|
61
57
|
'import.vaultRestored': '凭据文件 {rel} 已从本机 vault 回填',
|
|
@@ -327,9 +323,7 @@ export const zh = {
|
|
|
327
323
|
|
|
328
324
|
export const en: Record<keyof typeof zh, string> = {
|
|
329
325
|
// ---------- export ----------
|
|
330
|
-
'export.encryptionRequired': 'Encrypted export requires an EncryptionProvider (m4); refusing to export secrets in plaintext',
|
|
331
326
|
'export.sectionFailed': 'Section {adapter} export failed: {reason}',
|
|
332
|
-
'export.credentialsReadFailed': 'Failed to read the credentials file; secrets export skipped: {reason}',
|
|
333
327
|
'export.vaultRefreshed': 'Credential plaintext did not enter the backup; mirrored to the local vault ({count} file(s), available for restore backfill)',
|
|
334
328
|
'export.vaultRefreshSkipped': 'Vault mirror skipped {rel}: {reason}',
|
|
335
329
|
'export.vaultRefreshFailed': 'Vault mirror refresh failed (export unaffected): {reason}',
|
|
@@ -362,9 +356,7 @@ export const en: Record<keyof typeof zh, string> = {
|
|
|
362
356
|
'import.secretMissingDesc': 'Credential {ref} needs to be re-entered',
|
|
363
357
|
'import.secretNotProvided': 'Credential not provided; requires re-entry',
|
|
364
358
|
'import.notConfirmed': 'Import not confirmed: explicit confirm is required before any data is modified',
|
|
365
|
-
'import.encryptedPasswordRequired': 'This backup
|
|
366
|
-
'import.encryptedPasswordWrong': 'Wrong decryption password, please try again',
|
|
367
|
-
'import.notEncryptedContainer': 'This file is not an encrypted backup container and cannot be unlocked',
|
|
359
|
+
'import.encryptedPasswordRequired': 'This backup carries upstream-encrypted credentials (security.encrypted=true) and this plugin cannot decrypt them: a host must decrypt and inject them (decryptedCredentials) before import',
|
|
368
360
|
'import.userSkipped': 'Skipped by user (clicked "Skip current plugin" during import)',
|
|
369
361
|
'import.userSkippedDetail': 'Plugin {name} was skipped by the user and not installed',
|
|
370
362
|
'import.vaultRestored': 'Credential file {rel} backfilled from the local vault',
|
package/src/core/smoke.test.ts
CHANGED
|
@@ -827,15 +827,15 @@ test('兼容性评分规则', () => {
|
|
|
827
827
|
assert.equal(computeCompatibility({ sourceDsh: '0.1.0-rc.6', targetDsh: '0.1.0-rc.6', sourcePlatform: 'win32', targetPlatform: 'win32', schemaVersion: 999, missingSections: [] }), 'unsupported');
|
|
828
828
|
});
|
|
829
829
|
|
|
830
|
-
test('
|
|
830
|
+
test('包含秘密导出:本插件无加密层,明文导出照常进行且备份恒标记未加密', async () => {
|
|
831
831
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-cm-sec-'));
|
|
832
832
|
try {
|
|
833
833
|
const src = makeContext('win32', 'C:\\Users\\alice');
|
|
834
834
|
const exporter = new Exporter({ ctx: src, adapters: makeAdapters(), now: () => new Date() });
|
|
835
|
-
await
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
);
|
|
835
|
+
const { manifest, report } = await exporter.export({ includeSecrets: true, outPath: path.join(tmp, 'x.zip') });
|
|
836
|
+
assert.equal(manifest.security.encrypted, false, '备份恒为明文');
|
|
837
|
+
assert.equal(manifest.security.encryption, null, '不存在加密参数');
|
|
838
|
+
assert.equal(report.security.secretsExcluded, false, 'includeSecrets=true 时不走 vault 留存路径');
|
|
839
839
|
} finally {
|
|
840
840
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
841
841
|
}
|
package/src/core/types.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* HostContext 是 m3 定义、m5 实现的 DSH Service 门面(研究报告 §3.2 的叶子方法最小集),
|
|
6
6
|
* 测试用内存 mock 即可驱动完整导出→导入往返。
|
|
7
7
|
*/
|
|
8
|
-
import type {
|
|
8
|
+
import type { Manifest, SectionId, WorkspaceRecord } from '../schema/types.ts';
|
|
9
9
|
import type { TombstoneKind } from '../schema/tombstones.ts';
|
|
10
10
|
import type { MutationLockPort } from '../utils/env-lock.ts';
|
|
11
11
|
import type { RecursiveListing } from '../utils/recursive-walk.ts';
|
|
@@ -16,7 +16,11 @@ import type { MsgFunc } from './messages.ts';
|
|
|
16
16
|
/* ---------------- 导出选项与分区产出 ---------------- */
|
|
17
17
|
|
|
18
18
|
export interface ExportOptions {
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* 是否把真实秘密写入备份。本插件不再有加密层(备份恒为明文),结构化分区的秘密值
|
|
21
|
+
* 始终由 SecretScanner 剥离,故本开关当前的实际作用只剩「是否刷新本机 vault 镜像」:
|
|
22
|
+
* false → 导出后把敏感文件镜像到本机 vault(明文不进归档)。
|
|
23
|
+
*/
|
|
20
24
|
includeSecrets: boolean;
|
|
21
25
|
/** 仅导出指定分区(缺省 = 全部默认包含分区) */
|
|
22
26
|
only?: SectionId[];
|
|
@@ -235,7 +239,7 @@ export interface ImportAnalysis {
|
|
|
235
239
|
pathIssues: PathIssue[];
|
|
236
240
|
secretCount: number;
|
|
237
241
|
dependencyIssues: { item: string; dependency: string }[];
|
|
238
|
-
/**
|
|
242
|
+
/** 旧版加密备份标记(manifest.security.encrypted):本插件不再产生,仅历史产物为 true */
|
|
239
243
|
encrypted: boolean;
|
|
240
244
|
}
|
|
241
245
|
|
|
@@ -298,6 +302,7 @@ export interface ImportContext {
|
|
|
298
302
|
resolutions: Record<string, ItemResolution>;
|
|
299
303
|
/** 用户补录的秘密值(仅内存,永不落盘/日志) */
|
|
300
304
|
secretInputs: Record<string, string>;
|
|
305
|
+
/** 旧版加密备份的解密结果(仅内存;宿主解密后注入) */
|
|
301
306
|
decryptedCredentials?: Map<string, string>;
|
|
302
307
|
log: Logger;
|
|
303
308
|
/** 消息翻译器(analyzer 注入;适配器用它生成计划项描述/校验/结果消息) */
|
|
@@ -466,7 +471,6 @@ export interface ExportReport {
|
|
|
466
471
|
security: {
|
|
467
472
|
secretsExcluded: boolean;
|
|
468
473
|
containsSecrets: boolean;
|
|
469
|
-
encrypted: boolean;
|
|
470
474
|
redactedHits: number;
|
|
471
475
|
/** 本次导出镜像到本机 vault 的敏感文件数(文件级 vault;0 或缺失 = 未镜像) */
|
|
472
476
|
vaultRefreshed?: number;
|
|
@@ -504,14 +508,6 @@ export interface ConfigAdapter<TSection = unknown> {
|
|
|
504
508
|
rollback?(entries: SnapshotEntry[], ctx: HostContext): Promise<void>;
|
|
505
509
|
}
|
|
506
510
|
|
|
507
|
-
/* ---------------- 加密提供者(m4 用 node:crypto 实现) ---------------- */
|
|
508
|
-
|
|
509
|
-
export interface EncryptionProvider {
|
|
510
|
-
encrypt(plaintext: string): Promise<{ blob: Uint8Array; info: EncryptionInfo }>;
|
|
511
|
-
/** authTag 校验失败必须抛错 */
|
|
512
|
-
decrypt(blob: Uint8Array, info: EncryptionInfo, password: string): Promise<string>;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
511
|
/* ---------------- 错误类型 ---------------- */
|
|
516
512
|
|
|
517
513
|
/** 导入被确认前拒绝执行(安全阀) */
|
package/src/index.ts
CHANGED
|
@@ -20,8 +20,8 @@
|
|
|
20
20
|
* (isLoopbackRequest); LAN-exposed deployments never serve these endpoints;
|
|
21
21
|
* - uploads/exported ZIPs are staged under $DSH_HOME/dsh-config-manager/{tmp,exports}
|
|
22
22
|
* and every `path`/`zipPath` reference is confined to those roots;
|
|
23
|
-
* -
|
|
24
|
-
*
|
|
23
|
+
* - there is no encryption layer: every backup is plaintext, and secret values are
|
|
24
|
+
* stripped by the secret scanner before they reach any file, manifest, or log;
|
|
25
25
|
* - the import execute endpoint refuses to run without `confirm: true`
|
|
26
26
|
* (core ImportNotConfirmedError safety valve).
|
|
27
27
|
*
|
|
@@ -95,7 +95,6 @@ import { ImportNotConfirmedError, ImportUserSkippedError } from './core/types.ts
|
|
|
95
95
|
import { createAdapters } from './adapters/index.ts'
|
|
96
96
|
import { HOME_PATCH_FILE, PROFILE_PATCH_FILE } from './core/patch-layers.ts'
|
|
97
97
|
import { createLocalPluginPackHook } from './core/local-plugin-host.ts'
|
|
98
|
-
import { createEncryptionProvider, decryptCredentials, decryptArchive, SecurityError, encryptArchive, isArchiveBlob, verifyEncryptedBlob } from './security/index.ts'
|
|
99
98
|
import { createHardenedZipParser } from './security/zip-security.ts'
|
|
100
99
|
import { atomicCopyFile, atomicWriteFile } from './utils/atomic-write.ts'
|
|
101
100
|
import { EnvironmentLockManager, runWithMutationLock, EnvironmentLockUnavailableError, type MutationLockContext } from './utils/env-lock.ts'
|
|
@@ -140,11 +139,10 @@ import { createConfiguredSecretScanner } from './security/secret-scanner.ts'
|
|
|
140
139
|
import type { ConfiguredSecretPatterns } from './security/secret-scanner.ts'
|
|
141
140
|
import type { SecretScanner } from './core/types.ts'
|
|
142
141
|
import { sha256Hex } from './utils/hashing.ts'
|
|
143
|
-
import { MANIFEST_FILE, parseManifest } from './schema/manifest.ts'
|
|
144
142
|
import { isFileSection, SECTION_IDS } from './schema/config.ts'
|
|
145
143
|
import { stringifyJsonSafe } from './utils/json.ts'
|
|
146
|
-
import type {
|
|
147
|
-
import {
|
|
144
|
+
import type { SectionId, WorkspaceRecord } from './schema/types.ts'
|
|
145
|
+
import { zipToBuffer } from './utils/zip.ts'
|
|
148
146
|
import { isSameOrChild, normalizePath } from './utils/paths.ts'
|
|
149
147
|
import { createLogger, type Logger } from './utils/logger.ts'
|
|
150
148
|
|
|
@@ -157,7 +155,7 @@ export const name = 'config-manager'
|
|
|
157
155
|
export const inject = ['settings', 'credentials']
|
|
158
156
|
|
|
159
157
|
/** Plugin version, kept in sync with package.json ("version"). */
|
|
160
|
-
const PLUGIN_VERSION = '0.1.
|
|
158
|
+
const PLUGIN_VERSION = '0.1.62'
|
|
161
159
|
|
|
162
160
|
/** Plugin own package name — excluded from its own exported plugins list. */
|
|
163
161
|
const PLUGIN_NAME = 'dsh-config-manager'
|
|
@@ -919,48 +917,6 @@ async function withTimeout<T>(promise: Promise<T>, ms: number, message: string):
|
|
|
919
917
|
}
|
|
920
918
|
}
|
|
921
919
|
|
|
922
|
-
/** Decrypt an encrypted backup's credentials (in-memory only; undefined when not applicable). */
|
|
923
|
-
async function tryDecryptCredentials(
|
|
924
|
-
zipPath: string,
|
|
925
|
-
password: string | undefined,
|
|
926
|
-
): Promise<Map<string, string> | undefined> {
|
|
927
|
-
if (password === undefined || password === '') return undefined
|
|
928
|
-
const raw = await fs.readFile(zipPath)
|
|
929
|
-
const archive = parseZip(raw)
|
|
930
|
-
if (!archive.has(MANIFEST_FILE)) return undefined
|
|
931
|
-
let manifest: Manifest
|
|
932
|
-
try {
|
|
933
|
-
manifest = parseManifest(archive.readEntryText(MANIFEST_FILE))
|
|
934
|
-
} catch {
|
|
935
|
-
return undefined
|
|
936
|
-
}
|
|
937
|
-
if (!manifest.security.encrypted || manifest.security.encryption === null) return undefined
|
|
938
|
-
if (!archive.has('security/secrets.enc')) return undefined
|
|
939
|
-
const blob = archive.readEntry('security/secrets.enc')
|
|
940
|
-
const plaintext = await decryptCredentials(blob, manifest.security.encryption, password)
|
|
941
|
-
let parsed: unknown
|
|
942
|
-
try {
|
|
943
|
-
parsed = yaml.load(plaintext)
|
|
944
|
-
} catch {
|
|
945
|
-
return undefined
|
|
946
|
-
}
|
|
947
|
-
const map = new Map<string, string>()
|
|
948
|
-
if (parsed !== null && typeof parsed === 'object') {
|
|
949
|
-
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
|
950
|
-
if (typeof v === 'string' && v !== '') map.set(k, v)
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
return map
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
/** 解密错误 → 用户可读文本:BAD_PASSWORD 只报「密码错误」(不泄内部细节),其余原文 */
|
|
957
|
-
function decryptErrorText(error: unknown, msg: MsgFunc): string {
|
|
958
|
-
if (error instanceof SecurityError && error.code === 'BAD_PASSWORD') {
|
|
959
|
-
return msg('import.encryptedPasswordWrong')
|
|
960
|
-
}
|
|
961
|
-
return error instanceof Error ? error.message : String(error)
|
|
962
|
-
}
|
|
963
|
-
|
|
964
920
|
interface RoutesDeps {
|
|
965
921
|
host: ConfigManagerHostContext
|
|
966
922
|
adapters: ConfigAdapter[]
|
|
@@ -1840,13 +1796,6 @@ function makeRoutes(deps: RoutesDeps): { routes: WebRoute[]; scheduler: AutoSync
|
|
|
1840
1796
|
// 零写入;loopback fence 必备。
|
|
1841
1797
|
// ------------------------------------------------------------ download
|
|
1842
1798
|
// -------------------------------------------------------------- upload
|
|
1843
|
-
// ------------------------------------------------------ decrypt-archive
|
|
1844
|
-
// 整体加密备份容器的解锁(只读,零写入到任何配置):用备份密码解密上传的加密容器,
|
|
1845
|
-
// 得到明文 ZIP 写入受控临时目录并返回新 zipPath,供 analyze/plan/execute 引用。
|
|
1846
|
-
// 导出时容器密码与内部 secrets.enc 密码同源(同一 password 派生两层加密),
|
|
1847
|
-
// 因此顺带在明文 ZIP 上解出内部凭据覆盖清单(refs,非值)一并返回——
|
|
1848
|
-
// 导入全程只需输入这一次密码,无需第二个密码校验页面。
|
|
1849
|
-
// 密码仅内存随请求体传入,绝不落盘/落日志;解出的明文 ZIP 亦为临时文件,导入结束后清理。
|
|
1850
1799
|
// ------------------------------------------------------------- analyze
|
|
1851
1800
|
// ---------------------------------------------------------------- plan
|
|
1852
1801
|
// ------------------------------------------------------------ progress
|
package/src/security/index.ts
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 安全模块公共出口(m4-security):
|
|
3
|
-
* secret-scanner /
|
|
3
|
+
* secret-scanner / integrity / zip-security / redaction。
|
|
4
4
|
*
|
|
5
5
|
* 与 core 的注入点对齐:
|
|
6
6
|
* - `createSecretScanner()` → ExporterOptions.scanner(SecretScanner 契约)
|
|
7
|
-
* - `createEncryptionProvider(pw)` → ExporterOptions.encryption(EncryptionProvider 契约)
|
|
8
7
|
* - `createHardenedZipParser()` → ImporterOptions.parseZipOverride((buf, limits?) => ZipArchive)
|
|
9
8
|
* - `safeExtractHardened()` → m5 导入文件类分区落盘通道
|
|
10
9
|
* - `verifyChecksumsJson()` → 完整性校验通道(core analyzer 已有内置校验,本模块供独立调用)
|
|
11
10
|
*/
|
|
12
11
|
export * from './secret-scanner.ts';
|
|
13
|
-
export * from './encryption.ts';
|
|
14
12
|
export * from './integrity.ts';
|
|
15
13
|
export * from './zip-security.ts';
|
|
16
14
|
export * from './redaction.ts';
|