clawarmor 3.1.0 → 3.4.0
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/CHANGELOG.md +147 -0
- package/README.md +49 -1
- package/clawgear-skills/clawarmor-live-monitor/SKILL.md +120 -0
- package/clawgear-skills/hardened-operator-baseline/SKILL.md +172 -0
- package/clawgear-skills/incident-response-playbook/SKILL.md +189 -0
- package/clawgear-skills/skill-security-scanner/SKILL.md +170 -0
- package/cli.js +65 -3
- package/lib/audit-quiet.js +89 -0
- package/lib/baseline-cmd.js +189 -0
- package/lib/baseline.js +106 -0
- package/lib/harden.js +225 -11
- package/lib/incident-cmd.js +201 -0
- package/lib/invariant-sync.js +668 -0
- package/lib/scan.js +88 -17
- package/lib/skill-verify.js +259 -0
- package/package.json +1 -1
package/lib/scan.js
CHANGED
|
@@ -16,28 +16,40 @@ function box(title) {
|
|
|
16
16
|
paint.dim('╚'+'═'.repeat(W-2)+'╝')].join('\n');
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export async function runScan() {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
console.log('');
|
|
19
|
+
export async function runScan(flags = {}) {
|
|
20
|
+
const jsonMode = flags.json || false;
|
|
21
|
+
|
|
22
|
+
if (!jsonMode) {
|
|
23
|
+
console.log(''); console.log(box('ClawArmor Skill Scan v0.6')); console.log('');
|
|
24
|
+
console.log(` ${paint.dim('Scanning:')} Installed OpenClaw skills (code + SKILL.md)`);
|
|
25
|
+
console.log(` ${paint.dim('Started:')} ${new Date().toLocaleString('en-US',{dateStyle:'medium',timeStyle:'short'})}`);
|
|
26
|
+
console.log('');
|
|
27
|
+
}
|
|
24
28
|
|
|
25
29
|
const skills = findInstalledSkills();
|
|
26
30
|
if (!skills.length) {
|
|
27
|
-
|
|
31
|
+
if (jsonMode) {
|
|
32
|
+
process.stdout.write(JSON.stringify({ verdict: 'PASS', score: 100, totalSkills: 0, flaggedSkills: 0, findings: [], scannedAt: new Date().toISOString() }, null, 2) + '\n');
|
|
33
|
+
} else {
|
|
34
|
+
console.log(` ${paint.dim('No installed skills found.')}`); console.log('');
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
const userSkills = skills.filter(s => !s.isBuiltin);
|
|
31
40
|
const builtinSkills = skills.filter(s => s.isBuiltin);
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
if (!jsonMode) {
|
|
42
|
+
console.log(` ${paint.dim('Found')} ${paint.bold(String(skills.length))} ${paint.dim('skills')} ${paint.dim(`(${userSkills.length} user-installed, ${builtinSkills.length} built-in)`)}`);
|
|
43
|
+
console.log('');
|
|
44
|
+
}
|
|
34
45
|
|
|
35
46
|
let totalCritical = 0, totalHigh = 0;
|
|
36
47
|
const flagged = [];
|
|
37
48
|
const auditFindings = []; // accumulated for audit log
|
|
49
|
+
const jsonFindings = []; // for --json output
|
|
38
50
|
|
|
39
51
|
for (const skill of skills) {
|
|
40
|
-
process.stdout.write(` ${skill.isBuiltin ? paint.dim('⊙') : paint.cyan('▶')} ${paint.bold(skill.name)}${paint.dim(skill.isBuiltin?' [built-in]':' [user]')}...`);
|
|
52
|
+
if (!jsonMode) process.stdout.write(` ${skill.isBuiltin ? paint.dim('⊙') : paint.cyan('▶')} ${paint.bold(skill.name)}${paint.dim(skill.isBuiltin?' [built-in]':' [user]')}...`);
|
|
41
53
|
|
|
42
54
|
// Code findings (JS, py, sh, etc.)
|
|
43
55
|
const codeFindings = skill.files.flatMap(f => scanFile(f, skill.isBuiltin));
|
|
@@ -56,21 +68,80 @@ export async function runScan() {
|
|
|
56
68
|
|
|
57
69
|
totalCritical += critical.length; totalHigh += high.length;
|
|
58
70
|
|
|
59
|
-
|
|
71
|
+
// Collect findings for JSON output
|
|
72
|
+
for (const f of allFindings) {
|
|
73
|
+
if (['CRITICAL','HIGH','MEDIUM','LOW'].includes(f.severity)) {
|
|
74
|
+
for (const m of (f.matches || [])) {
|
|
75
|
+
jsonFindings.push({
|
|
76
|
+
skill: skill.name,
|
|
77
|
+
severity: f.severity,
|
|
78
|
+
patternId: f.patternId || f.id || 'unknown',
|
|
79
|
+
message: f.title || f.description || '',
|
|
80
|
+
file: (f.file || '').replace(HOME, '~'),
|
|
81
|
+
line: m.line,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (!(f.matches && f.matches.length)) {
|
|
85
|
+
jsonFindings.push({
|
|
86
|
+
skill: skill.name,
|
|
87
|
+
severity: f.severity,
|
|
88
|
+
patternId: f.patternId || f.id || 'unknown',
|
|
89
|
+
message: f.title || f.description || '',
|
|
90
|
+
file: (f.file || '').replace(HOME, '~'),
|
|
91
|
+
line: null,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!jsonMode) {
|
|
98
|
+
if (!allFindings.length) { process.stdout.write(` ${paint.green('✓ clean')}\n`); continue; }
|
|
60
99
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
100
|
+
const parts = [];
|
|
101
|
+
if (critical.length) parts.push(paint.red(`${critical.length} critical`));
|
|
102
|
+
if (high.length) parts.push(paint.yellow(`${high.length} high`));
|
|
103
|
+
if (medium.length) parts.push(paint.cyan(`${medium.length} medium`));
|
|
104
|
+
if (info.length) parts.push(paint.dim(`${info.length} info`));
|
|
105
|
+
process.stdout.write(` ${parts.join(', ')}\n`);
|
|
106
|
+
}
|
|
67
107
|
|
|
68
108
|
if (critical.length || high.length || medium.length) {
|
|
69
109
|
flagged.push({ skill, codeFindings, mdResults });
|
|
70
110
|
}
|
|
71
111
|
}
|
|
72
112
|
|
|
73
|
-
//
|
|
113
|
+
// JSON output mode
|
|
114
|
+
if (jsonMode) {
|
|
115
|
+
// Compute scan score: start 100, -25 per CRITICAL, -10 per HIGH, -3 per MEDIUM
|
|
116
|
+
let scanScore = 100;
|
|
117
|
+
for (const f of jsonFindings) {
|
|
118
|
+
if (f.severity === 'CRITICAL') scanScore -= 25;
|
|
119
|
+
else if (f.severity === 'HIGH') scanScore -= 10;
|
|
120
|
+
else if (f.severity === 'MEDIUM') scanScore -= 3;
|
|
121
|
+
}
|
|
122
|
+
scanScore = Math.max(0, scanScore);
|
|
123
|
+
|
|
124
|
+
let verdict = 'PASS';
|
|
125
|
+
if (totalCritical > 0) verdict = 'BLOCK';
|
|
126
|
+
else if (totalHigh > 0) verdict = 'WARN';
|
|
127
|
+
|
|
128
|
+
const output = {
|
|
129
|
+
verdict,
|
|
130
|
+
score: scanScore,
|
|
131
|
+
totalSkills: skills.length,
|
|
132
|
+
flaggedSkills: flagged.length,
|
|
133
|
+
findings: jsonFindings,
|
|
134
|
+
scannedAt: new Date().toISOString(),
|
|
135
|
+
};
|
|
136
|
+
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
137
|
+
|
|
138
|
+
auditLogAppend({ cmd: 'scan', trigger: 'manual', score: null, delta: null,
|
|
139
|
+
findings: auditFindings, blocked: null, skill: null });
|
|
140
|
+
|
|
141
|
+
return totalCritical > 0 ? 1 : 0;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Detailed human report for flagged skills
|
|
74
145
|
for (const {skill, codeFindings, mdResults} of flagged) {
|
|
75
146
|
console.log(''); console.log(SEP);
|
|
76
147
|
console.log(` ${paint.bold(skill.name)} ${paint.dim(short(skill.path))}`);
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// ClawArmor skill verify — check a skill directory against ClawGear publishing standards.
|
|
2
|
+
// Usage: clawarmor skill verify <skill-dir>
|
|
3
|
+
//
|
|
4
|
+
// Exit codes: 0=VERIFIED, 1=WARN, 2=BLOCK
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
7
|
+
import { join, extname, basename } from 'path';
|
|
8
|
+
import { paint } from './output/colors.js';
|
|
9
|
+
import { scanFile } from './scanner/file-scanner.js';
|
|
10
|
+
|
|
11
|
+
const SEP = '━'.repeat(52);
|
|
12
|
+
|
|
13
|
+
// Well-known API hosts that are allowed without flagging
|
|
14
|
+
const KNOWN_HOSTS = new Set([
|
|
15
|
+
'github.com', 'api.github.com', 'raw.githubusercontent.com',
|
|
16
|
+
'api.anthropic.com',
|
|
17
|
+
'api.openai.com',
|
|
18
|
+
'registry.npmjs.org',
|
|
19
|
+
'api.cloudflare.com',
|
|
20
|
+
'api.stripe.com',
|
|
21
|
+
'hooks.slack.com',
|
|
22
|
+
'discord.com', 'discordapp.com',
|
|
23
|
+
'api.telegram.org',
|
|
24
|
+
'clawhub.com', 'shopclawmart.com', 'clawgear.io',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Patterns for hardcoded credentials (value, not variable reference)
|
|
28
|
+
const CRED_PATTERNS = [
|
|
29
|
+
/api_key\s*=\s*["'][^${\s"']{8,}/i,
|
|
30
|
+
/token\s*=\s*["'][^${\s"']{8,}/i,
|
|
31
|
+
/password\s*=\s*["'][^${\s"']{4,}/i,
|
|
32
|
+
/secret\s*=\s*["'][^${\s"']{8,}/i,
|
|
33
|
+
/api_key\s*:\s*["'][^${\s"']{8,}/i,
|
|
34
|
+
/token\s*:\s*["'][^${\s"']{8,}/i,
|
|
35
|
+
/password\s*:\s*["'][^${\s"']{4,}/i,
|
|
36
|
+
/secret\s*:\s*["'][^${\s"']{8,}/i,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Patterns for elevated/exec commands
|
|
40
|
+
const EXEC_PATTERNS = [
|
|
41
|
+
/\bexec\b/,
|
|
42
|
+
/child_process/,
|
|
43
|
+
/subprocess/,
|
|
44
|
+
/\bsudo\b/,
|
|
45
|
+
/\bchmod\b/,
|
|
46
|
+
/\bchown\b/,
|
|
47
|
+
/execSync|execFile|spawnSync/,
|
|
48
|
+
/\bsh\s+-c\b/,
|
|
49
|
+
/\bbash\s+-c\b/,
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// Patterns for external network calls
|
|
53
|
+
const FETCH_PATTERNS = [
|
|
54
|
+
/\bfetch\s*\(\s*["'`]https?:\/\/([^/"'`\s]+)/gi,
|
|
55
|
+
/\bcurl\s+(?:-\S+\s+)*["']?https?:\/\/([^"'\s]+)/gi,
|
|
56
|
+
/\bwget\s+(?:-\S+\s+)*["']?https?:\/\/([^"'\s]+)/gi,
|
|
57
|
+
/new\s+URL\s*\(\s*["'`]https?:\/\/([^/"'`\s]+)/gi,
|
|
58
|
+
/axios\.\w+\s*\(\s*["'`]https?:\/\/([^/"'`\s]+)/gi,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const SCANNABLE_EXTS = new Set(['js', 'ts', 'sh', 'py', 'rb', 'md']);
|
|
62
|
+
|
|
63
|
+
function collectFiles(dir) {
|
|
64
|
+
const files = [];
|
|
65
|
+
try {
|
|
66
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
67
|
+
if (entry.name.startsWith('.')) continue;
|
|
68
|
+
const full = join(dir, entry.name);
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
files.push(...collectFiles(full));
|
|
71
|
+
} else {
|
|
72
|
+
files.push(full);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
} catch { /* ignore unreadable dirs */ }
|
|
76
|
+
return files;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function readSafe(filePath) {
|
|
80
|
+
try { return readFileSync(filePath, 'utf8'); }
|
|
81
|
+
catch { return ''; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function extractHosts(content) {
|
|
85
|
+
const hosts = [];
|
|
86
|
+
for (const pattern of FETCH_PATTERNS) {
|
|
87
|
+
const re = new RegExp(pattern.source, 'gi');
|
|
88
|
+
let m;
|
|
89
|
+
while ((m = re.exec(content)) !== null) {
|
|
90
|
+
const host = m[1].split('/')[0].split('?')[0];
|
|
91
|
+
if (host) hosts.push(host);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...new Set(hosts)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Main skill verify command.
|
|
99
|
+
* @param {string} skillDir - path to skill directory
|
|
100
|
+
* @returns {Promise<number>} exit code: 0=VERIFIED, 1=WARN, 2=BLOCK
|
|
101
|
+
*/
|
|
102
|
+
export async function runSkillVerify(skillDir) {
|
|
103
|
+
if (!skillDir) {
|
|
104
|
+
console.log(` Usage: clawarmor skill verify <skill-dir>`);
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const resolvedDir = skillDir.replace(/^~/, process.env.HOME || '');
|
|
109
|
+
|
|
110
|
+
if (!existsSync(resolvedDir)) {
|
|
111
|
+
console.log(` ${paint.red('✗')} Directory not found: ${skillDir}`);
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const skillName = basename(resolvedDir);
|
|
116
|
+
const files = collectFiles(resolvedDir);
|
|
117
|
+
const codeFiles = files.filter(f => {
|
|
118
|
+
const ext = extname(f).replace('.', '').toLowerCase();
|
|
119
|
+
return SCANNABLE_EXTS.has(ext);
|
|
120
|
+
});
|
|
121
|
+
const skillMdPath = join(resolvedDir, 'SKILL.md');
|
|
122
|
+
|
|
123
|
+
console.log('');
|
|
124
|
+
console.log(` ${paint.bold('ClawArmor Skill Verify')} ${paint.dim('—')} ${paint.cyan(skillName)}`);
|
|
125
|
+
console.log(` ${SEP}`);
|
|
126
|
+
|
|
127
|
+
const checks = [];
|
|
128
|
+
let hasWarn = false;
|
|
129
|
+
let hasBlock = false;
|
|
130
|
+
|
|
131
|
+
// ── Check 1: SKILL.md exists ────────────────────────────────────────────────
|
|
132
|
+
const skillMdExists = existsSync(skillMdPath);
|
|
133
|
+
if (skillMdExists) {
|
|
134
|
+
checks.push({ icon: '✅', label: 'SKILL.md present' });
|
|
135
|
+
} else {
|
|
136
|
+
checks.push({ icon: '❌', label: 'SKILL.md missing — required for ClawGear publishing', severity: 'BLOCK' });
|
|
137
|
+
hasBlock = true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const skillMdContent = skillMdExists ? readSafe(skillMdPath) : '';
|
|
141
|
+
const allContent = files.map(f => ({ path: f, content: readSafe(f) }));
|
|
142
|
+
|
|
143
|
+
// ── Check 2: No hardcoded credentials ───────────────────────────────────────
|
|
144
|
+
let credFound = false;
|
|
145
|
+
let credDetails = null;
|
|
146
|
+
for (const { path: fp, content } of allContent) {
|
|
147
|
+
const ext = extname(fp).replace('.', '').toLowerCase();
|
|
148
|
+
if (!SCANNABLE_EXTS.has(ext)) continue;
|
|
149
|
+
for (const pattern of CRED_PATTERNS) {
|
|
150
|
+
if (pattern.test(content)) {
|
|
151
|
+
credFound = true;
|
|
152
|
+
credDetails = `${fp.replace(process.env.HOME || '', '~')}`;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (credFound) break;
|
|
157
|
+
}
|
|
158
|
+
if (credFound) {
|
|
159
|
+
checks.push({ icon: '❌', label: `Hardcoded credentials found in ${credDetails}`, severity: 'BLOCK' });
|
|
160
|
+
hasBlock = true;
|
|
161
|
+
} else {
|
|
162
|
+
checks.push({ icon: '✅', label: 'No hardcoded credentials' });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Check 3: No obfuscation ──────────────────────────────────────────────────
|
|
166
|
+
let obfuscFound = false;
|
|
167
|
+
for (const fp of codeFiles) {
|
|
168
|
+
const findings = scanFile(fp, false);
|
|
169
|
+
const serious = findings.filter(f => ['CRITICAL', 'HIGH'].includes(f.severity));
|
|
170
|
+
if (serious.length) {
|
|
171
|
+
obfuscFound = true;
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (obfuscFound) {
|
|
176
|
+
checks.push({ icon: '❌', label: 'Obfuscation or malicious patterns detected — run clawarmor scan for details', severity: 'BLOCK' });
|
|
177
|
+
hasBlock = true;
|
|
178
|
+
} else {
|
|
179
|
+
checks.push({ icon: '✅', label: 'No obfuscation patterns' });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Check 4: Permissions declared if exec commands found ────────────────────
|
|
183
|
+
let execFound = false;
|
|
184
|
+
for (const { path: fp, content } of allContent) {
|
|
185
|
+
const ext = extname(fp).replace('.', '').toLowerCase();
|
|
186
|
+
if (!SCANNABLE_EXTS.has(ext)) continue;
|
|
187
|
+
for (const p of EXEC_PATTERNS) {
|
|
188
|
+
if (p.test(content)) {
|
|
189
|
+
execFound = true;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (execFound) break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (execFound) {
|
|
197
|
+
const permsDeclared = /\b(requires|permissions|elevated)\b/i.test(skillMdContent);
|
|
198
|
+
if (permsDeclared) {
|
|
199
|
+
checks.push({ icon: '✅', label: 'Exec commands found — permissions declared in SKILL.md' });
|
|
200
|
+
} else {
|
|
201
|
+
checks.push({ icon: '⚠️ ', label: 'Exec commands found — verify permissions are declared in SKILL.md', severity: 'WARN' });
|
|
202
|
+
hasWarn = true;
|
|
203
|
+
}
|
|
204
|
+
} else {
|
|
205
|
+
checks.push({ icon: '✅', label: 'No elevated exec commands' });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Check 5: No external fetch to unknown hosts ─────────────────────────────
|
|
209
|
+
const unknownHosts = [];
|
|
210
|
+
for (const { content } of allContent) {
|
|
211
|
+
for (const host of extractHosts(content)) {
|
|
212
|
+
// Strip port
|
|
213
|
+
const cleanHost = host.split(':')[0];
|
|
214
|
+
if (!KNOWN_HOSTS.has(cleanHost)) {
|
|
215
|
+
unknownHosts.push(cleanHost);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const uniqueUnknown = [...new Set(unknownHosts)];
|
|
220
|
+
if (uniqueUnknown.length) {
|
|
221
|
+
checks.push({ icon: '⚠️ ', label: `External fetch to unknown host(s): ${uniqueUnknown.join(', ')}`, severity: 'WARN' });
|
|
222
|
+
hasWarn = true;
|
|
223
|
+
} else {
|
|
224
|
+
checks.push({ icon: '✅', label: 'No unknown external hosts' });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Check 6: Description present in SKILL.md frontmatter ────────────────────
|
|
228
|
+
const descPresent = /^description\s*:/m.test(skillMdContent);
|
|
229
|
+
if (descPresent) {
|
|
230
|
+
checks.push({ icon: '✅', label: 'Description present in SKILL.md' });
|
|
231
|
+
} else {
|
|
232
|
+
checks.push({ icon: '⚠️ ', label: 'No description: field in SKILL.md frontmatter', severity: 'WARN' });
|
|
233
|
+
hasWarn = true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Print results ────────────────────────────────────────────────────────────
|
|
237
|
+
for (const c of checks) {
|
|
238
|
+
console.log(` ${c.icon} ${c.severity === 'BLOCK' ? paint.red(c.label) : c.severity === 'WARN' ? paint.yellow(c.label) : paint.dim(c.label)}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
console.log('');
|
|
242
|
+
|
|
243
|
+
const advisories = checks.filter(c => c.severity === 'WARN').length;
|
|
244
|
+
const blocks = checks.filter(c => c.severity === 'BLOCK').length;
|
|
245
|
+
|
|
246
|
+
if (hasBlock) {
|
|
247
|
+
console.log(` ${paint.bold('Verdict:')} ${paint.red('❌ BLOCK')} ${paint.dim('— ' + blocks + ' blocking issue' + (blocks > 1 ? 's' : ''))}`);
|
|
248
|
+
console.log('');
|
|
249
|
+
return 2;
|
|
250
|
+
} else if (hasWarn) {
|
|
251
|
+
console.log(` ${paint.bold('Verdict:')} ${paint.yellow('⚠️ WARN')} ${paint.dim('— ' + advisories + ' advisor' + (advisories > 1 ? 'ies' : 'y'))}`);
|
|
252
|
+
console.log('');
|
|
253
|
+
return 1;
|
|
254
|
+
} else {
|
|
255
|
+
console.log(` ${paint.bold('Verdict:')} ${paint.green('✅ VERIFIED')}`);
|
|
256
|
+
console.log('');
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
}
|