shellward 0.7.0 → 0.7.1

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.
@@ -14,9 +14,9 @@
14
14
  // - 并发上限,防滥用
15
15
  import { createServer } from 'http';
16
16
  import { spawn } from 'child_process';
17
- import { mkdtempSync, rmSync, existsSync, statSync } from 'fs';
17
+ import { mkdtempSync, rmSync, existsSync, statSync, mkdirSync, writeFileSync } from 'fs';
18
18
  import { tmpdir } from 'os';
19
- import { join, resolve } from 'path';
19
+ import { join, resolve, dirname, normalize, isAbsolute } from 'path';
20
20
  import { runProjectComplianceAudit } from '../compliance/audit.js';
21
21
  import { renderHtmlReport } from '../compliance/html-report.js';
22
22
  import { DEFAULT_CONFIG, resolveLocale } from '../types.js';
@@ -44,6 +44,14 @@ export function startWebServer(opts) {
44
44
  if (u.pathname === '/' || u.pathname === '') {
45
45
  return send(res, 200, 'text/html', formPage(!!opts.local));
46
46
  }
47
+ // 本地客户端:选文件夹上传(仅本地模式;数据只到 localhost、不出本机)
48
+ if (u.pathname === '/scan-files' && req.method === 'POST') {
49
+ if (!opts.local)
50
+ return send(res, 403, 'text/html', errorPage('公网模式不支持上传;请用「公开仓库 URL」。'));
51
+ if (active >= MAX_CONCURRENT)
52
+ return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试'));
53
+ return await handleUpload(req, res, locale, () => { active++; }, () => { active--; });
54
+ }
47
55
  if (u.pathname === '/scan') {
48
56
  if (active >= MAX_CONCURRENT) {
49
57
  return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试(并发上限)'));
@@ -117,6 +125,72 @@ async function handleLocal(res, path, locale, inc, dec) {
117
125
  dec();
118
126
  }
119
127
  }
128
+ const MAX_UPLOAD_BYTES = 16 * 1024 * 1024; // 16MB JSON 上限
129
+ /** 本地上传:客户端把选中的文件夹读成 {path,content}[] 发来,写入临时目录后扫描 */
130
+ async function handleUpload(req, res, locale, inc, dec) {
131
+ let body = '';
132
+ let size = 0;
133
+ let aborted = false;
134
+ await new Promise((resolveBody) => {
135
+ req.on('data', (c) => {
136
+ size += c.length;
137
+ if (size > MAX_UPLOAD_BYTES) {
138
+ aborted = true;
139
+ req.destroy();
140
+ resolveBody();
141
+ return;
142
+ }
143
+ body += c.toString('utf8');
144
+ });
145
+ req.on('end', () => resolveBody());
146
+ req.on('error', () => { aborted = true; resolveBody(); });
147
+ });
148
+ if (aborted)
149
+ return send(res, 413, 'text/html', errorPage('内容过大或读取失败(上限 16MB)。大项目请用本地 CLI:npx shellward scan'));
150
+ let payload;
151
+ try {
152
+ payload = JSON.parse(body);
153
+ }
154
+ catch {
155
+ return send(res, 400, 'text/html', errorPage('上传数据格式错误'));
156
+ }
157
+ const files = Array.isArray(payload.files) ? payload.files : [];
158
+ if (files.length === 0)
159
+ return send(res, 400, 'text/html', errorPage('未选择任何文件'));
160
+ const dir = mkdtempSync(join(tmpdir(), 'sw-up-'));
161
+ inc();
162
+ try {
163
+ for (const f of files) {
164
+ if (!f || typeof f.path !== 'string' || typeof f.content !== 'string')
165
+ continue;
166
+ // 路径安全:去掉绝对路径/.. 逃逸,落在临时目录内
167
+ const rel = normalize(f.path).replace(/^(\.\.(\/|\\|$))+/, '');
168
+ if (isAbsolute(rel) || rel.includes('..'))
169
+ continue;
170
+ const dest = join(dir, rel);
171
+ if (!dest.startsWith(dir))
172
+ continue;
173
+ try {
174
+ mkdirSync(dirname(dest), { recursive: true });
175
+ writeFileSync(dest, f.content);
176
+ }
177
+ catch { /* skip */ }
178
+ }
179
+ const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir);
180
+ const rootName = typeof payload.root === 'string' && payload.root ? payload.root : '(uploaded folder)';
181
+ send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: rootName }));
182
+ }
183
+ catch (e) {
184
+ send(res, 500, 'text/html', errorPage('扫描失败:' + esc(e?.message || String(e))));
185
+ }
186
+ finally {
187
+ dec();
188
+ try {
189
+ rmSync(dir, { recursive: true, force: true });
190
+ }
191
+ catch { /* ignore */ }
192
+ }
193
+ }
120
194
  /** 浅克隆公开仓库到临时目录(不鉴权、超时、不执行任何仓库代码) */
121
195
  function cloneRepo(url, dir) {
122
196
  return new Promise((res, rej) => {
@@ -139,26 +213,65 @@ function send(res, code, type, body) {
139
213
  res.end(body);
140
214
  }
141
215
  function formPage(local) {
142
- const field = local
143
- ? `<label>本地项目路径</label>
144
- <input name="path" placeholder="/Users/you/your-ai-project" autofocus>
145
- <p class="hint">本地模式:代码不上传、不出本机(客户端体验)。</p>`
146
- : `<label>公开仓库地址</label>
147
- <input name="repo" placeholder="https://github.com/owner/repo" autofocus>
148
- <p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。<b>私有/敏感代码请用本地 CLI</b>:<code>npx shellward scan</code>(不上传)。</p>`;
216
+ const urlForm = `
217
+ <form action="/scan" method="get">
218
+ <label>${local ? '② ' : ''}公开仓库地址</label>
219
+ <input name="repo" placeholder="https://github.com/owner/repo"${local ? '' : ' autofocus'}>
220
+ <button type="submit">${local ? '体检该仓库 →' : '开始体检 →'}</button>
221
+ <p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。${local ? '' : '<b>私有/敏感代码请用本地客户端或 CLI</b>:<code>npx shellward web --local</code> / <code>npx shellward scan</code>(不上传)。'}</p>
222
+ </form>`;
223
+ const uploadForm = local ? `
224
+ <form id="dirform">
225
+ <label>① 选择本地项目文件夹(推荐)</label>
226
+ <input type="file" id="dir" webkitdirectory directory multiple>
227
+ <button id="dbtn" type="submit">开始体检 →</button>
228
+ <p class="hint">📂 直接选你的项目文件夹,无需敲路径。文件仅发送到<b>本机的本地服务</b>处理,<b>不经过任何外部服务器、不出本机</b>。</p>
229
+ </form>
230
+ <div class="or">— 或 —</div>` : '';
149
231
  return page('ShellWard 合规体检', `
150
232
  <div class="hero">
151
233
  <div class="logo">🛡️ Shell<span>Ward</span> 合规网关</div>
152
234
  <h1>AI 应用合规体检</h1>
153
- <p class="sub">${local ? '填本地路径' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
154
- <form action="/scan" method="get">
155
- ${field}
156
- <button type="submit">开始体检 →</button>
157
- </form>
235
+ <p class="sub">${local ? '选项目文件夹或贴公开仓库链接' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
236
+ ${uploadForm}
237
+ ${urlForm}
158
238
  <p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
159
239
  <a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
160
- </div>`);
240
+ </div>
241
+ ${local ? UPLOAD_SCRIPT : ''}`);
161
242
  }
243
+ // 客户端:读取所选文件夹 → 过滤(跳过 node_modules 等、仅文本/配置、限大小) → POST 到本机服务
244
+ const UPLOAD_SCRIPT = `<script>
245
+ (function(){
246
+ var SKIP=/(^|\\/)(node_modules|\\.git|dist|build|\\.next|out|vendor|coverage|\\.venv|venv|__pycache__|target|\\.cache)(\\/|$)/;
247
+ var EXT=/\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|java|php|rs|json|yaml|yml|toml|ini|conf|sh|txt|csv)$/i;
248
+ var ENV=/(^|\\/)\\.env(\\.|$)/; var DEP=/^(package\\.json|requirements\\.txt|pyproject\\.toml|go\\.mod)$/;
249
+ var form=document.getElementById('dirform'); if(!form) return;
250
+ form.addEventListener('submit', async function(e){
251
+ e.preventDefault();
252
+ var inp=document.getElementById('dir'), btn=document.getElementById('dbtn');
253
+ if(!inp.files||!inp.files.length){alert('请先选择项目文件夹');return;}
254
+ btn.disabled=true; btn.textContent='读取中…';
255
+ var picked=[], total=0, root='';
256
+ for(var i=0;i<inp.files.length;i++){
257
+ var f=inp.files[i], rel=f.webkitRelativePath||f.name; if(!root)root=rel.split('/')[0];
258
+ if(SKIP.test(rel)) continue;
259
+ var base=rel.split('/').pop();
260
+ if(!(EXT.test(rel)||ENV.test(rel)||DEP.test(base))) continue;
261
+ if(f.size>524288) continue;
262
+ if(picked.length>=3000||total>8388608) break;
263
+ total+=f.size; picked.push(f);
264
+ }
265
+ if(!picked.length){alert('未找到可扫描的源码/配置文件');btn.disabled=false;btn.textContent='开始体检 →';return;}
266
+ btn.textContent='扫描中… ('+picked.length+' 个文件)';
267
+ var out=[]; for(var j=0;j<picked.length;j++){ try{ out.push({path:picked[j].webkitRelativePath||picked[j].name, content:await picked[j].text()}); }catch(_){} }
268
+ try{
269
+ var resp=await fetch('/scan-files',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({root:root,files:out})});
270
+ var html=await resp.text(); document.open(); document.write(html); document.close();
271
+ }catch(err){ alert('扫描失败: '+err); btn.disabled=false; btn.textContent='开始体检 →'; }
272
+ });
273
+ })();
274
+ </script>`;
162
275
  function errorPage(msg) {
163
276
  return page('出错了', `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
164
277
  <h1>⚠️ 无法完成</h1><p class="sub">${esc(msg)}</p>
@@ -182,8 +295,11 @@ input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16
182
295
  input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
183
296
  .hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
184
297
  .hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
298
+ input[type=file]{padding:12px;background:#f8fafc;cursor:pointer}
185
299
  button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
186
300
  font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
301
+ button:disabled{background:#94a3b8;cursor:default}
302
+ form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6px 0 14px}
187
303
  .foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
188
304
  .back{font-weight:600}
189
305
  </style></head><body>${body}</body></html>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shellward",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "mcpName": "io.github.jnMetaCode/shellward",
5
5
  "description": "AI agent security & MCP security middleware — prompt injection detection, AI firewall, runtime guardrails & data-loss prevention for LLM tool calls. 8-layer defense against data exfiltration & dangerous commands. Zero dependencies. SDK + OpenClaw plugin. Supports LangChain, AutoGPT, Claude Code, Cursor, OpenAI Agents, Hermes Agent.",
6
6
  "keywords": [
@@ -15,9 +15,9 @@
15
15
 
16
16
  import { createServer } from 'http'
17
17
  import { spawn } from 'child_process'
18
- import { mkdtempSync, rmSync, existsSync, statSync } from 'fs'
18
+ import { mkdtempSync, rmSync, existsSync, statSync, mkdirSync, writeFileSync } from 'fs'
19
19
  import { tmpdir } from 'os'
20
- import { join, resolve } from 'path'
20
+ import { join, resolve, dirname, normalize, isAbsolute } from 'path'
21
21
  import { runProjectComplianceAudit } from '../compliance/audit.js'
22
22
  import { renderHtmlReport } from '../compliance/html-report.js'
23
23
  import { DEFAULT_CONFIG, resolveLocale } from '../types.js'
@@ -52,6 +52,12 @@ export function startWebServer(opts: WebServerOptions): void {
52
52
  if (u.pathname === '/' || u.pathname === '') {
53
53
  return send(res, 200, 'text/html', formPage(!!opts.local))
54
54
  }
55
+ // 本地客户端:选文件夹上传(仅本地模式;数据只到 localhost、不出本机)
56
+ if (u.pathname === '/scan-files' && req.method === 'POST') {
57
+ if (!opts.local) return send(res, 403, 'text/html', errorPage('公网模式不支持上传;请用「公开仓库 URL」。'))
58
+ if (active >= MAX_CONCURRENT) return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试'))
59
+ return await handleUpload(req, res, locale, () => { active++ }, () => { active-- })
60
+ }
55
61
  if (u.pathname === '/scan') {
56
62
  if (active >= MAX_CONCURRENT) {
57
63
  return send(res, 503, 'text/html', errorPage('服务繁忙,请稍后再试(并发上限)'))
@@ -120,6 +126,52 @@ async function handleLocal(res: any, path: string, locale: 'zh' | 'en', inc: ()
120
126
  }
121
127
  }
122
128
 
129
+ const MAX_UPLOAD_BYTES = 16 * 1024 * 1024 // 16MB JSON 上限
130
+
131
+ /** 本地上传:客户端把选中的文件夹读成 {path,content}[] 发来,写入临时目录后扫描 */
132
+ async function handleUpload(req: any, res: any, locale: 'zh' | 'en', inc: () => void, dec: () => void) {
133
+ let body = ''
134
+ let size = 0
135
+ let aborted = false
136
+ await new Promise<void>((resolveBody) => {
137
+ req.on('data', (c: Buffer) => {
138
+ size += c.length
139
+ if (size > MAX_UPLOAD_BYTES) { aborted = true; req.destroy(); resolveBody(); return }
140
+ body += c.toString('utf8')
141
+ })
142
+ req.on('end', () => resolveBody())
143
+ req.on('error', () => { aborted = true; resolveBody() })
144
+ })
145
+ if (aborted) return send(res, 413, 'text/html', errorPage('内容过大或读取失败(上限 16MB)。大项目请用本地 CLI:npx shellward scan'))
146
+
147
+ let payload: { root?: string; files?: { path: string; content: string }[] }
148
+ try { payload = JSON.parse(body) } catch { return send(res, 400, 'text/html', errorPage('上传数据格式错误')) }
149
+ const files = Array.isArray(payload.files) ? payload.files : []
150
+ if (files.length === 0) return send(res, 400, 'text/html', errorPage('未选择任何文件'))
151
+
152
+ const dir = mkdtempSync(join(tmpdir(), 'sw-up-'))
153
+ inc()
154
+ try {
155
+ for (const f of files) {
156
+ if (!f || typeof f.path !== 'string' || typeof f.content !== 'string') continue
157
+ // 路径安全:去掉绝对路径/.. 逃逸,落在临时目录内
158
+ const rel = normalize(f.path).replace(/^(\.\.(\/|\\|$))+/, '')
159
+ if (isAbsolute(rel) || rel.includes('..')) continue
160
+ const dest = join(dir, rel)
161
+ if (!dest.startsWith(dir)) continue
162
+ try { mkdirSync(dirname(dest), { recursive: true }); writeFileSync(dest, f.content) } catch { /* skip */ }
163
+ }
164
+ const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir)
165
+ const rootName = typeof payload.root === 'string' && payload.root ? payload.root : '(uploaded folder)'
166
+ send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: rootName }))
167
+ } catch (e: any) {
168
+ send(res, 500, 'text/html', errorPage('扫描失败:' + esc(e?.message || String(e))))
169
+ } finally {
170
+ dec()
171
+ try { rmSync(dir, { recursive: true, force: true }) } catch { /* ignore */ }
172
+ }
173
+ }
174
+
123
175
  /** 浅克隆公开仓库到临时目录(不鉴权、超时、不执行任何仓库代码) */
124
176
  function cloneRepo(url: string, dir: string): Promise<void> {
125
177
  return new Promise((res, rej) => {
@@ -144,27 +196,69 @@ function send(res: any, code: number, type: string, body: string) {
144
196
  }
145
197
 
146
198
  function formPage(local: boolean): string {
147
- const field = local
148
- ? `<label>本地项目路径</label>
149
- <input name="path" placeholder="/Users/you/your-ai-project" autofocus>
150
- <p class="hint">本地模式:代码不上传、不出本机(客户端体验)。</p>`
151
- : `<label>公开仓库地址</label>
152
- <input name="repo" placeholder="https://github.com/owner/repo" autofocus>
153
- <p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。<b>私有/敏感代码请用本地 CLI</b>:<code>npx shellward scan</code>(不上传)。</p>`
199
+ const urlForm = `
200
+ <form action="/scan" method="get">
201
+ <label>${local ? '② ' : ''}公开仓库地址</label>
202
+ <input name="repo" placeholder="https://github.com/owner/repo"${local ? '' : ' autofocus'}>
203
+ <button type="submit">${local ? '体检该仓库 →' : '开始体检 →'}</button>
204
+ <p class="hint">仅支持公开仓库(GitHub / GitLab / Gitee / Bitbucket)。${local ? '' : '<b>私有/敏感代码请用本地客户端或 CLI</b>:<code>npx shellward web --local</code> / <code>npx shellward scan</code>(不上传)。'}</p>
205
+ </form>`
206
+
207
+ const uploadForm = local ? `
208
+ <form id="dirform">
209
+ <label>① 选择本地项目文件夹(推荐)</label>
210
+ <input type="file" id="dir" webkitdirectory directory multiple>
211
+ <button id="dbtn" type="submit">开始体检 →</button>
212
+ <p class="hint">📂 直接选你的项目文件夹,无需敲路径。文件仅发送到<b>本机的本地服务</b>处理,<b>不经过任何外部服务器、不出本机</b>。</p>
213
+ </form>
214
+ <div class="or">— 或 —</div>` : ''
215
+
154
216
  return page('ShellWard 合规体检', `
155
217
  <div class="hero">
156
218
  <div class="logo">🛡️ Shell<span>Ward</span> 合规网关</div>
157
219
  <h1>AI 应用合规体检</h1>
158
- <p class="sub">${local ? '填本地路径' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
159
- <form action="/scan" method="get">
160
- ${field}
161
- <button type="submit">开始体检 →</button>
162
- </form>
220
+ <p class="sub">${local ? '选项目文件夹或贴公开仓库链接' : '贴公开仓库链接'},30 秒查出数据出境 / 硬编码密钥 / 个人信息暴露等中国合规红线。</p>
221
+ ${uploadForm}
222
+ ${urlForm}
163
223
  <p class="foot">网安法 2026 · PIPL · 等保2.0 · 数据出境 · AI标识 | 零依赖 · 开源 ·
164
224
  <a href="https://github.com/jnMetaCode/shellward">GitHub ⭐</a></p>
165
- </div>`)
225
+ </div>
226
+ ${local ? UPLOAD_SCRIPT : ''}`)
166
227
  }
167
228
 
229
+ // 客户端:读取所选文件夹 → 过滤(跳过 node_modules 等、仅文本/配置、限大小) → POST 到本机服务
230
+ const UPLOAD_SCRIPT = `<script>
231
+ (function(){
232
+ var SKIP=/(^|\\/)(node_modules|\\.git|dist|build|\\.next|out|vendor|coverage|\\.venv|venv|__pycache__|target|\\.cache)(\\/|$)/;
233
+ var EXT=/\\.(ts|tsx|js|jsx|mjs|cjs|py|go|rb|java|php|rs|json|yaml|yml|toml|ini|conf|sh|txt|csv)$/i;
234
+ var ENV=/(^|\\/)\\.env(\\.|$)/; var DEP=/^(package\\.json|requirements\\.txt|pyproject\\.toml|go\\.mod)$/;
235
+ var form=document.getElementById('dirform'); if(!form) return;
236
+ form.addEventListener('submit', async function(e){
237
+ e.preventDefault();
238
+ var inp=document.getElementById('dir'), btn=document.getElementById('dbtn');
239
+ if(!inp.files||!inp.files.length){alert('请先选择项目文件夹');return;}
240
+ btn.disabled=true; btn.textContent='读取中…';
241
+ var picked=[], total=0, root='';
242
+ for(var i=0;i<inp.files.length;i++){
243
+ var f=inp.files[i], rel=f.webkitRelativePath||f.name; if(!root)root=rel.split('/')[0];
244
+ if(SKIP.test(rel)) continue;
245
+ var base=rel.split('/').pop();
246
+ if(!(EXT.test(rel)||ENV.test(rel)||DEP.test(base))) continue;
247
+ if(f.size>524288) continue;
248
+ if(picked.length>=3000||total>8388608) break;
249
+ total+=f.size; picked.push(f);
250
+ }
251
+ if(!picked.length){alert('未找到可扫描的源码/配置文件');btn.disabled=false;btn.textContent='开始体检 →';return;}
252
+ btn.textContent='扫描中… ('+picked.length+' 个文件)';
253
+ var out=[]; for(var j=0;j<picked.length;j++){ try{ out.push({path:picked[j].webkitRelativePath||picked[j].name, content:await picked[j].text()}); }catch(_){} }
254
+ try{
255
+ var resp=await fetch('/scan-files',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({root:root,files:out})});
256
+ var html=await resp.text(); document.open(); document.write(html); document.close();
257
+ }catch(err){ alert('扫描失败: '+err); btn.disabled=false; btn.textContent='开始体检 →'; }
258
+ });
259
+ })();
260
+ </script>`
261
+
168
262
  function errorPage(msg: string): string {
169
263
  return page('出错了', `<div class="hero"><div class="logo">🛡️ Shell<span>Ward</span></div>
170
264
  <h1>⚠️ 无法完成</h1><p class="sub">${esc(msg)}</p>
@@ -189,8 +283,11 @@ input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16
189
283
  input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
190
284
  .hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
191
285
  .hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
286
+ input[type=file]{padding:12px;background:#f8fafc;cursor:pointer}
192
287
  button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
193
288
  font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
289
+ button:disabled{background:#94a3b8;cursor:default}
290
+ form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6px 0 14px}
194
291
  .foot{margin:24px 0 0;font-size:12.5px;color:#94a3b8}.foot a,.back{color:#cb0000;text-decoration:none}
195
292
  .back{font-weight:600}
196
293
  </style></head><body>${body}</body></html>`