nsauditor-ai 0.2.51 → 0.2.52
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 +58 -3
- package/cli.mjs +2 -2
- package/package.json +1 -1
- package/utils/executive_report.mjs +66 -3
- package/utils/report_finding_census.mjs +347 -32
- package/utils/report_inputs.mjs +8 -1
package/README.md
CHANGED
|
@@ -17,6 +17,16 @@ NSAuditor AI is the open-source core of a privacy-first security intelligence pl
|
|
|
17
17
|
|
|
18
18
|
## What's New
|
|
19
19
|
|
|
20
|
+
**Latest: CE 0.2.52 + Enterprise 0.45.0 — the Enterprise NTP clock attestation is WITHDRAWN.** The probe
|
|
21
|
+
is deleted: it validated any reply of at least 48 bytes with a non-null transmit timestamp and nothing
|
|
22
|
+
else — no mode byte, no stratum, no originate echo — so over unauthenticated UDP a datagram of the right
|
|
23
|
+
shape produced a false clean reading, and it measured the SCANNER's own clock rather than your estate.
|
|
24
|
+
It had never been reachable from any shipped entry point.
|
|
25
|
+
RFC 3161 trusted timestamping is untouched, opt-in, and is the capability that answers the question —
|
|
26
|
+
off unless you configure a Time-Stamp Authority, and an outbound call to a third party when you do. On the Community side this cycle changes no behaviour: the
|
|
27
|
+
withdrawn-claim guard over `license --capabilities` descriptions learns the wording the product actually
|
|
28
|
+
published, so the class cannot recur silently here. See [CHANGELOG.md](./CHANGELOG.md).
|
|
29
|
+
|
|
20
30
|
**The Pro `report` subcommand, over a run record that states its own coverage (CE 0.2.51 / Enterprise 0.44.0).**
|
|
21
31
|
`nsauditor-ai report --from <dir> --format executive|jira` turns a completed scan run into a
|
|
22
32
|
client-facing deliverable — a self-contained, print-ready HTML report a consultant sends to their
|
|
@@ -140,7 +150,7 @@ Scan → Analyze → Prioritize → Track → Act
|
|
|
140
150
|
- **Smart result fusion** — the Result Concluder merges all plugin outputs into a normalized view with OS detection, service fingerprinting, and evidence linking
|
|
141
151
|
- **Structured finding format** — all findings use a common schema with category, severity, evidence, and remediation — enabling consistent SARIF export and MCP integration
|
|
142
152
|
- **AI-powered analysis** — send redacted scan results to OpenAI or Claude (your keys, your choice) for vulnerability assessments and remediation guidance
|
|
143
|
-
- **Risk-scored prioritization (Pro)** — findings carry a composite risk score (CVSS weighted by verification status, with an uplift for initial-access techniques) and a status field, and an operator suppression workflow (accepted-risk / false-positive with expiry) keeps triaged findings out of the report until they expire
|
|
153
|
+
- **Risk-scored prioritization (Pro/Enterprise)** — findings carry a composite risk score (CVSS weighted by verification status, with an uplift for initial-access techniques) and a status field, and an operator suppression workflow (accepted-risk / false-positive with expiry) keeps triaged findings out of the report until they expire
|
|
144
154
|
- **Continuous monitoring (CTEM)** — watch mode rescans on a schedule, diffs against previous results, and fires webhook alerts on changes
|
|
145
155
|
- **MCP integration** — expose scanning tools to AI assistants like Claude Code via Model Context Protocol
|
|
146
156
|
- **CI/CD ready** — SARIF output with `--fail-on` severity gating for pipeline integration
|
|
@@ -782,7 +792,7 @@ nsauditor-ai validate
|
|
|
782
792
|
nsauditor-ai feed bundle --from <dir-of-NVD-feeds> --out <bundle.json.gz> [--kev <f>] [--epss <f>]
|
|
783
793
|
nsauditor-ai feed import --file <feed-or-bundle> [--cache-dir <d>] [--extras-dir <d>] [--append] # the feeds you downloaded
|
|
784
794
|
nsauditor-ai compliance <attest|suppress|review|renew|keygen|sign-pack|verify-pack> (Enterprise)
|
|
785
|
-
nsauditor-ai report --from <dir> --format executive|jira [--run <id>] [--brand <brand.json>] [--out <path>] [--allow-partial] (Pro)
|
|
795
|
+
nsauditor-ai report --from <dir> --format executive|jira [--run <id>] [--brand <brand.json>] [--out <path>] [--allow-partial] (Pro/Enterprise)
|
|
786
796
|
nsauditor-ai mcp
|
|
787
797
|
nsauditor-ai --help (or -h, or `help`)
|
|
788
798
|
nsauditor-ai --version (or -v, or `version`)
|
|
@@ -876,7 +886,7 @@ HEALTHCHECK --interval=60s --timeout=5s --start-period=10s --retries=3 \
|
|
|
876
886
|
CMD nsauditor-ai validate --json | grep -q '"overall": "ok"' || exit 1
|
|
877
887
|
```
|
|
878
888
|
|
|
879
|
-
### `report` command (Pro)
|
|
889
|
+
### `report` command (Pro/Enterprise)
|
|
880
890
|
|
|
881
891
|
`nsauditor-ai report --from <dir> --format executive|jira` turns a completed scan run under `--from` into a client-facing deliverable — an HTML report a consultant sends to their customer, or a Jira-importer CSV.
|
|
882
892
|
|
|
@@ -907,6 +917,51 @@ nsauditor-ai report --from out/2026-08-26 --format executive --brand brand.json
|
|
|
907
917
|
nsauditor-ai report --from out/2026-08-26 --format jira
|
|
908
918
|
```
|
|
909
919
|
|
|
920
|
+
#### The `brand.json` file
|
|
921
|
+
|
|
922
|
+
`--brand` takes a small JSON object. Every field is optional; omit the file entirely and the report
|
|
923
|
+
renders unbranded with the default title.
|
|
924
|
+
|
|
925
|
+
```json
|
|
926
|
+
{
|
|
927
|
+
"title": "Q3 External Perimeter Review",
|
|
928
|
+
"companyName": "Acme Manufacturing GmbH",
|
|
929
|
+
"preparedBy": "J. Rivera, Security Consulting LLC",
|
|
930
|
+
"contact": "security@example.com",
|
|
931
|
+
"logoPath": "acme-logo.png"
|
|
932
|
+
}
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
| field | type | default | notes |
|
|
936
|
+
|---|---|---|---|
|
|
937
|
+
| `title` | string | `Network Scan Report` | Replaces the report's headline. |
|
|
938
|
+
| `companyName` | string | *(empty)* | The client the report is prepared **for**. |
|
|
939
|
+
| `preparedBy` | string | *(empty)* | The consultant or firm preparing it. |
|
|
940
|
+
| `contact` | string | *(empty)* | Free text — an address, an email, a phone number. |
|
|
941
|
+
| `logoPath` | string | *(none)* | A **local** PNG or JPEG, resolved relative to the brand file's own directory. Embedded into the HTML as a data URI. |
|
|
942
|
+
|
|
943
|
+
**`logoPath` is deliberately restrictive, and each refusal protects the report's core promise —
|
|
944
|
+
that it opens with no external network reference of any kind:**
|
|
945
|
+
|
|
946
|
+
- **A URL is refused and never fetched.** A remote logo is egress at render time and a tracking
|
|
947
|
+
pixel every time your client opens the file.
|
|
948
|
+
- **A UNC share (`\\host\share\...`) is refused** for the same reason: it is a network reference.
|
|
949
|
+
- **SVG is refused.** SVG is markup and can carry a script, which would break the report's
|
|
950
|
+
no-script guarantee from inside the image. Use PNG or JPEG.
|
|
951
|
+
- **The path must stay inside the brand file's own directory** — `../` escapes are refused, checked
|
|
952
|
+
both as typed and again after symlinks are resolved.
|
|
953
|
+
- **2 MB maximum**, and the format is detected by **magic bytes, not by file extension** — renaming
|
|
954
|
+
`logo.svg` to `logo.png` does not get it past the check.
|
|
955
|
+
- **An empty string is read as "no logo"**, the same as omitting the key, so a generated
|
|
956
|
+
`brand.json` that emits `""` is not an error.
|
|
957
|
+
|
|
958
|
+
A brand file that cannot be read, is not valid JSON, is not a JSON object, or whose `logoPath` is
|
|
959
|
+
refused is a **fatal error** — the report is not written. A flag you passed is never silently
|
|
960
|
+
dropped, because that is how a report goes out unbranded without anyone noticing.
|
|
961
|
+
|
|
962
|
+
⚠️ `--brand` applies to `--format executive` only. Passing it with `--format jira` is **refused**,
|
|
963
|
+
not ignored: a CSV has nowhere to put a cover page.
|
|
964
|
+
|
|
910
965
|
---
|
|
911
966
|
|
|
912
967
|
## Configuration
|
package/cli.mjs
CHANGED
|
@@ -1452,7 +1452,7 @@ export async function main(testHooks = {}) {
|
|
|
1452
1452
|
Usage:
|
|
1453
1453
|
nsauditor-ai [scan] --host <ip|cidr|hostname> [options]
|
|
1454
1454
|
nsauditor-ai [scan] --host-file <path> [options]
|
|
1455
|
-
nsauditor-ai report --from <dir> --format executive|jira [options] (Pro)
|
|
1455
|
+
nsauditor-ai report --from <dir> --format executive|jira [options] (Pro/Enterprise)
|
|
1456
1456
|
nsauditor-ai license <subcommand>
|
|
1457
1457
|
nsauditor-ai security <subcommand>
|
|
1458
1458
|
nsauditor-ai validate
|
|
@@ -1644,7 +1644,7 @@ Examples:
|
|
|
1644
1644
|
nsauditor-ai license install enterprise_eyJhbGciOiJFUzI1NiIs...
|
|
1645
1645
|
nsauditor-ai license --status
|
|
1646
1646
|
|
|
1647
|
-
Report subcommand (Pro):
|
|
1647
|
+
Report subcommand (Pro/Enterprise):
|
|
1648
1648
|
nsauditor-ai report --from <dir> --format executive|jira [--run <id>]
|
|
1649
1649
|
[--brand <brand.json>] [--out <path>] [--allow-partial]
|
|
1650
1650
|
Renders a client-facing deliverable from a completed scan run under --from.
|
package/package.json
CHANGED
|
@@ -350,9 +350,25 @@ function renderCoverageSummary(model) {
|
|
|
350
350
|
return `${escapeHtml(String(u.host))}: ${names}`;
|
|
351
351
|
});
|
|
352
352
|
const total = unread.reduce((a, u) => a + Object.values(u.unread).reduce((x, y) => x + y, 0), 0);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
353
|
+
// ⚠️ TWO REASONS, TWO SENTENCES — and conflating them put a FALSE QUANTITY in front of a
|
|
354
|
+
// client. A container nobody reads is genuinely absent from this document. A carve-out whose
|
|
355
|
+
// PREMISE BROKE is different: its objects ARE rendered above except for the break, so saying
|
|
356
|
+
// they "are NOT included above" contradicts the body of the same report. Plugin 1150 emits
|
|
357
|
+
// `data: findings`, so one orphan among sixteen mirrors used to announce that seventeen
|
|
358
|
+
// objects were missing from a report containing sixteen of them.
|
|
359
|
+
const broken = unread.some((u) => Object.keys(u.premiseFailures ?? {}).length);
|
|
360
|
+
if (broken) {
|
|
361
|
+
const why = unread.flatMap((u) => Object.values(u.premiseFailures ?? {}));
|
|
362
|
+
lines.push(`<p class="caveat">⚠️ ${total} recorded finding-like object(s) are NOT accounted `
|
|
363
|
+
+ `for by this report — ${parts.join(' · ')}. Some containers are not read at all; others `
|
|
364
|
+
+ 'are summaries whose link to a rendered finding could not be re-derived on this run, so '
|
|
365
|
+
+ 'their objects may appear above under a different heading. '
|
|
366
|
+
+ `${escapeHtml(why.join(' · '))}</p>`);
|
|
367
|
+
} else {
|
|
368
|
+
lines.push(`<p class="caveat">⚠️ ${total} recorded finding-like object(s) live in containers `
|
|
369
|
+
+ `this report does not read, and are NOT included above — ${parts.join(' · ')}. `
|
|
370
|
+
+ 'Treat this report as incomplete for those surfaces.</p>');
|
|
371
|
+
}
|
|
356
372
|
}
|
|
357
373
|
if (!model.findings.length) {
|
|
358
374
|
lines.push(`<p>No findings were recorded for the ${c.written} hosts scanned in this run.</p>`);
|
|
@@ -549,7 +565,54 @@ function renderFooter(model) {
|
|
|
549
565
|
return `<footer>Generated by NSAuditor AI ${escapeHtml(tier)} ${escapeHtml(String(ceVersion))}${eeSuffix}</footer>`;
|
|
550
566
|
}
|
|
551
567
|
|
|
568
|
+
// Names what `brand` actually was, for the refusal below — never called on a plain object.
|
|
569
|
+
// Same voice and job as brand.mjs's own `describeLogoPathType`: a caller told only "must be an
|
|
570
|
+
// object" over a value it cannot see has to open this module to learn what it did wrong.
|
|
571
|
+
function describeBrand(v) {
|
|
572
|
+
if (v === null) return 'null';
|
|
573
|
+
if (v === undefined) return 'undefined';
|
|
574
|
+
if (Array.isArray(v)) return 'an array';
|
|
575
|
+
return `a ${typeof v}`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// ⚠️ REFUSES rather than defaulting, and the choice is the opposite of what "make it total"
|
|
579
|
+
// usually means: this renders a CLIENT-FACING deliverable, so silently normalising an absent
|
|
580
|
+
// brand into an unbranded report is the WORSE failure — it hands the caller a complete,
|
|
581
|
+
// plausible document at exit 0 with the consultant's identity missing, and that is the copy
|
|
582
|
+
// that reaches the client.
|
|
583
|
+
//
|
|
584
|
+
// That is the CC-4 ruling one layer down. `tests/report_command.test.mjs` already pins "a
|
|
585
|
+
// value-less --brand is FATAL, not a silent unbranded default", recorded there because a shell
|
|
586
|
+
// glob that swallowed the path "produced the IDENTICAL unbranded default report, at exit 0, as
|
|
587
|
+
// never asking for a brand at all — silently". An absent argument here is indistinguishable
|
|
588
|
+
// from a lost one for exactly the same reason, and refusing costs the honest caller nothing:
|
|
589
|
+
// "no branding wanted" stays expressible as `{}`, which renders precisely what it rendered
|
|
590
|
+
// before (measured: byte-identical to loadBrand(null)'s defaults, 4579 bytes on the fixture).
|
|
591
|
+
//
|
|
592
|
+
// WHAT WAS MEASURED, over eight brand shapes, before this guard existed: null / undefined / an
|
|
593
|
+
// omitted second argument all threw `TypeError: Cannot read properties of null (reading
|
|
594
|
+
// 'title')` — the loud half. A string, a number, a boolean and an array all RENDERED, 4579
|
|
595
|
+
// bytes, titled "Network Scan Report", with no company line — the quiet half, and the one a
|
|
596
|
+
// caller passing its company NAME where the brand OBJECT belongs would have shipped.
|
|
597
|
+
//
|
|
598
|
+
// A DEFAULTED PARAMETER (`brand = {}`) was rejected: it fires only on `undefined`, so it fixes
|
|
599
|
+
// the omitted-argument call and leaves an explicit `null` — the shape a caller that lost its
|
|
600
|
+
// brand actually produces — crashing exactly as before. The guard is a normalisation check at
|
|
601
|
+
// the single public entry point instead. `renderCover` and `renderBrandBlock` dereference
|
|
602
|
+
// `brand` freely — DERIVED, not counted by hand: 11 dereferences in this module, 7 in
|
|
603
|
+
// renderBrandBlock + 3 in renderCover + this one — and are reachable ONLY through here; that
|
|
604
|
+
// premise is not
|
|
605
|
+
// left to a reading — `tests/executive_report_brand_absent.test.mjs` pins this module's export
|
|
606
|
+
// surface, so exporting either helper fails the build rather than silently opening an
|
|
607
|
+
// unguarded path.
|
|
552
608
|
export function renderExecutiveReport(model, brand, { renderedAt = new Date() } = {}) {
|
|
609
|
+
if (brand === null || typeof brand !== 'object' || Array.isArray(brand)) {
|
|
610
|
+
throw new TypeError(
|
|
611
|
+
'renderExecutiveReport: `brand` must be an object — pass the `brand` field of '
|
|
612
|
+
+ "loadBrand()'s result, or `{}` for a deliberately unbranded report. It is "
|
|
613
|
+
+ `${describeBrand(brand)}. Refusing rather than rendering a client-facing report with `
|
|
614
|
+
+ 'the branding silently missing.');
|
|
615
|
+
}
|
|
553
616
|
const title = escapeHtml(brand.title || 'Network Scan Report');
|
|
554
617
|
|
|
555
618
|
return `<!DOCTYPE html>
|
|
@@ -25,6 +25,16 @@
|
|
|
25
25
|
// because the network path's failure is a SOURCE the consumer never opens. A fixture
|
|
26
26
|
// drawn from a producer's field names cannot catch a producer whose output lives in a
|
|
27
27
|
// different artifact.
|
|
28
|
+
//
|
|
29
|
+
// ⚠️ AND THEN THE ALLOWLIST BECAME THE NEXT INSTANCE OF THE SAME CLASS. Its entries were
|
|
30
|
+
// PROSE, and two of them asserted a FACT about one run rather than a property of a shape —
|
|
31
|
+
// the cisAlarmCoverage entry closed with its own instruction, "re-derive, do not re-assert",
|
|
32
|
+
// and NOTHING RE-DERIVED IT. A carve-out that outlives the fact justifying it excludes real
|
|
33
|
+
// findings from a client deliverable while the census still prints `unread: {}`: a false
|
|
34
|
+
// clean wearing the exit code of a measurement. So an entry is now `{reason, verify}`, and a
|
|
35
|
+
// premise that FAILS or THROWS withdraws the carve-out — the container is counted UNREAD and
|
|
36
|
+
// reported in `premiseFailures` with the detail, because "nobody wrote a reader" and "the
|
|
37
|
+
// reason we wrote no reader stopped being true" need different repairs.
|
|
28
38
|
|
|
29
39
|
/** Containers the report READS. Adding a reader means adding its key here. */
|
|
30
40
|
export const READ_CONTAINERS = Object.freeze([
|
|
@@ -36,38 +46,304 @@ export const READ_CONTAINERS = Object.freeze([
|
|
|
36
46
|
]);
|
|
37
47
|
|
|
38
48
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
49
|
+
* THE CENSUS'S SUBJECT, in one place, because a verifier that vouches for a different set of
|
|
50
|
+
* objects than the census counted is vouching for nothing. Both the walk below and every
|
|
51
|
+
* `verify` use this predicate: it is the DEFINITION of what is being carved out, not a
|
|
52
|
+
* second opinion about it.
|
|
53
|
+
*/
|
|
54
|
+
export function isCensusSubject(node) {
|
|
55
|
+
return !!node && typeof node === 'object' && !Array.isArray(node) && typeof node.severity === 'string';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const resultsOf = (raw) => (Array.isArray(raw?.results) ? raw.results : []);
|
|
59
|
+
const arrayAt = (o, k) => (Array.isArray(o?.[k]) ? o[k] : []);
|
|
60
|
+
|
|
61
|
+
/** Key-sorted serialisation, so two rows compare by CONTENT and not by key order. */
|
|
62
|
+
function canonical(v) {
|
|
63
|
+
if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
|
|
64
|
+
if (v && typeof v === 'object') {
|
|
65
|
+
return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
|
|
66
|
+
}
|
|
67
|
+
return JSON.stringify(v ?? null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── The premises, one per carve-out ──────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* (i) `result.summary.cisAlarmCoverage.uncovered[]` — the mirror is still 1:1.
|
|
74
|
+
*
|
|
75
|
+
* Derived from the real records, never invented: an uncovered entry is
|
|
76
|
+
* `{id:'cis-3.1', title, severity, coverageMode}` and its paired rendered finding carries
|
|
77
|
+
* BOTH `details.cisId` (with `details.category` = 'cis-alarm-coverage-no-filter') AND an
|
|
78
|
+
* `issues[]` string reading "CloudWatch alarm missing: … (CIS AWS Foundations Benchmark
|
|
79
|
+
* cis-3.1)". Measured over the 152 sample records: 48 plugin results carry an alarm-coverage
|
|
80
|
+
* summary, 624 uncovered entries in total, and BOTH channels independently pair 100% of them.
|
|
81
|
+
* Either channel is therefore accepted — a class the reader receives is not a lost finding,
|
|
82
|
+
* whichever field carried it — and the details channel is scoped to alarm-coverage findings
|
|
83
|
+
* so a `cisId` on an unrelated finding cannot stand in for the mirror.
|
|
84
|
+
*/
|
|
85
|
+
function verifyAlarmCoverageStillMirrorsItsFindings(raw) {
|
|
86
|
+
const broken = [];
|
|
87
|
+
for (const e of resultsOf(raw)) {
|
|
88
|
+
const res = e?.result ?? {};
|
|
89
|
+
const uncovered = arrayAt(res?.summary?.cisAlarmCoverage, 'uncovered').filter(isCensusSubject);
|
|
90
|
+
if (!uncovered.length) continue;
|
|
91
|
+
|
|
92
|
+
const rendered = new Set();
|
|
93
|
+
for (const f of arrayAt(res, 'findings')) {
|
|
94
|
+
if (!f || typeof f !== 'object') continue;
|
|
95
|
+
for (const issue of arrayAt(f, 'issues')) {
|
|
96
|
+
if (typeof issue !== 'string' || !/alarms?\s+missing/i.test(issue)) continue;
|
|
97
|
+
for (const m of issue.matchAll(/\bcis-\d+(?:\.\d+)*\b/gi)) rendered.add(m[0].toLowerCase());
|
|
98
|
+
}
|
|
99
|
+
const d = f.details;
|
|
100
|
+
if (d && typeof d.cisId === 'string' && typeof d.category === 'string' && /alarm/i.test(d.category)) {
|
|
101
|
+
rendered.add(d.cisId.toLowerCase());
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const u of uncovered) {
|
|
105
|
+
if (typeof u.id !== 'string' || !u.id) {
|
|
106
|
+
broken.push(`plugin ${e?.id ?? '?'}: an uncovered entry with no \`id\` — nothing can pair it`);
|
|
107
|
+
} else if (!rendered.has(u.id.toLowerCase())) {
|
|
108
|
+
broken.push(`plugin ${e?.id ?? '?'}: ${u.id}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return broken.length
|
|
113
|
+
? { ok: false,
|
|
114
|
+
unreadCount: broken.length,
|
|
115
|
+
detail: `the 1:1 mirror has BROKEN — ${broken.length} uncovered alarm class(es) have no rendered `
|
|
116
|
+
+ `"alarm missing" finding, so they reach no reader: ${broken.join(' · ')}` }
|
|
117
|
+
: { ok: true, detail: 'every uncovered alarm class pairs with a rendered finding' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* (ii) `result.findings[].policyAnalyses[].issues[]` — the parent really is rendered.
|
|
122
|
+
*
|
|
123
|
+
* The carve-out calls the nested issues EVIDENCE FOR a finding that IS rendered. That is
|
|
124
|
+
* true only while the parent carries content of its own: `shapeFinding` titles a finding
|
|
125
|
+
* from its `issues[]` (falling back to `detail`/`description`), and with none of those it
|
|
126
|
+
* renders "(untitled finding)" — utils/report_inputs.mjs:139-151. A parent with no issue of
|
|
127
|
+
* its own is an empty row whose only real content is the nested detail nobody reads, which
|
|
128
|
+
* is the carve-out's own negation. Measured: 104 parents across the sample corpus carry
|
|
129
|
+
* counted policy-analysis issues, and 0 of them lack their own.
|
|
130
|
+
*/
|
|
131
|
+
function verifyPolicyAnalysisBacksARenderedFinding(raw) {
|
|
132
|
+
const orphans = [];
|
|
133
|
+
for (const e of resultsOf(raw)) {
|
|
134
|
+
const findings = arrayAt(e?.result, 'findings');
|
|
135
|
+
for (let i = 0; i < findings.length; i += 1) {
|
|
136
|
+
const f = findings[i];
|
|
137
|
+
if (!f || typeof f !== 'object') continue;
|
|
138
|
+
const nested = arrayAt(f, 'policyAnalyses')
|
|
139
|
+
.flatMap((pa) => arrayAt(pa, 'issues'))
|
|
140
|
+
.filter(isCensusSubject);
|
|
141
|
+
if (!nested.length) continue;
|
|
142
|
+
// Presence is not content: an `issues: ['']` renders as no text at all.
|
|
143
|
+
const own = arrayAt(f, 'issues')
|
|
144
|
+
.filter((x) => (typeof x === 'string' ? x.trim().length > 0 : !!x));
|
|
145
|
+
if (!own.length) {
|
|
146
|
+
orphans.push(`plugin ${e?.id ?? '?'} findings[${i}] carries ${nested.length} policy-analysis `
|
|
147
|
+
+ 'issue(s) and NO issue of its own');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return orphans.length
|
|
152
|
+
? { ok: false,
|
|
153
|
+
unreadCount: orphans.length,
|
|
154
|
+
detail: 'the nested detail is no longer EVIDENCE FOR a rendered finding — the parent renders as '
|
|
155
|
+
+ `"(untitled finding)" with nothing in it: ${orphans.join(' · ')}` }
|
|
156
|
+
: { ok: true, detail: 'every policy-analysis parent carries its own issue text' };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* (iii) `result.data[]` — on the cloud path it still MIRRORS `findings[]`.
|
|
161
|
+
*
|
|
162
|
+
* ⚠️ THE PREDICATE IS DELIBERATELY SCOPED TO THE CENSUS'S SUBJECT, AND THAT IS WHAT MAKES
|
|
163
|
+
* THE NETWORK SHAPE PASS WITHOUT AN EXEMPTION. A network host legitimately has
|
|
164
|
+
* `findings[] = 0` and `data[] = 85` — measured on the 192.168.1.1 run, 84 of them
|
|
165
|
+
* `{probe_protocol, probe_port, probe_info}`. Not one carries a `severity`, so not one is a
|
|
166
|
+
* census subject and there is nothing to mirror: the acceptance is STRUCTURAL, not a list of
|
|
167
|
+
* telemetry field names that a producer could quietly step outside. The corollary is
|
|
168
|
+
* intended: a telemetry row that GAINS a severity is a severity-bearing object in a container
|
|
169
|
+
* nobody reads, and it fails.
|
|
170
|
+
*
|
|
171
|
+
* Matching is CONTENT-DEEP within one plugin result. Measured across the sample corpus: 86
|
|
172
|
+
* plugin results carry severity-bearing data rows (1150 and 1170 only, 1075 rows), and every
|
|
173
|
+
* row is deep-equal to a row in the same result's findings[]. A laxer key of
|
|
174
|
+
* severity+resource+issues was measured too — also 0 unmatched — and REJECTED: it collapses
|
|
175
|
+
* to a constant on rows that carry neither `resource` nor `issues`, which is a false-clean
|
|
176
|
+
* generator on exactly the network-ish shape this entry has to keep accepting.
|
|
177
|
+
*
|
|
178
|
+
* One stated limit: the mirror is looked for in `findings[]` in its ARRAY form only. A
|
|
179
|
+
* producer that emitted the 060 DICT-of-categories shape AND severity-bearing data[] would
|
|
180
|
+
* FAIL this premise rather than be excused by it. Nothing does today; failing is the
|
|
181
|
+
* noise-costing direction, and a lax read here would be a carve-out granted on a shape
|
|
182
|
+
* nobody has looked at.
|
|
183
|
+
*/
|
|
184
|
+
function verifyDataMirrorsFindings(raw) {
|
|
185
|
+
const unmirrored = [];
|
|
186
|
+
for (const e of resultsOf(raw)) {
|
|
187
|
+
const res = e?.result ?? {};
|
|
188
|
+
const rows = arrayAt(res, 'data').filter(isCensusSubject);
|
|
189
|
+
if (!rows.length) continue;
|
|
190
|
+
const rendered = new Set(arrayAt(res, 'findings')
|
|
191
|
+
.filter((f) => f && typeof f === 'object').map(canonical));
|
|
192
|
+
for (const r of rows) {
|
|
193
|
+
if (!rendered.has(canonical(r))) {
|
|
194
|
+
// Locate it well enough to act on: `resource` alone is not distinguishing — on some
|
|
195
|
+
// producers it holds the REGION, so a dozen rows share it.
|
|
196
|
+
const excerpt = arrayAt(r, 'issues').map((x) => (typeof x === 'string' ? x : x?.detail ?? ''))
|
|
197
|
+
.find((t) => t && t.trim()) ?? '';
|
|
198
|
+
unmirrored.push(`plugin ${e?.id ?? '?'}: ${r.severity.toUpperCase()} `
|
|
199
|
+
+ `${r.resource ?? '(no resource)'}${excerpt ? ` — "${excerpt.slice(0, 80)}"` : ''}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return unmirrored.length
|
|
204
|
+
? { ok: false,
|
|
205
|
+
unreadCount: unmirrored.length,
|
|
206
|
+
detail: `result.data[] is no longer a mirror — ${unmirrored.length} severity-bearing row(s) have no `
|
|
207
|
+
+ `content-equal finding in the same plugin result, so nothing renders them: ${unmirrored.join(' · ')}` }
|
|
208
|
+
: { ok: true, detail: 'every severity-bearing data row is mirrored by a rendered finding' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* (iv) `result.portResults[]` — the roll-up really is a roll-up.
|
|
213
|
+
*
|
|
214
|
+
* The reason claims the entry's severity "summarises the port's own issues[], which ARE
|
|
215
|
+
* read". That is checkable in both directions, and both directions matter:
|
|
216
|
+
* · a severity over an EMPTY issues[] summarises nothing — it is a finding nothing renders,
|
|
217
|
+
* which is the exact hazard the carve-out denies;
|
|
218
|
+
* · a severity ABSENT from its own issues' severities is a roll-up of something the port
|
|
219
|
+
* does not hold.
|
|
220
|
+
* The empty case is not simply forbidden, because 040 seeds the roll-up at SEVERITY.PASS and
|
|
221
|
+
* raises it to the max of the port's issues (plugins/040_tls_cert_auditor.mjs:491-496), so a
|
|
222
|
+
* clean port emits exactly `{severity:'pass', issues:[]}`. PASS is the identity element of
|
|
223
|
+
* this roll-up; failing it would flag every healthy HTTPS port and teach an operator to read
|
|
224
|
+
* past the warning. Measured over 232 records (samples + both out/ trees): 7 portResults
|
|
225
|
+
* entries carry a severity, all 7 hold it among their own issue severities, and none has an
|
|
226
|
+
* empty issues[].
|
|
227
|
+
*/
|
|
228
|
+
const ROLLUP_IDENTITY_SEVERITY = 'pass';
|
|
229
|
+
|
|
230
|
+
function verifyPortRollUpSummarisesItsOwnIssues(raw) {
|
|
231
|
+
const bad = [];
|
|
232
|
+
for (const e of resultsOf(raw)) {
|
|
233
|
+
const ports = arrayAt(e?.result, 'portResults');
|
|
234
|
+
for (let i = 0; i < ports.length; i += 1) {
|
|
235
|
+
const pr = ports[i];
|
|
236
|
+
if (!isCensusSubject(pr)) continue;
|
|
237
|
+
const where = `plugin ${e?.id ?? '?'} port ${pr.port ?? `[${i}]`}`;
|
|
238
|
+
const issueSeverities = arrayAt(pr, 'issues').filter(isCensusSubject).map((x) => x.severity);
|
|
239
|
+
if (!issueSeverities.length) {
|
|
240
|
+
if (pr.severity !== ROLLUP_IDENTITY_SEVERITY) {
|
|
241
|
+
bad.push(`${where}: severity "${pr.severity}" over an EMPTY issues[] — it summarises nothing, `
|
|
242
|
+
+ 'so nothing renders it');
|
|
243
|
+
}
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (!issueSeverities.includes(pr.severity)) {
|
|
247
|
+
bad.push(`${where}: roll-up severity "${pr.severity}" is not among its own issue severities `
|
|
248
|
+
+ `(${[...new Set(issueSeverities)].join(', ')})`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return bad.length
|
|
253
|
+
? { ok: false, unreadCount: bad.length, detail: `the roll-up no longer summarises its own issues[]: ${bad.join(' · ')}` }
|
|
254
|
+
: { ok: true, detail: 'every port roll-up is carried by its own issues' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Containers that carry a `severity` but are NOT findings. Each needs a written reason AND,
|
|
259
|
+
* where the reason rests on a checkable fact, a `verify(raw, queue) => {ok, detail}` that
|
|
260
|
+
* re-derives it on every run — an allowlist entry with no reason is how a real door gets
|
|
261
|
+
* closed by accident, and one whose reason nothing re-derives is how a closed door stays
|
|
262
|
+
* shut after the reason has gone.
|
|
41
263
|
*/
|
|
42
264
|
export const ALLOWLISTED_CONTAINERS = Object.freeze({
|
|
43
|
-
'result.portResults[]':
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'result.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
265
|
+
'result.portResults[]': Object.freeze({
|
|
266
|
+
reason:
|
|
267
|
+
'A per-port ROLL-UP, not a finding: its severity summarises the port\'s own '
|
|
268
|
+
+ '`issues[]`, which ARE read. Rendering both would double-count every TLS issue.',
|
|
269
|
+
verify: verifyPortRollUpSummarisesItsOwnIssues,
|
|
270
|
+
}),
|
|
271
|
+
'result.summary.cisAlarmCoverage.uncovered[]': Object.freeze({
|
|
272
|
+
reason:
|
|
273
|
+
'A 1:1 SUMMARY MIRROR of 1040\'s own alarm-coverage findings, not a second source. '
|
|
274
|
+
+ 'Adjudicated on the 0.44.0 cloud run: 13 uncovered classes, 13 "alarm missing" '
|
|
275
|
+
+ 'findings, and the set difference of their cis-N.N ids is EMPTY. That was a fact '
|
|
276
|
+
+ 'about one run, so `verify` re-derives it on every run rather than re-asserting it.',
|
|
277
|
+
verify: verifyAlarmCoverageStillMirrorsItsFindings,
|
|
278
|
+
}),
|
|
279
|
+
'result.findings[].policyAnalyses[].issues[]': Object.freeze({
|
|
280
|
+
reason:
|
|
281
|
+
'Per-statement EVIDENCE nested inside a finding that IS rendered — e.g. "Full admin '
|
|
282
|
+
+ 'grant: Action \'*\' in policy X" backing a parent whose own issues[] already say '
|
|
283
|
+
+ '"SHADOW ADMIN: User has full wildcard (*) permissions". Not a missed finding. '
|
|
284
|
+
+ 'RESIDUAL, stated rather than hidden: the per-statement detail is not surfaced in the '
|
|
285
|
+
+ 'report at all, which is a DEPTH choice nobody has ruled on, not a coverage gap.',
|
|
286
|
+
verify: verifyPolicyAnalysisBacksARenderedFinding,
|
|
287
|
+
}),
|
|
288
|
+
'result.data[]': Object.freeze({
|
|
289
|
+
reason:
|
|
290
|
+
'NOT a findings container, and the report must never read it. On a CLOUD host it '
|
|
291
|
+
+ 'MIRRORS result.findings (verified 0.44.0: 1150 16/16, 1170 9/9). On a NETWORK host '
|
|
292
|
+
+ 'it is PROBE TELEMETRY — measured on the 192.168.1.1 run, findings[]=0 while '
|
|
293
|
+
+ 'data[]=85, 84 of them {probe_protocol, probe_port, probe_info} ("Connect refused '
|
|
294
|
+
+ '(ECONNREFUSED)", "No UDP response"). ⚠️ A `findings ?? data` fallback was written '
|
|
295
|
+
+ 'here and REVERTED: it put scan telemetry into a client deliverable, hidden because '
|
|
296
|
+
+ 'the telemetry rows collided on one content hash and collapsed to two. '
|
|
297
|
+
+ 'cloud_finding_summary\'s findingsOf may use that precedence — it only ever sees '
|
|
298
|
+
+ 'cloud producers; this consumer sees both paths.',
|
|
299
|
+
verify: verifyDataMirrorsFindings,
|
|
300
|
+
}),
|
|
67
301
|
});
|
|
68
302
|
|
|
69
303
|
const QUEUE_CONTAINER = 'findingQueue[]';
|
|
70
304
|
|
|
305
|
+
/**
|
|
306
|
+
* Adjudicate ONE allowlist entry against a record.
|
|
307
|
+
*
|
|
308
|
+
* ⚠️ FAILS CLOSED IN EVERY DIRECTION THAT IS NOT AN EXPLICIT `ok: true`. A verifier that
|
|
309
|
+
* throws has not established the premise; neither has one that returns a shape nobody can
|
|
310
|
+
* read. Both are treated as a broken premise, because the alternative — a thrown TypeError
|
|
311
|
+
* silently restoring the carve-out — is the false clean this whole mechanism exists to
|
|
312
|
+
* close, and it would arrive precisely when a producer changed shape.
|
|
313
|
+
*
|
|
314
|
+
* @returns {string|null} null when the carve-out stands (premise held, or none declared);
|
|
315
|
+
* otherwise the operator-facing detail of why it does not.
|
|
316
|
+
*/
|
|
317
|
+
export function adjudicateAllowlistEntry(entry, raw, queue) {
|
|
318
|
+
// An entry that declares no premise keeps its carve-out: a shape nobody can check yet is
|
|
319
|
+
// better carved out loudly, with its reason readable, than checked wrongly. The ratchet
|
|
320
|
+
// that stops "undeclared" becoming the default lives in the tests, not here.
|
|
321
|
+
if (typeof entry?.verify !== 'function') return null;
|
|
322
|
+
let verdict;
|
|
323
|
+
try {
|
|
324
|
+
verdict = entry.verify(raw, queue);
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return { detail: `premise check THREW (${err?.message ?? String(err)}) — a check that cannot `
|
|
327
|
+
+ 'run has established nothing, so the carve-out is withdrawn' };
|
|
328
|
+
}
|
|
329
|
+
if (!verdict || typeof verdict !== 'object' || typeof verdict.ok !== 'boolean') {
|
|
330
|
+
return { detail: 'premise check returned no verdict — treated as FAILED, because a check that '
|
|
331
|
+
+ 'cannot answer has not established anything' };
|
|
332
|
+
}
|
|
333
|
+
if (verdict.ok) return null;
|
|
334
|
+
// ⚠️ THE COUNT TRAVELS WITH THE DETAIL. This used to return the detail STRING alone, so the
|
|
335
|
+
// caller had a sentence saying "1 row has no mirror" and no number to act on — and fell back to
|
|
336
|
+
// the container's whole population, which is exactly the false quantity the consumer renders.
|
|
337
|
+
// A failure is `{detail, unreadCount}`; `unreadCount` is whatever the verifier claimed and is
|
|
338
|
+
// VALIDATED downstream by resolveUnreadCount, never trusted here.
|
|
339
|
+
return {
|
|
340
|
+
detail: typeof verdict.detail === 'string' && verdict.detail.trim()
|
|
341
|
+
? verdict.detail
|
|
342
|
+
: 'premise FAILED, with no detail given',
|
|
343
|
+
unreadCount: verdict.unreadCount,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
71
347
|
/**
|
|
72
348
|
* Classify where a severity-bearing object lives. Path is the dotted walk from `result`.
|
|
73
349
|
* @returns {string} a container key
|
|
@@ -91,7 +367,7 @@ function* severityObjects(node, path = '') {
|
|
|
91
367
|
if (Array.isArray(node)) {
|
|
92
368
|
for (let i = 0; i < node.length; i += 1) yield* severityObjects(node[i], `${path}[${i}]`);
|
|
93
369
|
} else if (node && typeof node === 'object') {
|
|
94
|
-
if (
|
|
370
|
+
if (isCensusSubject(node)) yield { path, obj: node };
|
|
95
371
|
for (const [k, v] of Object.entries(node)) yield* severityObjects(v, `${path}.${k}`);
|
|
96
372
|
}
|
|
97
373
|
}
|
|
@@ -102,22 +378,61 @@ function* severityObjects(node, path = '') {
|
|
|
102
378
|
* @param {Array} queue the host's finding queue (scan_finding_queue.json, or the
|
|
103
379
|
* eeEnrichment fallback) — passed in because it is a SIBLING
|
|
104
380
|
* artifact, not part of `raw`, which is the whole reason it was missed.
|
|
105
|
-
* @returns {{byContainer: Object<string, number>, unread: Object<string, number>,
|
|
381
|
+
* @returns {{byContainer: Object<string, number>, unread: Object<string, number>,
|
|
382
|
+
* premiseFailures: Object<string, string>, total: number}}
|
|
383
|
+
* `unread` is the operator's answer to "what does this report not read?" and a container
|
|
384
|
+
* lands there for one of TWO reasons; `premiseFailures` says which, keyed on the same
|
|
385
|
+
* container, because the repairs are opposite — an unknown container needs a reader
|
|
386
|
+
* written, a withdrawn carve-out needs its justification re-argued or removed.
|
|
387
|
+
*/
|
|
388
|
+
/**
|
|
389
|
+
* HOW MANY OBJECTS A BROKEN PREMISE ACTUALLY LEAVES UNREAD.
|
|
390
|
+
*
|
|
391
|
+
* ⚠️ THE REPAIR FOR ONE FALSE CLEAN INTRODUCED A FALSE QUANTITY, and the number reaches a
|
|
392
|
+
* CUSTOMER. `unread[k]` used to be the container's WHOLE population, and `executive_report.mjs`
|
|
393
|
+
* renders it as "N recorded finding-like object(s) … are NOT included above". For a MIRROR
|
|
394
|
+
* carve-out that breaks PARTIALLY, most of those objects ARE rendered above — plugin 1150 emits
|
|
395
|
+
* `data: findings`, so 16 mirrored rows and one orphan told a client that 17 objects were missing
|
|
396
|
+
* from a report containing 16 of them. **A caveat that contradicts the body of its own document.**
|
|
397
|
+
*
|
|
398
|
+
* ⚠️ AND `unreadCount ?? population` IS NOT ENOUGH — that only catches null/undefined. A verifier
|
|
399
|
+
* returning `0` on a real break, or `"3"`, or a count ABOVE the population, would put a garbage
|
|
400
|
+
* number into a client-facing sentence. Every one of those degrades to the population instead:
|
|
401
|
+
* **over-report, never emit a silent or impossible number.** `0` is deliberately refused rather
|
|
402
|
+
* than trusted — a failure claiming nothing is unread is a verifier bug silencing itself.
|
|
106
403
|
*/
|
|
404
|
+
export function resolveUnreadCount(failure, population) {
|
|
405
|
+
const n = failure?.unreadCount;
|
|
406
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0 || n > population) return population;
|
|
407
|
+
return n;
|
|
408
|
+
}
|
|
409
|
+
|
|
107
410
|
export function censusFindingContainers(raw, queue = []) {
|
|
108
411
|
const byContainer = {};
|
|
109
412
|
const bump = (k) => { byContainer[k] = (byContainer[k] || 0) + 1; };
|
|
110
413
|
|
|
111
|
-
for (const e of (
|
|
414
|
+
for (const e of resultsOf(raw)) {
|
|
112
415
|
for (const { path } of severityObjects(e?.result ?? {}, '')) bump(containerOf(path));
|
|
113
416
|
}
|
|
114
417
|
if (Array.isArray(queue) && queue.length) byContainer[QUEUE_CONTAINER] = queue.length;
|
|
115
418
|
|
|
116
419
|
const unread = {};
|
|
420
|
+
const premiseFailures = {};
|
|
117
421
|
for (const [k, n] of Object.entries(byContainer)) {
|
|
118
422
|
if (READ_CONTAINERS.includes(k)) continue;
|
|
119
|
-
if (Object.prototype.hasOwnProperty.call(ALLOWLISTED_CONTAINERS, k)) continue;
|
|
120
|
-
|
|
423
|
+
if (!Object.prototype.hasOwnProperty.call(ALLOWLISTED_CONTAINERS, k)) { unread[k] = n; continue; }
|
|
424
|
+
// The premise is only adjudicated where something is actually being carved out: running
|
|
425
|
+
// it over a container this record does not carry would report on nothing.
|
|
426
|
+
const failure = adjudicateAllowlistEntry(ALLOWLISTED_CONTAINERS[k], raw, queue);
|
|
427
|
+
if (failure === null) continue;
|
|
428
|
+
// The BREAK, not the container — see resolveUnreadCount for why the count is validated
|
|
429
|
+
// rather than trusted, and why the population stays in `byContainer` where it belongs.
|
|
430
|
+
unread[k] = resolveUnreadCount(failure, n);
|
|
431
|
+
// `premiseFailures` stays a map of container -> DETAIL STRING: it is the operator- and
|
|
432
|
+
// reader-facing half, and the count already lives in `unread`. Putting the verdict OBJECT
|
|
433
|
+
// here would change a published shape for no gain and hand two numbers to a consumer that
|
|
434
|
+
// needs one.
|
|
435
|
+
premiseFailures[k] = failure.detail;
|
|
121
436
|
}
|
|
122
|
-
return { byContainer, unread, total: Object.values(byContainer).reduce((a, b) => a + b, 0) };
|
|
437
|
+
return { byContainer, unread, premiseFailures, total: Object.values(byContainer).reduce((a, b) => a + b, 0) };
|
|
123
438
|
}
|
package/utils/report_inputs.mjs
CHANGED
|
@@ -384,7 +384,14 @@ async function finishLoadingRecord(outRoot, rec, allowPartial) {
|
|
|
384
384
|
// container announces itself on the first render, which is how all three of the doors
|
|
385
385
|
// found tonight SHOULD have been found.
|
|
386
386
|
const census = censusFindingContainers(raw, findingQueue);
|
|
387
|
-
|
|
387
|
+
// ⚠️ `premiseFailures` TRAVELS WITH THE COUNTS, because the two reasons a container is
|
|
388
|
+
// unread need DIFFERENT sentences. A container nobody wrote a reader for is genuinely NOT
|
|
389
|
+
// IN the report; a carve-out whose premise BROKE has most of its objects rendered and only
|
|
390
|
+
// the break unread. Handing the consumer one number and no reason is what let the report
|
|
391
|
+
// tell a client that 17 objects were missing from a document containing 16 of them.
|
|
392
|
+
if (Object.keys(census.unread).length) {
|
|
393
|
+
unreadByHost.push({ host, unread: census.unread, premiseFailures: census.premiseFailures });
|
|
394
|
+
}
|
|
388
395
|
hosts.push(shapeHost(host, dir, { ...raw, __findingQueue: findingQueue }));
|
|
389
396
|
}
|
|
390
397
|
|