shellward 0.7.10 → 0.7.12
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 +1 -1
- package/dist/compliance/audit.js +39 -4
- package/dist/web/scan-server.js +22 -13
- package/package.json +1 -1
- package/src/compliance/audit.ts +34 -5
- package/src/web/scan-server.ts +23 -13
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
[](https://www.npmjs.com/package/shellward)
|
|
10
10
|
[](./LICENSE)
|
|
11
|
-
[](#performance)
|
|
12
12
|
[](#performance)
|
|
13
13
|
|
|
14
14
|
**🌐 官网: https://jnmetacode.github.io/shellward/**
|
package/dist/compliance/audit.js
CHANGED
|
@@ -121,17 +121,52 @@ export function runProjectComplianceAudit(config, root) {
|
|
|
121
121
|
}
|
|
122
122
|
// CLI 静态扫描:未部署运行时 → 能力/审计类不虚报"已启用",只如实评估项目证据
|
|
123
123
|
const report = runComplianceAudit(config, env, { deployed: false });
|
|
124
|
+
// 「敏感个人信息识别」这条静态扫描确实做得到 —— 直接连到扫描结果,不再标"待核验"
|
|
125
|
+
const piiCount = scan.findings.filter(f => f.kind === 'pii').length;
|
|
126
|
+
const spi = report.results.find(r => r.control.id === 'pipl-spi-detect');
|
|
127
|
+
if (spi) {
|
|
128
|
+
if (piiCount > 0) {
|
|
129
|
+
spi.status = 'fail';
|
|
130
|
+
spi.detail_zh = `扫描在项目文件中发现 ${piiCount} 处个人信息暴露(见上方「项目实测风险」)— 需评估最小必要并脱敏`;
|
|
131
|
+
spi.detail_en = `Scan found ${piiCount} PII exposure(s) in files — assess minimization & de-identify`;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
spi.status = 'pass';
|
|
135
|
+
spi.detail_zh = '已扫描项目文件,未发现明文个人信息暴露(运行时 PII 处理建议仍接入 ShellWard)';
|
|
136
|
+
spi.detail_en = 'Scanned files; no plaintext PII exposure found';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// override 改了状态 → 重算计数与控制项得分
|
|
140
|
+
recount(report);
|
|
141
|
+
const baseScore = computeScore(report.results);
|
|
124
142
|
// 发现驱动评分:项目实测风险按严重度扣分(封顶 40),使分数反映"你的真实风险"
|
|
125
143
|
const penalty = computeProjectPenalty(scan);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
144
|
+
report.score = Math.max(0, baseScore - penalty);
|
|
145
|
+
report.grade = gradeOf(report.score);
|
|
146
|
+
if (penalty > 0)
|
|
129
147
|
report.projectPenalty = penalty;
|
|
130
|
-
}
|
|
131
148
|
report.staticScan = true;
|
|
132
149
|
report.filesScanned = scan.filesScanned;
|
|
133
150
|
return { report, scan };
|
|
134
151
|
}
|
|
152
|
+
/** 重新统计 pass/warn/fail/manual 计数(控制项状态被覆盖后调用) */
|
|
153
|
+
function recount(report) {
|
|
154
|
+
let passed = 0, warned = 0, failed = 0, manual = 0;
|
|
155
|
+
for (const r of report.results) {
|
|
156
|
+
if (r.status === 'pass')
|
|
157
|
+
passed++;
|
|
158
|
+
else if (r.status === 'warn')
|
|
159
|
+
warned++;
|
|
160
|
+
else if (r.status === 'fail')
|
|
161
|
+
failed++;
|
|
162
|
+
else
|
|
163
|
+
manual++;
|
|
164
|
+
}
|
|
165
|
+
report.passed = passed;
|
|
166
|
+
report.warned = warned;
|
|
167
|
+
report.failed = failed;
|
|
168
|
+
report.manual = manual;
|
|
169
|
+
}
|
|
135
170
|
const FINDING_PENALTY = { critical: 8, high: 4, medium: 1 };
|
|
136
171
|
const MAX_PROJECT_PENALTY = 40;
|
|
137
172
|
function computeProjectPenalty(scan) {
|
package/dist/web/scan-server.js
CHANGED
|
@@ -34,9 +34,12 @@ export function validateRepoUrl(input) {
|
|
|
34
34
|
return { ok: false, reason: '仅支持 github.com / gitlab.com / gitee.com / bitbucket.org 的公开仓库 URL' };
|
|
35
35
|
return { ok: true, url };
|
|
36
36
|
}
|
|
37
|
+
// 报告「返回」链接:本地模式用绝对地址(上传报告经 blob: URL 打开,相对 '/' 会失效)
|
|
38
|
+
let SERVER_BASE = '/';
|
|
37
39
|
export function startWebServer(opts) {
|
|
38
40
|
const locale = resolveLocale(DEFAULT_CONFIG);
|
|
39
41
|
const host = opts.local ? '127.0.0.1' : '0.0.0.0';
|
|
42
|
+
SERVER_BASE = opts.local ? `http://localhost:${opts.port}/` : '/';
|
|
40
43
|
let active = 0;
|
|
41
44
|
const server = createServer(async (req, res) => {
|
|
42
45
|
try {
|
|
@@ -110,7 +113,7 @@ async function handleRepo(res, repo, locale, inc, dec) {
|
|
|
110
113
|
try {
|
|
111
114
|
await cloneRepo(v.url, dir);
|
|
112
115
|
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir);
|
|
113
|
-
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: v.url, backLink:
|
|
116
|
+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: v.url, backLink: SERVER_BASE }));
|
|
114
117
|
}
|
|
115
118
|
catch (e) {
|
|
116
119
|
const msg = esc(e?.message || String(e));
|
|
@@ -133,7 +136,7 @@ async function handleLocal(res, path, locale, inc, dec) {
|
|
|
133
136
|
inc();
|
|
134
137
|
try {
|
|
135
138
|
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, root);
|
|
136
|
-
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root, backLink:
|
|
139
|
+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root, backLink: SERVER_BASE }));
|
|
137
140
|
}
|
|
138
141
|
finally {
|
|
139
142
|
dec();
|
|
@@ -192,7 +195,7 @@ async function handleUpload(req, res, locale, inc, dec) {
|
|
|
192
195
|
}
|
|
193
196
|
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir);
|
|
194
197
|
const rootName = typeof payload.root === 'string' && payload.root ? payload.root : '(uploaded folder)';
|
|
195
|
-
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: rootName, backLink:
|
|
198
|
+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: rootName, backLink: SERVER_BASE }));
|
|
196
199
|
}
|
|
197
200
|
catch (e) {
|
|
198
201
|
send(res, 500, 'text/html', errorPage('扫描失败:' + esc(e?.message || String(e))));
|
|
@@ -248,7 +251,7 @@ function handleDemo(res, locale, inc, dec) {
|
|
|
248
251
|
writeFileSync(join(dir, 'data', 'customers.csv'), 'name,id_card,phone,card\n张三,110101199003071233,13800138000,4111111111111111\n');
|
|
249
252
|
writeFileSync(join(dir, '.env'), 'AWS_ACCESS_KEY=AKIARZ9MKP2QWLS7YV3N\nDB_PASSWORD=Sup3rS3cretProdPwd2026\n');
|
|
250
253
|
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir);
|
|
251
|
-
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: '示例项目(含风险)/ demo-ai-app', backLink:
|
|
254
|
+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: '示例项目(含风险)/ demo-ai-app', backLink: SERVER_BASE }));
|
|
252
255
|
}
|
|
253
256
|
catch (e) {
|
|
254
257
|
send(res, 500, 'text/html', errorPage('演示失败:' + esc(e?.message || String(e))));
|
|
@@ -391,20 +394,26 @@ function page(title, body) {
|
|
|
391
394
|
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;
|
|
392
395
|
background:linear-gradient(135deg,#eef1f6,#e2e8f0);color:#0f172a;
|
|
393
396
|
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
|
394
|
-
.hero{background:#fff;max-width:
|
|
395
|
-
box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}
|
|
396
|
-
.logo{font-weight:800;font-size:15px}.logo span{color:#cb0000}
|
|
397
|
-
h1{font-size:
|
|
398
|
-
.sub{color:#64748b;margin:0 0
|
|
397
|
+
.hero{background:#fff;max-width:580px;width:92%;margin:40px;padding:38px 40px 34px;border-radius:18px;
|
|
398
|
+
box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center;border-top:4px solid #cb0000}
|
|
399
|
+
.logo{font-weight:800;font-size:15px;letter-spacing:.2px}.logo span{color:#cb0000}
|
|
400
|
+
h1{font-size:29px;margin:12px 0 8px;letter-spacing:-.5px}
|
|
401
|
+
.sub{color:#64748b;margin:0 0 24px;font-size:15px}
|
|
399
402
|
form{display:flex;flex-direction:column;gap:10px;text-align:left}
|
|
400
|
-
label{font-size:13px;font-weight:
|
|
403
|
+
label{font-size:13px;font-weight:700;color:#334155}
|
|
401
404
|
input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16px;width:100%}
|
|
402
405
|
input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
|
|
403
|
-
.hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
|
|
406
|
+
.hint{font-size:12.5px;color:#64748b;margin:2px 0 6px;line-height:1.55}
|
|
404
407
|
.hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
|
|
405
|
-
|
|
408
|
+
/* 文件选择器:美化成虚线投放区 + 红色按钮 */
|
|
409
|
+
input[type=file]{width:100%;padding:20px 16px;border:2px dashed #cbd5e1;border-radius:12px;
|
|
410
|
+
background:#f8fafc;cursor:pointer;font-size:14px;color:#64748b;transition:.15s}
|
|
411
|
+
input[type=file]:hover{border-color:#cb0000;background:#fff}
|
|
412
|
+
input[type=file]::file-selector-button{background:#cb0000;color:#fff;border:0;border-radius:8px;
|
|
413
|
+
padding:9px 18px;margin-right:14px;font-weight:700;font-size:14px;cursor:pointer}
|
|
414
|
+
input[type=file]::file-selector-button:hover{background:#a80000}
|
|
406
415
|
button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
|
|
407
|
-
font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
|
|
416
|
+
font-weight:700;cursor:pointer;margin-top:4px;transition:.15s}button:hover{background:#a80000}
|
|
408
417
|
button:disabled{background:#94a3b8;cursor:default}
|
|
409
418
|
form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6px 0 14px}
|
|
410
419
|
.status{display:none;margin:10px 0 0;padding:10px 14px;border-radius:8px;background:#f1f5f9;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shellward",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12",
|
|
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": [
|
package/src/compliance/audit.ts
CHANGED
|
@@ -189,19 +189,48 @@ export function runProjectComplianceAudit(config: ShellWardConfig, root: string)
|
|
|
189
189
|
// CLI 静态扫描:未部署运行时 → 能力/审计类不虚报"已启用",只如实评估项目证据
|
|
190
190
|
const report = runComplianceAudit(config, env, { deployed: false })
|
|
191
191
|
|
|
192
|
+
// 「敏感个人信息识别」这条静态扫描确实做得到 —— 直接连到扫描结果,不再标"待核验"
|
|
193
|
+
const piiCount = scan.findings.filter(f => f.kind === 'pii').length
|
|
194
|
+
const spi = report.results.find(r => r.control.id === 'pipl-spi-detect')
|
|
195
|
+
if (spi) {
|
|
196
|
+
if (piiCount > 0) {
|
|
197
|
+
spi.status = 'fail'
|
|
198
|
+
spi.detail_zh = `扫描在项目文件中发现 ${piiCount} 处个人信息暴露(见上方「项目实测风险」)— 需评估最小必要并脱敏`
|
|
199
|
+
spi.detail_en = `Scan found ${piiCount} PII exposure(s) in files — assess minimization & de-identify`
|
|
200
|
+
} else {
|
|
201
|
+
spi.status = 'pass'
|
|
202
|
+
spi.detail_zh = '已扫描项目文件,未发现明文个人信息暴露(运行时 PII 处理建议仍接入 ShellWard)'
|
|
203
|
+
spi.detail_en = 'Scanned files; no plaintext PII exposure found'
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// override 改了状态 → 重算计数与控制项得分
|
|
208
|
+
recount(report)
|
|
209
|
+
const baseScore = computeScore(report.results)
|
|
210
|
+
|
|
192
211
|
// 发现驱动评分:项目实测风险按严重度扣分(封顶 40),使分数反映"你的真实风险"
|
|
193
212
|
const penalty = computeProjectPenalty(scan)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
report.projectPenalty = penalty
|
|
198
|
-
}
|
|
213
|
+
report.score = Math.max(0, baseScore - penalty)
|
|
214
|
+
report.grade = gradeOf(report.score)
|
|
215
|
+
if (penalty > 0) report.projectPenalty = penalty
|
|
199
216
|
report.staticScan = true
|
|
200
217
|
report.filesScanned = scan.filesScanned
|
|
201
218
|
|
|
202
219
|
return { report, scan }
|
|
203
220
|
}
|
|
204
221
|
|
|
222
|
+
/** 重新统计 pass/warn/fail/manual 计数(控制项状态被覆盖后调用) */
|
|
223
|
+
function recount(report: ComplianceReport): void {
|
|
224
|
+
let passed = 0, warned = 0, failed = 0, manual = 0
|
|
225
|
+
for (const r of report.results) {
|
|
226
|
+
if (r.status === 'pass') passed++
|
|
227
|
+
else if (r.status === 'warn') warned++
|
|
228
|
+
else if (r.status === 'fail') failed++
|
|
229
|
+
else manual++
|
|
230
|
+
}
|
|
231
|
+
report.passed = passed; report.warned = warned; report.failed = failed; report.manual = manual
|
|
232
|
+
}
|
|
233
|
+
|
|
205
234
|
const FINDING_PENALTY = { critical: 8, high: 4, medium: 1 } as const
|
|
206
235
|
const MAX_PROJECT_PENALTY = 40
|
|
207
236
|
|
package/src/web/scan-server.ts
CHANGED
|
@@ -41,9 +41,13 @@ export function validateRepoUrl(input: string): { ok: true; url: string } | { ok
|
|
|
41
41
|
return { ok: true, url }
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// 报告「返回」链接:本地模式用绝对地址(上传报告经 blob: URL 打开,相对 '/' 会失效)
|
|
45
|
+
let SERVER_BASE = '/'
|
|
46
|
+
|
|
44
47
|
export function startWebServer(opts: WebServerOptions): void {
|
|
45
48
|
const locale = resolveLocale(DEFAULT_CONFIG)
|
|
46
49
|
const host = opts.local ? '127.0.0.1' : '0.0.0.0'
|
|
50
|
+
SERVER_BASE = opts.local ? `http://localhost:${opts.port}/` : '/'
|
|
47
51
|
let active = 0
|
|
48
52
|
|
|
49
53
|
const server = createServer(async (req, res) => {
|
|
@@ -113,7 +117,7 @@ async function handleRepo(res: any, repo: string, locale: 'zh' | 'en', inc: () =
|
|
|
113
117
|
try {
|
|
114
118
|
await cloneRepo(v.url, dir)
|
|
115
119
|
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir)
|
|
116
|
-
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: v.url, backLink:
|
|
120
|
+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: v.url, backLink: SERVER_BASE }))
|
|
117
121
|
} catch (e: any) {
|
|
118
122
|
const msg = esc(e?.message || String(e))
|
|
119
123
|
send(res, 502, 'text/html', errorPage(
|
|
@@ -133,7 +137,7 @@ async function handleLocal(res: any, path: string, locale: 'zh' | 'en', inc: ()
|
|
|
133
137
|
inc()
|
|
134
138
|
try {
|
|
135
139
|
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, root)
|
|
136
|
-
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root, backLink:
|
|
140
|
+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root, backLink: SERVER_BASE }))
|
|
137
141
|
} finally {
|
|
138
142
|
dec()
|
|
139
143
|
}
|
|
@@ -176,7 +180,7 @@ async function handleUpload(req: any, res: any, locale: 'zh' | 'en', inc: () =>
|
|
|
176
180
|
}
|
|
177
181
|
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir)
|
|
178
182
|
const rootName = typeof payload.root === 'string' && payload.root ? payload.root : '(uploaded folder)'
|
|
179
|
-
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: rootName, backLink:
|
|
183
|
+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: rootName, backLink: SERVER_BASE }))
|
|
180
184
|
} catch (e: any) {
|
|
181
185
|
send(res, 500, 'text/html', errorPage('扫描失败:' + esc(e?.message || String(e))))
|
|
182
186
|
} finally {
|
|
@@ -226,7 +230,7 @@ function handleDemo(res: any, locale: 'zh' | 'en', inc: () => void, dec: () => v
|
|
|
226
230
|
writeFileSync(join(dir, '.env'),
|
|
227
231
|
'AWS_ACCESS_KEY=AKIARZ9MKP2QWLS7YV3N\nDB_PASSWORD=Sup3rS3cretProdPwd2026\n')
|
|
228
232
|
const { report, scan } = runProjectComplianceAudit(DEFAULT_CONFIG, dir)
|
|
229
|
-
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: '示例项目(含风险)/ demo-ai-app', backLink:
|
|
233
|
+
send(res, 200, 'text/html', renderHtmlReport(report, scan, locale, { root: '示例项目(含风险)/ demo-ai-app', backLink: SERVER_BASE }))
|
|
230
234
|
} catch (e: any) {
|
|
231
235
|
send(res, 500, 'text/html', errorPage('演示失败:' + esc(e?.message || String(e))))
|
|
232
236
|
} finally {
|
|
@@ -373,20 +377,26 @@ function page(title: string, body: string): string {
|
|
|
373
377
|
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;
|
|
374
378
|
background:linear-gradient(135deg,#eef1f6,#e2e8f0);color:#0f172a;
|
|
375
379
|
font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}
|
|
376
|
-
.hero{background:#fff;max-width:
|
|
377
|
-
box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center}
|
|
378
|
-
.logo{font-weight:800;font-size:15px}.logo span{color:#cb0000}
|
|
379
|
-
h1{font-size:
|
|
380
|
-
.sub{color:#64748b;margin:0 0
|
|
380
|
+
.hero{background:#fff;max-width:580px;width:92%;margin:40px;padding:38px 40px 34px;border-radius:18px;
|
|
381
|
+
box-shadow:0 12px 40px rgba(15,23,42,.12);text-align:center;border-top:4px solid #cb0000}
|
|
382
|
+
.logo{font-weight:800;font-size:15px;letter-spacing:.2px}.logo span{color:#cb0000}
|
|
383
|
+
h1{font-size:29px;margin:12px 0 8px;letter-spacing:-.5px}
|
|
384
|
+
.sub{color:#64748b;margin:0 0 24px;font-size:15px}
|
|
381
385
|
form{display:flex;flex-direction:column;gap:10px;text-align:left}
|
|
382
|
-
label{font-size:13px;font-weight:
|
|
386
|
+
label{font-size:13px;font-weight:700;color:#334155}
|
|
383
387
|
input{padding:14px 16px;border:1px solid #cbd5e1;border-radius:10px;font-size:16px;width:100%}
|
|
384
388
|
input:focus{outline:none;border-color:#cb0000;box-shadow:0 0 0 3px rgba(203,0,0,.12)}
|
|
385
|
-
.hint{font-size:12.5px;color:#64748b;margin:2px 0 6px}
|
|
389
|
+
.hint{font-size:12.5px;color:#64748b;margin:2px 0 6px;line-height:1.55}
|
|
386
390
|
.hint code{background:#f1f5f9;padding:1px 6px;border-radius:5px}
|
|
387
|
-
|
|
391
|
+
/* 文件选择器:美化成虚线投放区 + 红色按钮 */
|
|
392
|
+
input[type=file]{width:100%;padding:20px 16px;border:2px dashed #cbd5e1;border-radius:12px;
|
|
393
|
+
background:#f8fafc;cursor:pointer;font-size:14px;color:#64748b;transition:.15s}
|
|
394
|
+
input[type=file]:hover{border-color:#cb0000;background:#fff}
|
|
395
|
+
input[type=file]::file-selector-button{background:#cb0000;color:#fff;border:0;border-radius:8px;
|
|
396
|
+
padding:9px 18px;margin-right:14px;font-weight:700;font-size:14px;cursor:pointer}
|
|
397
|
+
input[type=file]::file-selector-button:hover{background:#a80000}
|
|
388
398
|
button{background:#cb0000;color:#fff;border:0;border-radius:10px;padding:14px;font-size:16px;
|
|
389
|
-
font-weight:700;cursor:pointer;margin-top:4px}button:hover{background:#a80000}
|
|
399
|
+
font-weight:700;cursor:pointer;margin-top:4px;transition:.15s}button:hover{background:#a80000}
|
|
390
400
|
button:disabled{background:#94a3b8;cursor:default}
|
|
391
401
|
form{margin:0 0 14px}.or{text-align:center;color:#94a3b8;font-size:13px;margin:6px 0 14px}
|
|
392
402
|
.status{display:none;margin:10px 0 0;padding:10px 14px;border-radius:8px;background:#f1f5f9;
|