nsauditor-ai 0.2.55 → 0.2.57
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 +76 -62
- package/cli.mjs +210 -179
- package/mcp_server.mjs +71 -31
- package/package.json +1 -1
- package/plugin_manager.mjs +49 -8
- package/plugins/040_tls_cert_auditor.mjs +176 -56
- package/plugins/050_tribe_health.mjs +13 -34
- package/plugins/060_dns_sec_auditor.mjs +24 -30
- package/plugins/dns_scanner.mjs +16 -8
- package/plugins/ftp_banner_check.mjs +21 -13
- package/plugins/host_up_check.mjs +31 -72
- package/plugins/http_probe.mjs +93 -25
- package/plugins/mcp_scanner.mjs +15 -1
- package/plugins/netbios_scanner.mjs +32 -13
- package/plugins/opensearch_scanner.mjs +4 -0
- package/plugins/port_scanner.mjs +29 -49
- package/plugins/result_concluder.mjs +122 -30
- package/plugins/snmp_scanner.mjs +20 -8
- package/plugins/syn_scanner.mjs +2 -1
- package/plugins/upnp_scanner.mjs +82 -13
- package/plugins/webapp_detector.mjs +82 -15
- package/utils/conclusion_utils.mjs +13 -1
- package/utils/cookie_redaction.mjs +50 -0
- package/utils/delta_reporter.mjs +70 -25
- package/utils/ee_load.mjs +45 -0
- package/utils/executive_report.mjs +48 -8
- package/utils/export_csv.mjs +14 -22
- package/utils/mcp_call_digest.mjs +31 -0
- package/utils/net_validation.mjs +107 -37
- package/utils/output_dir.mjs +70 -0
- package/utils/plugin_discovery.mjs +4 -2
- package/utils/ports_spec.mjs +46 -0
- package/utils/report_inputs.mjs +64 -1
- package/utils/report_md.mjs +51 -112
- package/utils/sarif.mjs +33 -93
- package/utils/scan_delta.mjs +514 -38
- package/utils/scan_delta_view.mjs +5 -3
- package/utils/scan_history.mjs +149 -0
- package/utils/scheduler.mjs +17 -5
- package/utils/service_flags.mjs +363 -0
- package/utils/watch_cycle.mjs +37 -0
- package/utils/webhook.mjs +2 -1
package/mcp_server.mjs
CHANGED
|
@@ -12,11 +12,12 @@ import { dirname, join } from 'node:path';
|
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { homedir } from 'node:os';
|
|
14
14
|
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { responseDigest, RECEIPT_MARKER } from './utils/mcp_call_digest.mjs';
|
|
15
16
|
import { appendFile, mkdir, chmod } from 'node:fs/promises';
|
|
16
17
|
|
|
17
18
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
19
|
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
19
|
-
import { resolveAndValidate } from './utils/net_validation.mjs';
|
|
20
|
+
import { resolveAndValidate, classifyAddress, canonicalIp, allowAllHosts } from './utils/net_validation.mjs';
|
|
20
21
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
21
22
|
import {
|
|
22
23
|
CallToolRequestSchema,
|
|
@@ -175,10 +176,11 @@ export function buildScanCloudRegionIntent(regions) {
|
|
|
175
176
|
// 1. Embedded in the response text (Claude cannot omit; it's the payload)
|
|
176
177
|
// 2. Persisted to ~/.nsauditor/mcp-calls.log (append-only, mode 0600)
|
|
177
178
|
//
|
|
178
|
-
// Customer verification:
|
|
179
|
-
// nsauditor-ai mcp verify-call <uuid>
|
|
180
|
-
//
|
|
181
|
-
//
|
|
179
|
+
// Customer verification: save the response (footer included) and run
|
|
180
|
+
// nsauditor-ai mcp verify-call <uuid> --response <file>
|
|
181
|
+
// The id alone proves only that this server issued it once — an AI client that has seen an earlier response can paste
|
|
182
|
+
// a real id under a fabricated one. So the server also logs a digest of each response BODY (utils/mcp_call_digest.mjs),
|
|
183
|
+
// and verify-call recomputes it from the saved text (1.3.0 (s6)). See cli.mjs `verify-call`.
|
|
182
184
|
const MCP_CALL_LOG_PATH = join(homedir(), '.nsauditor', 'mcp-calls.log');
|
|
183
185
|
|
|
184
186
|
async function recordToolCall(toolName) {
|
|
@@ -200,11 +202,20 @@ async function recordToolCall(toolName) {
|
|
|
200
202
|
}
|
|
201
203
|
|
|
202
204
|
function appendCallSentinel(text, callId) {
|
|
205
|
+
// The digest of the BODY — the text the receipt is appended to — is what binds this response to its id. It is
|
|
206
|
+
// logged, never printed: a digest in the footer would replay together with the id. Best-effort like the id line: a
|
|
207
|
+
// failed write leaves the call verifiable only as "issued", never as a false ✓.
|
|
208
|
+
try {
|
|
209
|
+
_nodeFs.appendFileSync(MCP_CALL_LOG_PATH, JSON.stringify({ call_id: callId, digest: responseDigest(text) }) + '\n', { encoding: 'utf8' });
|
|
210
|
+
} catch (err) {
|
|
211
|
+
process.stderr.write(`[nsauditor-mcp] call-log digest write failed: ${err.message}\n`);
|
|
212
|
+
}
|
|
203
213
|
return (
|
|
204
|
-
`${text}\n\n
|
|
214
|
+
`${text}\n\n${RECEIPT_MARKER}\n` +
|
|
205
215
|
`call_id: ${callId}\n` +
|
|
206
|
-
`
|
|
207
|
-
`
|
|
216
|
+
`To check that this exact response came from this server, save the whole response (footer included)\n` +
|
|
217
|
+
`to a file and run:\n` +
|
|
218
|
+
` nsauditor-ai mcp verify-call ${callId} --response <file>`
|
|
208
219
|
);
|
|
209
220
|
}
|
|
210
221
|
|
|
@@ -244,7 +255,23 @@ export const TOOLS = [
|
|
|
244
255
|
{
|
|
245
256
|
name: 'scan_host',
|
|
246
257
|
description:
|
|
247
|
-
'Run a full plugin scan on a target host and return structured results
|
|
258
|
+
'Run a full plugin scan on a target host and return structured results: service detection, OS fingerprinting, and'
|
|
259
|
+
+ ' the flags the service checks leave on each service record — weak SSH algorithms, SNMP default community, weak '
|
|
260
|
+
+ 'TLS protocols / ciphers and a self-signed certificate, the MCP server checks, and anonymous FTP login and DNS '
|
|
261
|
+
+ 'zone transfer only when the server\'s environment enables them (FTP_CHECK_ANON; DNS_CHECK_AXFR with '
|
|
262
|
+
+ 'DNS_AXFR_DOMAIN — both off by default). The records also carry the HTTP probe\'s methods (006, dangerousMethods; '
|
|
263
|
+
+ 'methodsTested false is not tested, never "none"), the NetBIOS/SMB null-session check (014, nullSessionAllowed; '
|
|
264
|
+
+ 'opt-in, SMB_NULL_SESSION) and the TLS-certificate (040, certAudit) and debug-endpoint (050, tribeHealth) audits. For a domain-name target the DNS-security audit (060, dnsSecurity) lands '
|
|
265
|
+
+ 'on a 53/udp record if the scan found one, else in the conclusion\'s evidence; an IP-address target is DECLINED '
|
|
266
|
+
+ '(manifest: skipped, with the reason; markdown: NOT TESTED), so DNS posture is unknown, not clean. Opt-in checks carry '
|
|
267
|
+
+ 'axfrTested / anonymousLoginTested / nullSessionTested; a null result is NOT TESTED, never "refused". Enterprise\'s '
|
|
268
|
+
+ 'zero-trust assessment (1023) reaches the conclusion only as one score line in its evidence, not its '
|
|
269
|
+
+ 'per-dimension findings. It does NOT look up '
|
|
270
|
+
+ 'CVEs and does NOT run the Enterprise analysis agents or exploit intelligence — so zero findings here is NOT a '
|
|
271
|
+
+ 'statement that the host has no known vulnerabilities. For CVEs, call get_vulnerabilities (Pro) with each '
|
|
272
|
+
+ 'service\'s cpe where it names a concrete version; a service whose cpe is null gets no lookup, so its CVE coverage'
|
|
273
|
+
+ ' is unknown, not clean. The CLI scan (Enterprise package, Pro or Enterprise licence) runs the CVE mapper, the '
|
|
274
|
+
+ 'analysis agents and, with a KEV / EPSS store, exploit intelligence.',
|
|
248
275
|
inputSchema: {
|
|
249
276
|
type: 'object',
|
|
250
277
|
properties: {
|
|
@@ -335,7 +362,7 @@ export const TOOLS = [
|
|
|
335
362
|
},
|
|
336
363
|
pluginName: {
|
|
337
364
|
type: 'string',
|
|
338
|
-
description: 'Plugin
|
|
365
|
+
description: 'Plugin ID (e.g. "002") or its full name as list_plugins returns it, in any case (e.g. "SSH Scanner")',
|
|
339
366
|
},
|
|
340
367
|
},
|
|
341
368
|
required: ['host', 'port', 'pluginName'],
|
|
@@ -379,38 +406,48 @@ export const TOOLS = [
|
|
|
379
406
|
|
|
380
407
|
/**
|
|
381
408
|
* Validate host to prevent SSRF via loopback, link-local, or cloud metadata.
|
|
382
|
-
*
|
|
409
|
+
*
|
|
410
|
+
* A literal is classified by VALUE in any spelling (net_validation.classifyAddress); a name is
|
|
411
|
+
* resolved and EVERY answer is classified — in both arms of NSA_ALLOW_ALL_HOSTS. Allow-all admits
|
|
412
|
+
* private ranges only: loopback, unspecified, link-local and metadata stay refused, by literal or
|
|
413
|
+
* by answer. A name that does not resolve is refused in both arms.
|
|
414
|
+
*
|
|
415
|
+
* ⚠️ LIMIT (stated, 1.3.0): this does NOT pin the resolved address. It returns the NAME, and each
|
|
416
|
+
* plugin resolves it again when it connects, so a name whose answer changes between this check
|
|
417
|
+
* and the scan (DNS rebinding) is not caught. Pinning is boarded for a later release; it is not in 1.3.0.
|
|
418
|
+
* Before 1.3.0 this comment said the resolution "defeats rebinding"; it never did.
|
|
383
419
|
* @param {string} host
|
|
384
420
|
* @returns {Promise<string>} normalised hostname
|
|
385
421
|
*/
|
|
386
422
|
export async function validateHost(host) {
|
|
387
423
|
const h = String(host).trim().toLowerCase();
|
|
388
424
|
if (!h) throw new Error('Empty host');
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
// and by the DNS resolveAndValidate() layer for all encoding forms.
|
|
425
|
+
const notAllowed = () => new Error('Scanning loopback, link-local, or metadata addresses is not allowed via MCP');
|
|
426
|
+
// Cheap pre-check for the obvious spellings; the classifier below is the authority.
|
|
392
427
|
const _isAllDigits = /^\d+$/.test(h);
|
|
393
428
|
const _n = _isAllDigits && h.length <= 10 ? Number(h) : -1;
|
|
394
429
|
const isDecimalLoopback = _n >= 0x7F000000 && _n <= 0x7FFFFFFF;
|
|
395
430
|
if (isDecimalLoopback || /^(localhost|127\.|0\.|::1|0\.0\.0\.0|169\.254\.|fe80:|metadata\.google)/i.test(h)) {
|
|
396
|
-
throw
|
|
431
|
+
throw notAllowed();
|
|
397
432
|
}
|
|
398
433
|
|
|
399
434
|
// Cloud-sentinel hosts (aws/gcp/azure) are scoping tokens, NOT DNS names — they
|
|
400
435
|
// route to the cloud-scanner plugins (probe_service enforces the cloud-intent
|
|
401
436
|
// gate downstream; the CLI whitelists them the same way in scanSingleHost).
|
|
402
|
-
//
|
|
403
|
-
// (otherwise dns.lookup('aws') ENOTFOUND is misread as a blocked address).
|
|
437
|
+
// They are never resolved (dns.lookup('aws') would be ENOTFOUND).
|
|
404
438
|
if (isCloudSentinelHost(h)) return h;
|
|
405
439
|
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
440
|
+
// NSA_ALLOW_ALL_HOSTS (1/true/yes/on only) admits PRIVATE ranges for local-network auditing.
|
|
441
|
+
const allowPrivate = allowAllHosts();
|
|
442
|
+
const cls = classifyAddress(h);
|
|
443
|
+
if (cls === 'always' || (cls === 'private' && !allowPrivate)) throw notAllowed();
|
|
444
|
+
if (canonicalIp(h)) return h; // a permitted literal — nothing to resolve
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
await resolveAndValidate(h, { allowPrivate });
|
|
448
|
+
} catch (err) {
|
|
449
|
+
if (/blocked/.test(String(err && err.message))) throw notAllowed();
|
|
450
|
+
throw new Error(`Host could not be resolved via MCP: ${h}`);
|
|
414
451
|
}
|
|
415
452
|
return h;
|
|
416
453
|
}
|
|
@@ -456,6 +493,8 @@ export async function handleScanHost(args) {
|
|
|
456
493
|
host: output.host,
|
|
457
494
|
conclusion: output.conclusion,
|
|
458
495
|
toolVersion: TOOL_VERSION,
|
|
496
|
+
// Build 4 (Gate 3-A F-1): the manifest, so an audit that did not run — 060's decline of an IP address — is said.
|
|
497
|
+
manifest: output.manifest ?? [],
|
|
459
498
|
});
|
|
460
499
|
}
|
|
461
500
|
} catch { /* swallow — markdown is best-effort */ }
|
|
@@ -464,7 +503,9 @@ export async function handleScanHost(args) {
|
|
|
464
503
|
host: output.host,
|
|
465
504
|
conclusion: output.conclusion ?? null,
|
|
466
505
|
manifest: output.manifest ?? [],
|
|
467
|
-
|
|
506
|
+
// Plugins that RAN, from the manifest — not output.results, which holds one wrapped run per port plus
|
|
507
|
+
// error / timeout / gate-skip envelopes. Same count scan_cloud reports.
|
|
508
|
+
pluginsRan: (output.manifest || []).filter((m) => m.status === 'ran').length,
|
|
468
509
|
markdown,
|
|
469
510
|
};
|
|
470
511
|
}
|
|
@@ -657,7 +698,7 @@ export async function handleProbeService(args) {
|
|
|
657
698
|
const s = String(host).trim().toLowerCase();
|
|
658
699
|
throw new Error(
|
|
659
700
|
`Plugin ${plugin.id} (${plugin.name}) is a network plugin — it does not run against the ` +
|
|
660
|
-
`'${s}' cloud sentinel. Use a network host
|
|
701
|
+
`'${s}' cloud sentinel. Use a network host or IP address, or a cloud auditor with host: "${s}".`,
|
|
661
702
|
);
|
|
662
703
|
}
|
|
663
704
|
const hostKind = isCloudSentinelHost(host) ? `cloud:${String(host).trim().toLowerCase()}` : 'network';
|
|
@@ -862,10 +903,9 @@ export function createServer() {
|
|
|
862
903
|
};
|
|
863
904
|
}
|
|
864
905
|
|
|
865
|
-
// CE 0.1.36 (Thread L Phase 2): mint a per-call
|
|
866
|
-
//
|
|
867
|
-
//
|
|
868
|
-
// exists in the customer's local log file.
|
|
906
|
+
// CE 0.1.36 (Thread L Phase 2): mint a per-call UUID and log it BEFORE the Pro-gate, so even a denial shows the
|
|
907
|
+
// call hit the server. The id alone does NOT authenticate a response — a fabricated response can carry a real id
|
|
908
|
+
// copied from an earlier one — so the receipt also logs a digest of the body (appendCallSentinel, 1.3.0 (s6)).
|
|
869
909
|
const callId = await recordToolCall(name);
|
|
870
910
|
|
|
871
911
|
// Gate Pro-tier tools at the MCP dispatch layer
|
package/package.json
CHANGED
package/plugin_manager.mjs
CHANGED
|
@@ -274,6 +274,16 @@ async function callPlugin(mod, host, ctx, priorOutputs = null, cliOpts = {}) {
|
|
|
274
274
|
});
|
|
275
275
|
}
|
|
276
276
|
|
|
277
|
+
// A UDP row opens its port only when it ANSWERED (1.3.0, B6-4c). The row's own `status` decides when it carries one (the
|
|
278
|
+
// port scanner's 'open' / 'no-response'); otherwise only POSITIVE text counts, never a negative that CONTAINS a positive
|
|
279
|
+
// phrase: "No UDP response" matched `udp response` and "No SNMP response for community …" matched `snmp response`, so a
|
|
280
|
+
// plugin gated on `udp_open` read 'ran' over a silent port.
|
|
281
|
+
const UDP_NEGATIVE = /^no\b|no response|\berror\b/;
|
|
282
|
+
function udpRowAnswered(d, info) {
|
|
283
|
+
if (d?.status != null) return String(d.status).toLowerCase() === "open";
|
|
284
|
+
return !UDP_NEGATIVE.test(info) && /udp response|snmp response|sysdescr|pdu/.test(info);
|
|
285
|
+
}
|
|
286
|
+
|
|
277
287
|
// Heuristics to update context from any plugin's result
|
|
278
288
|
function updateContextFromResult(mod, result, ctx) {
|
|
279
289
|
try {
|
|
@@ -336,9 +346,9 @@ function updateContextFromResult(mod, result, ctx) {
|
|
|
336
346
|
}
|
|
337
347
|
}
|
|
338
348
|
|
|
339
|
-
// UDP hints (rare)
|
|
349
|
+
// UDP hints (rare) — only a row that ANSWERED opens its port (B6-4c, `udpRowAnswered`).
|
|
340
350
|
if (proto === "udp" && Number.isFinite(port)) {
|
|
341
|
-
if (
|
|
351
|
+
if (udpRowAnswered(d, info)) {
|
|
342
352
|
ctx.udpOpen.add(port);
|
|
343
353
|
}
|
|
344
354
|
}
|
|
@@ -373,7 +383,8 @@ function updateContextFromResult(mod, result, ctx) {
|
|
|
373
383
|
}
|
|
374
384
|
if (id === "007" || name.includes("snmp")) {
|
|
375
385
|
const first = rows[0];
|
|
376
|
-
|
|
386
|
+
const firstInfo = safeLower(first?.probe_info || "");
|
|
387
|
+
if (udpRowAnswered(first, firstInfo) && /snmp response/.test(firstInfo) && Number.isFinite(first?.probe_port)) {
|
|
377
388
|
ctx.udpOpen.add(Number(first.probe_port));
|
|
378
389
|
}
|
|
379
390
|
}
|
|
@@ -390,7 +401,8 @@ function updateContextFromResult(mod, result, ctx) {
|
|
|
390
401
|
*/
|
|
391
402
|
export function describeSkipReason(mod, ctx) {
|
|
392
403
|
const req = mod?.requirements || {};
|
|
393
|
-
|
|
404
|
+
// No probe answered, which is no evidence either way — never "down" (1.3.0 lane 1 F).
|
|
405
|
+
if (req.host === 'up' && !ctx.hostUp) return 'no evidence the host is up';
|
|
394
406
|
if (req.host === 'down' && ctx.hostUp) return 'host is up (requires down)';
|
|
395
407
|
if (Array.isArray(req.tcp_open) && req.tcp_open.length) {
|
|
396
408
|
const missing = req.tcp_open.filter(p => !ctx.tcpOpen.has(p));
|
|
@@ -690,6 +702,30 @@ export class PluginManager {
|
|
|
690
702
|
return mergeResultObjects(plugin, filtered);
|
|
691
703
|
}
|
|
692
704
|
|
|
705
|
+
/**
|
|
706
|
+
* The adapter registry handed to the concluder, by plugin ID (1.3.0): every loaded plugin's `conclude` — named or on
|
|
707
|
+
* its default object, as discovery attaches it — with its authoritativePorts and cloudProvider. Ids are a
|
|
708
|
+
* correctness key here, so a built-in wins: CE over EE over a custom NSAUDITOR_PLUGIN_PATH plugin that reuses an id
|
|
709
|
+
* (which would otherwise capture that plugin's records).
|
|
710
|
+
*/
|
|
711
|
+
_concluderAdapters() {
|
|
712
|
+
const RANK = { ce: 0, ee: 1, custom: 2 };
|
|
713
|
+
const map = new Map();
|
|
714
|
+
for (const p of this.plugins || []) {
|
|
715
|
+
if (isConcluder(p) || typeof p?.conclude !== 'function') continue;
|
|
716
|
+
const id = String(p.id);
|
|
717
|
+
const rank = RANK[p._source] ?? 1;
|
|
718
|
+
const prev = map.get(id);
|
|
719
|
+
if (prev && prev.rank <= rank) {
|
|
720
|
+
if (prev.rank < rank) vlog(`plugin id ${id} from ${p._source} ignored for the concluder: a ${prev.source} plugin owns it`);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
map.set(id, { rank, source: p._source ?? null, name: p.name ?? null, conclude: p.conclude.bind(p),
|
|
724
|
+
authoritativePorts: p.authoritativePorts ?? null, cloudProvider: p.cloudProvider ?? null });
|
|
725
|
+
}
|
|
726
|
+
return map;
|
|
727
|
+
}
|
|
728
|
+
|
|
693
729
|
async runConcluder(resultsArray) {
|
|
694
730
|
const concluder =
|
|
695
731
|
this.plugins.find((p) => p.id === "008") ||
|
|
@@ -702,16 +738,17 @@ export class PluginManager {
|
|
|
702
738
|
}
|
|
703
739
|
|
|
704
740
|
try {
|
|
741
|
+
const adapters = this._concluderAdapters();
|
|
705
742
|
// Support both signatures:
|
|
706
|
-
// 1) run(pluginResults)
|
|
707
|
-
// 2) run(host, port, { results })
|
|
743
|
+
// 1) run(pluginResults, { adapters })
|
|
744
|
+
// 2) run(host, port, { results, adapters })
|
|
708
745
|
let conclusion;
|
|
709
746
|
if (concluder.run.length >= 3) {
|
|
710
747
|
vlog("Running Result Concluder with plugin results (opts.results signature):", JSON.stringify(resultsArray, null, 2));
|
|
711
|
-
conclusion = await concluder.run(null, 0, { results: resultsArray, context: withBaseContext({}) });
|
|
748
|
+
conclusion = await concluder.run(null, 0, { results: resultsArray, context: withBaseContext({}), adapters });
|
|
712
749
|
} else {
|
|
713
750
|
vlog("Running Result Concluder with plugin results (single-arg signature):", JSON.stringify(resultsArray, null, 2));
|
|
714
|
-
conclusion = await concluder.run(resultsArray);
|
|
751
|
+
conclusion = await concluder.run(resultsArray, { adapters });
|
|
715
752
|
}
|
|
716
753
|
|
|
717
754
|
vlog("Result Concluder raw output:", JSON.stringify(conclusion, null, 2));
|
|
@@ -795,6 +832,9 @@ export class PluginManager {
|
|
|
795
832
|
reason: describeSkipReason(mod, ctx),
|
|
796
833
|
duration_ms: 0,
|
|
797
834
|
});
|
|
835
|
+
// Requested and NOT measured: the same three-state map the run path writes below, so a
|
|
836
|
+
// late consumer cannot read a gated-out upstream as one nobody requested (D26-3).
|
|
837
|
+
ctx.pluginRunStatus.set(String(mod.id || ''), 'skipped');
|
|
798
838
|
continue;
|
|
799
839
|
}
|
|
800
840
|
|
|
@@ -807,6 +847,7 @@ export class PluginManager {
|
|
|
807
847
|
reason: `missing capabilities: ${(mod.requiredCapabilities || []).join(',')}`,
|
|
808
848
|
duration_ms: 0,
|
|
809
849
|
});
|
|
850
|
+
ctx.pluginRunStatus.set(String(mod.id || ''), 'skipped');
|
|
810
851
|
continue;
|
|
811
852
|
}
|
|
812
853
|
|