ship-safe 6.4.0 → 8.0.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/README.md +80 -23
- package/cli/agents/agent-attestation-agent.js +318 -0
- package/cli/agents/agent-config-scanner.js +15 -0
- package/cli/agents/agentic-security-agent.js +35 -0
- package/cli/agents/cicd-scanner.js +22 -0
- package/cli/agents/config-auditor.js +235 -0
- package/cli/agents/deep-analyzer.js +39 -19
- package/cli/agents/hermes-security-agent.js +536 -0
- package/cli/agents/index.js +65 -21
- package/cli/agents/managed-agent-scanner.js +333 -0
- package/cli/agents/memory-poisoning-agent.js +304 -0
- package/cli/agents/scoring-engine.js +16 -1
- package/cli/agents/supply-chain-agent.js +129 -3
- package/cli/bin/ship-safe.js +178 -5
- package/cli/commands/audit.js +116 -2
- package/cli/commands/autofix.js +383 -0
- package/cli/commands/env-audit.js +349 -0
- package/cli/commands/live-advisories.js +241 -0
- package/cli/commands/red-team.js +2 -2
- package/cli/commands/scan-mcp.js +78 -0
- package/cli/commands/scan-skill.js +248 -5
- package/cli/commands/watch.js +205 -0
- package/cli/index.js +5 -0
- package/cli/providers/llm-provider.js +89 -1
- package/cli/utils/compliance-map.js +66 -0
- package/cli/utils/hermes-tool-registry.js +252 -0
- package/cli/utils/patterns.js +1 -0
- package/cli/utils/plugin-loader.js +276 -0
- package/cli/utils/scan-playbook.js +312 -0
- package/cli/utils/security-memory.js +296 -0
- package/package.json +2 -2
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autofix Command — Agentic Auto-Fix PRs
|
|
3
|
+
* ========================================
|
|
4
|
+
*
|
|
5
|
+
* Reads findings from a ship-safe report (or the last audit run), applies the
|
|
6
|
+
* LLM-generated fixes from Tier 3 deep analysis, commits them to a branch, and
|
|
7
|
+
* opens a GitHub pull request.
|
|
8
|
+
*
|
|
9
|
+
* Requires:
|
|
10
|
+
* - A report with deepAnalysis.fix fields (run with `--deep` flag)
|
|
11
|
+
* - Git repository
|
|
12
|
+
* - GitHub CLI (`gh`) installed for PR creation, OR GITHUB_TOKEN + repo info
|
|
13
|
+
*
|
|
14
|
+
* USAGE:
|
|
15
|
+
* npx ship-safe autofix Auto-fix from last report
|
|
16
|
+
* npx ship-safe autofix --report report.json Auto-fix from specific report
|
|
17
|
+
* npx ship-safe autofix --dry-run Preview fixes without applying
|
|
18
|
+
* npx ship-safe autofix --severity high Only fix critical+high findings
|
|
19
|
+
*
|
|
20
|
+
* SAFETY:
|
|
21
|
+
* - Never auto-commits secrets, config files, or .env
|
|
22
|
+
* - Always creates a new branch (never pushes to main/master/develop)
|
|
23
|
+
* - Dry-run mode shows a diff without writing any files
|
|
24
|
+
* - Each fix is applied atomically — if a file fails, others continue
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import fs from 'fs';
|
|
28
|
+
import path from 'path';
|
|
29
|
+
import { execFileSync, execSync } from 'child_process';
|
|
30
|
+
import chalk from 'chalk';
|
|
31
|
+
import * as output from '../utils/output.js';
|
|
32
|
+
|
|
33
|
+
// Severity rank for filtering
|
|
34
|
+
const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
35
|
+
|
|
36
|
+
// Files we never auto-edit (secrets, config, generated)
|
|
37
|
+
const NEVER_EDIT = [
|
|
38
|
+
/\.env(\.|$)/i,
|
|
39
|
+
/\.pem$|\.key$|\.p12$|\.pfx$/i,
|
|
40
|
+
/package-lock\.json$|yarn\.lock$|pnpm-lock\.yaml$/i,
|
|
41
|
+
/\.min\.(js|css)$/,
|
|
42
|
+
/node_modules\//,
|
|
43
|
+
/dist\//,
|
|
44
|
+
/build\//,
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// =============================================================================
|
|
48
|
+
// MAIN
|
|
49
|
+
// =============================================================================
|
|
50
|
+
|
|
51
|
+
export async function autofixCommand(options = {}) {
|
|
52
|
+
const rootPath = path.resolve(options.path || '.');
|
|
53
|
+
const dryRun = options.dryRun || false;
|
|
54
|
+
const minSev = options.severity || 'high';
|
|
55
|
+
const minRank = SEV_RANK[minSev] ?? 3;
|
|
56
|
+
const reportPath = options.report
|
|
57
|
+
? path.resolve(options.report)
|
|
58
|
+
: findLastReport(rootPath);
|
|
59
|
+
|
|
60
|
+
console.log();
|
|
61
|
+
output.header('Ship Safe — Agentic Autofix');
|
|
62
|
+
console.log();
|
|
63
|
+
|
|
64
|
+
if (!reportPath || !fs.existsSync(reportPath)) {
|
|
65
|
+
output.error('No report found. Run `npx ship-safe audit . --deep --json` first, or pass --report <path>.');
|
|
66
|
+
console.log(chalk.gray(' The --deep flag enables Tier 3 exploit-chain analysis that generates fix suggestions.'));
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Load Report ─────────────────────────────────────────────────────────
|
|
71
|
+
let report;
|
|
72
|
+
try {
|
|
73
|
+
report = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
output.error(`Failed to parse report: ${err.message}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const findings = report.findings ?? [];
|
|
80
|
+
console.log(chalk.gray(` Report: ${reportPath}`));
|
|
81
|
+
console.log(chalk.gray(` Total findings: ${findings.length}`));
|
|
82
|
+
console.log();
|
|
83
|
+
|
|
84
|
+
// ── Filter to fixable findings ──────────────────────────────────────────
|
|
85
|
+
const fixable = findings.filter(f => {
|
|
86
|
+
if (!f.deepAnalysis?.fix) return false;
|
|
87
|
+
if ((SEV_RANK[f.severity] ?? 0) < minRank) return false;
|
|
88
|
+
if (!f.file) return false;
|
|
89
|
+
const absFile = path.resolve(rootPath, f.file);
|
|
90
|
+
if (NEVER_EDIT.some(p => p.test(absFile.replace(/\\/g, '/')))) return false;
|
|
91
|
+
if (!fs.existsSync(absFile)) return false;
|
|
92
|
+
return true;
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (fixable.length === 0) {
|
|
96
|
+
console.log(chalk.yellow(` No fixable findings found at severity >= ${minSev}.`));
|
|
97
|
+
console.log(chalk.gray(' Tip: Run `npx ship-safe audit . --deep` to generate AI-powered fix suggestions.'));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
console.log(chalk.cyan(` Found ${fixable.length} fixable finding(s) at severity >= ${minSev}:`));
|
|
102
|
+
console.log();
|
|
103
|
+
|
|
104
|
+
for (const f of fixable) {
|
|
105
|
+
const sev = f.severity === 'critical' ? chalk.red.bold(f.severity)
|
|
106
|
+
: f.severity === 'high' ? chalk.yellow(f.severity)
|
|
107
|
+
: chalk.blue(f.severity);
|
|
108
|
+
console.log(` ${sev} ${chalk.white(f.title)}`);
|
|
109
|
+
console.log(` ${chalk.gray('File:')} ${f.file}${f.line ? `:${f.line}` : ''}`);
|
|
110
|
+
console.log(` ${chalk.gray('Fix:')} ${f.deepAnalysis.fix}`);
|
|
111
|
+
console.log();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
console.log(chalk.yellow(' Dry-run mode — no files will be changed.'));
|
|
116
|
+
console.log(chalk.gray(' Remove --dry-run to apply fixes and open a PR.'));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Confirm ──────────────────────────────────────────────────────────────
|
|
121
|
+
if (!options.yes) {
|
|
122
|
+
const { createInterface } = await import('readline');
|
|
123
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
124
|
+
const answer = await new Promise(resolve => {
|
|
125
|
+
rl.question(chalk.cyan(` Apply ${fixable.length} fix(es) and open a PR? [y/N] `), resolve);
|
|
126
|
+
});
|
|
127
|
+
rl.close();
|
|
128
|
+
if (!/^y/i.test(answer)) {
|
|
129
|
+
console.log(chalk.gray('\n Cancelled.\n'));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
console.log();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── Check git state ──────────────────────────────────────────────────────
|
|
136
|
+
if (!isGitRepo(rootPath)) {
|
|
137
|
+
output.error('Not a git repository. Autofix requires git.');
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const currentBranch = getCurrentBranch(rootPath);
|
|
142
|
+
const protectedBranches = ['main', 'master', 'develop', 'dev', 'production', 'staging'];
|
|
143
|
+
if (protectedBranches.includes(currentBranch)) {
|
|
144
|
+
// Work on a new branch instead
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
148
|
+
const branchName = `ship-safe/autofix-${timestamp}`;
|
|
149
|
+
|
|
150
|
+
console.log(chalk.gray(` Creating branch: ${branchName}`));
|
|
151
|
+
try {
|
|
152
|
+
execFileSync('git', ['checkout', '-b', branchName], { cwd: rootPath, stdio: 'pipe' });
|
|
153
|
+
} catch (err) {
|
|
154
|
+
output.error(`Failed to create branch: ${err.message}`);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── Apply fixes ──────────────────────────────────────────────────────────
|
|
159
|
+
const applied = [];
|
|
160
|
+
const failed = [];
|
|
161
|
+
|
|
162
|
+
for (const f of fixable) {
|
|
163
|
+
const absFile = path.resolve(rootPath, f.file);
|
|
164
|
+
const fix = f.deepAnalysis.fix;
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
applyInlineAnnotation(absFile, f.line, fix);
|
|
168
|
+
applied.push(f);
|
|
169
|
+
console.log(chalk.green(` ✔ Annotated: ${f.file}:${f.line ?? ''}`));
|
|
170
|
+
} catch (err) {
|
|
171
|
+
failed.push({ finding: f, error: err.message });
|
|
172
|
+
console.log(chalk.yellow(` ⚠ Skipped: ${f.file} — ${err.message}`));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (applied.length === 0) {
|
|
177
|
+
console.log(chalk.yellow('\n No files were changed. Cleaning up branch...'));
|
|
178
|
+
try {
|
|
179
|
+
execFileSync('git', ['checkout', currentBranch], { cwd: rootPath, stdio: 'pipe' });
|
|
180
|
+
execFileSync('git', ['branch', '-D', branchName], { cwd: rootPath, stdio: 'pipe' });
|
|
181
|
+
} catch { /* ignore cleanup errors */ }
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── Commit ───────────────────────────────────────────────────────────────
|
|
186
|
+
console.log(chalk.gray(`\n Committing ${applied.length} fix annotation(s)...`));
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
const filesToStage = [...new Set(applied.map(f => path.resolve(rootPath, f.file)))];
|
|
190
|
+
execFileSync('git', ['add', ...filesToStage], { cwd: rootPath, stdio: 'pipe' });
|
|
191
|
+
|
|
192
|
+
const commitMsg = [
|
|
193
|
+
`fix(security): apply ship-safe autofix annotations`,
|
|
194
|
+
'',
|
|
195
|
+
`Addresses ${applied.length} finding(s) from ship-safe audit:`,
|
|
196
|
+
...applied.map(f => `- ${f.severity.toUpperCase()}: ${f.title} (${f.file}${f.line ? `:${f.line}` : ''})`),
|
|
197
|
+
'',
|
|
198
|
+
'Fix suggestions generated by ship-safe Tier 3 (Opus) deep analysis.',
|
|
199
|
+
'Review each annotation and apply the suggested code change.',
|
|
200
|
+
].join('\n');
|
|
201
|
+
|
|
202
|
+
execFileSync('git', ['commit', '-m', commitMsg], { cwd: rootPath, stdio: 'pipe' });
|
|
203
|
+
console.log(chalk.green(' Committed.'));
|
|
204
|
+
} catch (err) {
|
|
205
|
+
output.error(`Commit failed: ${err.message}`);
|
|
206
|
+
// Restore branch state
|
|
207
|
+
try { execFileSync('git', ['checkout', currentBranch], { cwd: rootPath, stdio: 'pipe' }); } catch { /* ignore */ }
|
|
208
|
+
process.exit(1);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Push and open PR ─────────────────────────────────────────────────────
|
|
212
|
+
console.log(chalk.gray(' Pushing branch...'));
|
|
213
|
+
try {
|
|
214
|
+
execFileSync('git', ['push', '-u', 'origin', branchName], { cwd: rootPath, stdio: 'pipe' });
|
|
215
|
+
} catch (err) {
|
|
216
|
+
console.log(chalk.yellow(` Push failed: ${err.message}`));
|
|
217
|
+
console.log(chalk.gray(` Branch created locally: ${branchName}`));
|
|
218
|
+
console.log(chalk.gray(' Push manually with: git push -u origin ' + branchName));
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Open PR ───────────────────────────────────────────────────────────────
|
|
223
|
+
const prBody = buildPRBody(applied, failed, reportPath);
|
|
224
|
+
const prTitle = `fix(security): ship-safe autofix — ${applied.length} finding(s)`;
|
|
225
|
+
|
|
226
|
+
let prUrl = null;
|
|
227
|
+
const ghAvailable = isCommandAvailable('gh');
|
|
228
|
+
|
|
229
|
+
if (ghAvailable) {
|
|
230
|
+
try {
|
|
231
|
+
const result = execFileSync('gh', [
|
|
232
|
+
'pr', 'create',
|
|
233
|
+
'--title', prTitle,
|
|
234
|
+
'--body', prBody,
|
|
235
|
+
'--base', currentBranch,
|
|
236
|
+
'--head', branchName,
|
|
237
|
+
], { cwd: rootPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
238
|
+
prUrl = result.trim();
|
|
239
|
+
} catch (err) {
|
|
240
|
+
console.log(chalk.yellow(` gh pr create failed: ${err.stderr?.toString().trim() || err.message}`));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Summary ───────────────────────────────────────────────────────────────
|
|
245
|
+
console.log();
|
|
246
|
+
console.log(chalk.green.bold(` ✔ Autofix complete`));
|
|
247
|
+
console.log(` Applied: ${chalk.white(applied.length)} fix annotation(s)`);
|
|
248
|
+
if (failed.length > 0) console.log(` Skipped: ${chalk.yellow(failed.length)} (see above)`);
|
|
249
|
+
console.log(` Branch: ${chalk.cyan(branchName)}`);
|
|
250
|
+
if (prUrl) {
|
|
251
|
+
console.log(` PR: ${chalk.cyan(prUrl)}`);
|
|
252
|
+
} else {
|
|
253
|
+
console.log(chalk.gray(' Install `gh` (GitHub CLI) to auto-open pull requests.'));
|
|
254
|
+
}
|
|
255
|
+
console.log();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// =============================================================================
|
|
259
|
+
// HELPERS
|
|
260
|
+
// =============================================================================
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Apply the fix as a structured comment annotation above the finding line.
|
|
264
|
+
* The comment explains the issue and the fix — a developer reviews and applies.
|
|
265
|
+
* We never blindly rewrite code; instead we annotate so engineers make the call.
|
|
266
|
+
*/
|
|
267
|
+
/**
|
|
268
|
+
* Apply fix annotations to a list of findings in-place.
|
|
269
|
+
* Returns the count of files successfully annotated.
|
|
270
|
+
* Exported for use by the --agentic audit loop.
|
|
271
|
+
*/
|
|
272
|
+
export function applyInlineAnnotations(findings) {
|
|
273
|
+
const NEVER_EDIT = new Set(['.env', '.env.local', '.env.production', 'secrets.json', '.npmrc', '.netrc']);
|
|
274
|
+
const fixable = findings.filter(f =>
|
|
275
|
+
f.fix && f.file && fs.existsSync(f.file) && !NEVER_EDIT.has(path.basename(f.file))
|
|
276
|
+
);
|
|
277
|
+
let count = 0;
|
|
278
|
+
for (const f of fixable.slice(0, 10)) {
|
|
279
|
+
try {
|
|
280
|
+
applyInlineAnnotation(f.file, f.line, f.fix);
|
|
281
|
+
count++;
|
|
282
|
+
} catch { /* skip unwritable */ }
|
|
283
|
+
}
|
|
284
|
+
return count;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function applyInlineAnnotation(filePath, lineNum, fix) {
|
|
288
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
289
|
+
const lines = content.split('\n');
|
|
290
|
+
const idx = Math.max(0, (lineNum || 1) - 1);
|
|
291
|
+
|
|
292
|
+
if (idx >= lines.length) {
|
|
293
|
+
throw new Error(`Line ${lineNum} out of range`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Already annotated?
|
|
297
|
+
if (idx > 0 && /ship-safe-fix/i.test(lines[idx - 1])) return;
|
|
298
|
+
|
|
299
|
+
const indent = lines[idx].match(/^(\s*)/)?.[1] ?? '';
|
|
300
|
+
const isJs = /\.(js|ts|jsx|tsx|mjs|cjs|java|c|cpp|cs|go|rs|swift|kt)$/.test(filePath);
|
|
301
|
+
const isPy = /\.py$/.test(filePath);
|
|
302
|
+
|
|
303
|
+
// Wrap fix in a structured annotation comment
|
|
304
|
+
const fixLines = fix.split('\n').map(l => l.trim()).filter(Boolean);
|
|
305
|
+
let annotation;
|
|
306
|
+
|
|
307
|
+
if (isPy) {
|
|
308
|
+
annotation = [
|
|
309
|
+
`${indent}# ship-safe-fix [REVIEW REQUIRED]`,
|
|
310
|
+
...fixLines.map(l => `${indent}# ${l}`),
|
|
311
|
+
].join('\n');
|
|
312
|
+
} else {
|
|
313
|
+
annotation = [
|
|
314
|
+
`${indent}// ship-safe-fix [REVIEW REQUIRED]`,
|
|
315
|
+
...fixLines.map(l => `${indent}// ${l}`),
|
|
316
|
+
].join('\n');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
lines.splice(idx, 0, annotation);
|
|
320
|
+
fs.writeFileSync(filePath, lines.join('\n'), 'utf-8');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function buildPRBody(applied, failed, reportPath) {
|
|
324
|
+
const rows = applied.map(f =>
|
|
325
|
+
`| ${f.severity} | ${f.title} | \`${f.file}${f.line ? `:${f.line}` : ''}\` | ${f.deepAnalysis.fix?.slice(0, 80) ?? ''} |`
|
|
326
|
+
).join('\n');
|
|
327
|
+
|
|
328
|
+
return `## Security Autofix
|
|
329
|
+
|
|
330
|
+
Ship Safe detected **${applied.length}** security finding(s) and has annotated the affected files with fix instructions. Each annotation is marked \`// ship-safe-fix [REVIEW REQUIRED]\` — **please review and apply the suggested changes before merging.**
|
|
331
|
+
|
|
332
|
+
### Findings Fixed
|
|
333
|
+
|
|
334
|
+
| Severity | Title | Location | Fix Summary |
|
|
335
|
+
|----------|-------|----------|-------------|
|
|
336
|
+
${rows}
|
|
337
|
+
|
|
338
|
+
${failed.length > 0 ? `### Skipped (${failed.length})\n\n${failed.map(f => `- ${f.finding.title}: ${f.error}`).join('\n')}` : ''}
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
> Generated by [ship-safe](https://shipsafecli.com) — AI-powered security scanner.
|
|
343
|
+
> Run \`npx ship-safe audit . --deep\` to regenerate findings.
|
|
344
|
+
|
|
345
|
+
🤖 Generated with [Claude Code](https://claude.com/claude-code)`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function findLastReport(rootPath) {
|
|
349
|
+
// Look for common report filenames in order of preference
|
|
350
|
+
const candidates = [
|
|
351
|
+
'ship-safe-report.json',
|
|
352
|
+
'.ship-safe/last-report.json',
|
|
353
|
+
'security-report.json',
|
|
354
|
+
].map(f => path.join(rootPath, f));
|
|
355
|
+
|
|
356
|
+
return candidates.find(p => fs.existsSync(p)) ?? null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function isGitRepo(rootPath) {
|
|
360
|
+
try {
|
|
361
|
+
execFileSync('git', ['rev-parse', '--git-dir'], { cwd: rootPath, stdio: 'pipe' });
|
|
362
|
+
return true;
|
|
363
|
+
} catch {
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function getCurrentBranch(rootPath) {
|
|
369
|
+
try {
|
|
370
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootPath, encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
371
|
+
} catch {
|
|
372
|
+
return 'main';
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isCommandAvailable(cmd) {
|
|
377
|
+
try {
|
|
378
|
+
execFileSync(cmd, ['--version'], { stdio: 'pipe' });
|
|
379
|
+
return true;
|
|
380
|
+
} catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|