infracensus-collector 1.2.0 → 1.3.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/dist/a10-index.js +2 -2
- package/dist/{archive-O5IFKUA6.js → archive-RCFXOPI6.js} +2 -2
- package/dist/archive-RCFXOPI6.js.map +7 -0
- package/dist/azure-index.js +6 -6
- package/dist/{chunk-O53NHWBB.js → chunk-4MDLMXYN.js} +4 -4
- package/dist/chunk-4MDLMXYN.js.map +7 -0
- package/dist/{chunk-ZR6RYRL4.js → chunk-6GUY42OX.js} +2 -2
- package/dist/{chunk-O3Z4CFCD.js → chunk-C5MDCEUD.js} +3 -3
- package/dist/{chunk-O3Z4CFCD.js.map → chunk-C5MDCEUD.js.map} +1 -1
- package/dist/{chunk-ZKIAAYO7.js → chunk-GD4YRP6B.js} +2 -2
- package/dist/{chunk-C5F6AXVA.js → chunk-I6CYXRMJ.js} +2 -2
- package/dist/{chunk-JANRTRX2.js → chunk-JB7TTPKL.js} +3 -3
- package/dist/{chunk-OXLPM65U.js → chunk-JYSJ52ZS.js} +2 -2
- package/dist/{chunk-2F5HE7ZP.js → chunk-NM4U7JPT.js} +2 -2
- package/dist/{chunk-YLXKRAN7.js → chunk-P635KMDV.js} +2 -2
- package/dist/{chunk-V2QWEC2M.js → chunk-QNHCMF7Z.js} +2 -2
- package/dist/{chunk-LUQXJAXW.js → chunk-RCSMKY5W.js} +20 -2
- package/dist/chunk-RCSMKY5W.js.map +7 -0
- package/dist/{chunk-FTSH2KIW.js → chunk-UQJQKUAU.js} +2 -2
- package/dist/{chunk-JSSRGIHN.js → chunk-XAV5QW4Y.js} +3 -3
- package/dist/{chunk-JSSRGIHN.js.map → chunk-XAV5QW4Y.js.map} +1 -1
- package/dist/{chunk-DHDRRYOE.js → chunk-ZTA7536B.js} +2 -2
- package/dist/cli.js +5 -5
- package/dist/{config-PRZDIGWJ.js → config-WRCKIKEN.js} +3 -3
- package/dist/configimport-index.js +4 -4
- package/dist/hyperv-index.js +6 -6
- package/dist/identity-index.js +6 -6
- package/dist/index.js +459 -98
- package/dist/index.js.map +2 -2
- package/dist/{pdf-JFKD73IQ.js → pdf-7V5KSOI3.js} +1 -1
- package/dist/{pdf-JFKD73IQ.js.map → pdf-7V5KSOI3.js.map} +1 -1
- package/dist/sensitive-data-index.js +6 -6
- package/dist/sensitive-data-index.js.map +1 -1
- package/dist/sql-index.js +6 -6
- package/dist/{status-JRWSNR5R.js → status-3XYANSK6.js} +5 -5
- package/dist/vmware-index.js +6 -6
- package/dist/voice-index.js +6 -6
- package/package.json +1 -1
- package/dist/archive-O5IFKUA6.js.map +0 -7
- package/dist/chunk-LUQXJAXW.js.map +0 -7
- package/dist/chunk-O53NHWBB.js.map +0 -7
- /package/dist/{chunk-ZR6RYRL4.js.map → chunk-6GUY42OX.js.map} +0 -0
- /package/dist/{chunk-ZKIAAYO7.js.map → chunk-GD4YRP6B.js.map} +0 -0
- /package/dist/{chunk-C5F6AXVA.js.map → chunk-I6CYXRMJ.js.map} +0 -0
- /package/dist/{chunk-JANRTRX2.js.map → chunk-JB7TTPKL.js.map} +0 -0
- /package/dist/{chunk-OXLPM65U.js.map → chunk-JYSJ52ZS.js.map} +0 -0
- /package/dist/{chunk-2F5HE7ZP.js.map → chunk-NM4U7JPT.js.map} +0 -0
- /package/dist/{chunk-YLXKRAN7.js.map → chunk-P635KMDV.js.map} +0 -0
- /package/dist/{chunk-V2QWEC2M.js.map → chunk-QNHCMF7Z.js.map} +0 -0
- /package/dist/{chunk-FTSH2KIW.js.map → chunk-UQJQKUAU.js.map} +0 -0
- /package/dist/{chunk-DHDRRYOE.js.map → chunk-ZTA7536B.js.map} +0 -0
- /package/dist/{config-PRZDIGWJ.js.map → config-WRCKIKEN.js.map} +0 -0
- /package/dist/{status-JRWSNR5R.js.map → status-3XYANSK6.js.map} +0 -0
package/dist/a10-index.js
CHANGED
|
@@ -4,10 +4,10 @@ import {
|
|
|
4
4
|
} from "./chunk-MN4JODQW.js";
|
|
5
5
|
import {
|
|
6
6
|
requireEntitlementToScan
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-ZTA7536B.js";
|
|
8
8
|
import {
|
|
9
9
|
ROUTES
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-RCSMKY5W.js";
|
|
11
11
|
import "./chunk-CW3YCPEC.js";
|
|
12
12
|
|
|
13
13
|
// src/a10-index.ts
|
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
extensionKind,
|
|
3
3
|
extractOpenXml,
|
|
4
4
|
extractPlainText
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-C5MDCEUD.js";
|
|
6
6
|
import {
|
|
7
7
|
checkEntryPath,
|
|
8
8
|
checkEntrySize
|
|
@@ -67,4 +67,4 @@ function extractArchive(bytes) {
|
|
|
67
67
|
export {
|
|
68
68
|
extractArchive
|
|
69
69
|
};
|
|
70
|
-
//# sourceMappingURL=archive-
|
|
70
|
+
//# sourceMappingURL=archive-RCFXOPI6.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/sensitiveData/archive.ts"],
|
|
4
|
+
"sourcesContent": ["import { unzipSync } from \"fflate\";\nimport { checkEntryPath, checkEntrySize, type SizeBudget } from \"@netdoc/config-ir\";\nimport { extensionKind, extractOpenXml, extractPlainText, type ExtractOutcome } from \"./extract.js\";\n\n/**\n * Archives (PLAN-010 Phase 6).\n *\n * \u2500\u2500 WHY A ZIP IS WORTH OPENING AT ALL \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * \"Records_2019.zip\" on a share is the single most likely place an old export\n * of a whole roster is sitting. It is what somebody made when they cleared a\n * folder, and it is exactly the material that outlives the retention policy\n * everybody has forgotten. Leaving archives unread means the module's coverage\n * number is honest and its FINDINGS are systematically missing the worst files.\n *\n * \u2500\u2500 AND WHY IT IS THE MOST DANGEROUS THING THIS MODULE DOES \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * An archive is attacker-controlled input that expands. Anyone who can write to\n * a share can leave a 42 KB file that becomes several petabytes, and the scan\n * runs unattended, at night, on a machine inside the customer's network. So\n * nothing here is written fresh: it reuses `@netdoc/config-ir`'s archive guards,\n * which already carry the per-file cap, the entry cap, the total-expansion cap\n * and the compression-ratio ceiling, threaded through one budget so an archive\n * is refused the MOMENT it crosses a limit rather than after it has expanded.\n *\n * `checkEntryPath` is the other half: an entry named `..\\..\\windows\\system32`\n * is a zip-slip attempt, and this never writes to disk \u2014 but the path becomes a\n * REPORTED path, so a crafted name would land in an audit row and an export.\n *\n * \u2500\u2500 WHAT IS NOT DONE, AND SAID RATHER THAN IMPLIED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n *\n * Nested archives are NOT opened. One level only. A zip inside a zip is the\n * classic amplification shape, and the guards above bound each level but not\n * their product \u2014 an archive of a thousand archives of a thousand archives is\n * within every per-archive limit and is unbounded overall. Depth is the cheap,\n * total answer, and the inner archive is reported as an unread entry rather\n * than silently ignored.\n *\n * Encrypted zips are refused as `encrypted`, not as corrupt. \"1,900\n * password-protected archives\" is a finding; \"1,900 corrupt files\" is noise.\n */\n\n\n/** Entries read per archive, over and above the shared budget's entry cap. */\nconst MAX_TEXT_ENTRIES = 500;\n\nexport interface ArchiveEntry {\n /** Path INSIDE the archive, as reported. Never written to disk. */\n path: string;\n text: string;\n}\n\nexport interface ArchiveOutcome {\n entries: ArchiveEntry[];\n /** Entries that were not read, by reason, so the gap is countable. */\n unread: Record<string, number>;\n /** True when a cap stopped the walk of this archive before it finished. */\n truncated: boolean;\n}\n\nconst note = (unread: Record<string, number>, reason: string): void => {\n unread[reason] = (unread[reason] ?? 0) + 1;\n};\n\n/**\n * Read the text entries of one archive.\n *\n * Returns entries rather than one concatenated blob so the caller can attribute\n * a finding to `Records_2019.zip \u2192 registrar/2018.csv`. A single joined string\n * would report the archive as the file holding an SSN, which is true and\n * useless: nobody can act on it without opening the archive themselves.\n */\nexport function extractArchive(bytes: Uint8Array): ArchiveOutcome {\n const entries: ArchiveEntry[] = [];\n const unread: Record<string, number> = {};\n let truncated = false;\n\n // The shared budget, threaded through every entry. This is what makes the\n // guard incremental rather than a post-hoc check on something already\n // expanded in memory.\n const budget: SizeBudget = { entries: 0, uncompressedBytes: 0 };\n\n let files: Record<string, Uint8Array>;\n try {\n files = unzipSync(bytes);\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n // fflate has no encrypted-zip support and fails on the flag. Distinguished\n // because a password-protected archive is a finding an operator can act on\n // and a corrupt one is not.\n note(unread, /encrypt|password/i.test(message) ? \"encrypted\" : \"corrupt\");\n return { entries, unread, truncated };\n }\n\n for (const [rawPath, content] of Object.entries(files)) {\n // Directory entries. Not a gap; nothing was ever going to be read from one.\n if (rawPath.endsWith(\"/\") || content.length === 0) continue;\n\n if (entries.length >= MAX_TEXT_ENTRIES) {\n truncated = true;\n break;\n }\n\n // Zip-slip. Never written to disk here \u2014 but the path is REPORTED, and a\n // crafted name would land in an audit row and an export.\n const pathCheck = checkEntryPath(rawPath);\n if (!pathCheck.ok) {\n note(unread, pathCheck.reason ?? \"unsafe-path\");\n continue;\n }\n\n // The compressed size is not exposed per entry by `unzipSync`, so the\n // ratio check cannot run per entry here. The per-file, entry-count and\n // total-expansion caps all still apply \u2014 and the whole archive was already\n // bounded by the walk's own `maxFileBytes` before it was ever read.\n const sizeCheck = checkEntrySize(content.length, undefined, budget);\n if (!sizeCheck.ok) {\n note(unread, sizeCheck.reason ?? \"too-large\");\n // A budget failure is about the ARCHIVE, not this entry: once it is\n // exceeded every later entry fails too. Stop, and say the read was\n // truncated rather than counting a thousand identical refusals.\n truncated = true;\n break;\n }\n\n const extension = rawPath.slice(rawPath.lastIndexOf(\".\") + 1).toLowerCase();\n const kind = extensionKind(extension);\n\n if (kind === \"ignore\") continue;\n\n if (extension === \"zip\") {\n // ALWAYS refused, never conditionally. This started as a depth counter\n // and the counter was the bug: at the only depth it is ever called with\n // it took the other branch, so a zip inside a zip was reported as an\n // unsupported format rather than as the nested archive it is. A knob\n // with one possible value is worse than no knob \u2014 it reads as a policy\n // that can be tuned and behaves as one that cannot.\n //\n // The policy itself is in the header: the guards bound each LEVEL, not\n // their product, so an archive of archives is within every per-archive\n // limit and unbounded overall. One level is the cheap total answer.\n note(unread, \"nested-archive\");\n continue;\n }\n\n // PDF inside an archive is skipped rather than parsed: `extractPdf` is\n // async and everything here is synchronous, and making this async to reach\n // it would put an await inside the bomb guard's loop for a case that is\n // rare. Counted, so the gap is visible rather than assumed away.\n if (kind === \"pdf\" || kind === \"unsupported\") {\n note(unread, \"unsupported-format\");\n continue;\n }\n\n const outcome: ExtractOutcome =\n kind === \"openxml\" ? extractOpenXml(content, extension) : extractPlainText(content);\n\n if (outcome.kind === \"text\") {\n entries.push({ path: rawPath, text: outcome.text });\n } else if (outcome.kind === \"unreadable\") {\n note(unread, outcome.reason);\n }\n }\n\n return { entries, unread, truncated };\n}\n"],
|
|
5
|
+
"mappings": ";;;;;;;;;;;AAAA,SAAS,iBAAiB;AA4C1B,IAAM,mBAAmB;AAgBzB,IAAM,OAAO,CAAC,QAAgC,WAAyB;AACrE,SAAO,MAAM,KAAK,OAAO,MAAM,KAAK,KAAK;AAC3C;AAUO,SAAS,eAAe,OAAmC;AAChE,QAAM,UAA0B,CAAC;AACjC,QAAM,SAAiC,CAAC;AACxC,MAAI,YAAY;AAKhB,QAAM,SAAqB,EAAE,SAAS,GAAG,mBAAmB,EAAE;AAE9D,MAAI;AACJ,MAAI;AACF,YAAQ,UAAU,KAAK;AAAA,EACzB,SAAS,KAAK;AACZ,UAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAI/D,SAAK,QAAQ,oBAAoB,KAAK,OAAO,IAAI,cAAc,SAAS;AACxE,WAAO,EAAE,SAAS,QAAQ,UAAU;AAAA,EACtC;AAEA,aAAW,CAAC,SAAS,OAAO,KAAK,OAAO,QAAQ,KAAK,GAAG;AAEtD,QAAI,QAAQ,SAAS,GAAG,KAAK,QAAQ,WAAW,EAAG;AAEnD,QAAI,QAAQ,UAAU,kBAAkB;AACtC,kBAAY;AACZ;AAAA,IACF;AAIA,UAAM,YAAY,eAAe,OAAO;AACxC,QAAI,CAAC,UAAU,IAAI;AACjB,WAAK,QAAQ,UAAU,UAAU,aAAa;AAC9C;AAAA,IACF;AAMA,UAAM,YAAY,eAAe,QAAQ,QAAQ,QAAW,MAAM;AAClE,QAAI,CAAC,UAAU,IAAI;AACjB,WAAK,QAAQ,UAAU,UAAU,WAAW;AAI5C,kBAAY;AACZ;AAAA,IACF;AAEA,UAAM,YAAY,QAAQ,MAAM,QAAQ,YAAY,GAAG,IAAI,CAAC,EAAE,YAAY;AAC1E,UAAM,OAAO,cAAc,SAAS;AAEpC,QAAI,SAAS,SAAU;AAEvB,QAAI,cAAc,OAAO;AAWvB,WAAK,QAAQ,gBAAgB;AAC7B;AAAA,IACF;AAMA,QAAI,SAAS,SAAS,SAAS,eAAe;AAC5C,WAAK,QAAQ,oBAAoB;AACjC;AAAA,IACF;AAEA,UAAM,UACJ,SAAS,YAAY,eAAe,SAAS,SAAS,IAAI,iBAAiB,OAAO;AAEpF,QAAI,QAAQ,SAAS,QAAQ;AAC3B,cAAQ,KAAK,EAAE,MAAM,SAAS,MAAM,QAAQ,KAAK,CAAC;AAAA,IACpD,WAAW,QAAQ,SAAS,cAAc;AACxC,WAAK,QAAQ,QAAQ,MAAM;AAAA,IAC7B;AAAA,EACF;AAEA,SAAO,EAAE,SAAS,QAAQ,UAAU;AACtC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/azure-index.js
CHANGED
|
@@ -2,18 +2,18 @@ import {
|
|
|
2
2
|
ArmClient,
|
|
3
3
|
buildAzureInventory,
|
|
4
4
|
listAccessibleSubscriptions
|
|
5
|
-
} from "./chunk-
|
|
5
|
+
} from "./chunk-JYSJ52ZS.js";
|
|
6
6
|
import {
|
|
7
7
|
pushAzureInventory
|
|
8
|
-
} from "./chunk-
|
|
9
|
-
import "./chunk-
|
|
8
|
+
} from "./chunk-JB7TTPKL.js";
|
|
9
|
+
import "./chunk-6GUY42OX.js";
|
|
10
10
|
import {
|
|
11
11
|
loadAzureConfig
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-GD4YRP6B.js";
|
|
13
13
|
import {
|
|
14
14
|
requireEntitlementToScan
|
|
15
|
-
} from "./chunk-
|
|
16
|
-
import "./chunk-
|
|
15
|
+
} from "./chunk-ZTA7536B.js";
|
|
16
|
+
import "./chunk-RCSMKY5W.js";
|
|
17
17
|
import "./chunk-CW3YCPEC.js";
|
|
18
18
|
|
|
19
19
|
// src/azure-index.ts
|
|
@@ -5,11 +5,11 @@ import {
|
|
|
5
5
|
} from "./chunk-E45UL3V3.js";
|
|
6
6
|
import {
|
|
7
7
|
configFilePath
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-GD4YRP6B.js";
|
|
9
9
|
import {
|
|
10
10
|
collectorEntitlementStatus,
|
|
11
11
|
collectorOwnsModule
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-ZTA7536B.js";
|
|
13
13
|
import {
|
|
14
14
|
PKG_ROOT
|
|
15
15
|
} from "./chunk-CW3YCPEC.js";
|
|
@@ -245,7 +245,7 @@ function condenseLogTail(lines) {
|
|
|
245
245
|
function pendingFor(domain, queue) {
|
|
246
246
|
if (domain === null || queue === void 0) return null;
|
|
247
247
|
if (queue.running === domain) return "running";
|
|
248
|
-
if (queue.queued
|
|
248
|
+
if (queue.queued.includes(domain)) return "queued";
|
|
249
249
|
return null;
|
|
250
250
|
}
|
|
251
251
|
function buildUiStatus(src) {
|
|
@@ -310,4 +310,4 @@ export {
|
|
|
310
310
|
condenseLogTail,
|
|
311
311
|
buildUiStatus
|
|
312
312
|
};
|
|
313
|
-
//# sourceMappingURL=chunk-
|
|
313
|
+
//# sourceMappingURL=chunk-4MDLMXYN.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/ui/status.ts", "../src/core/useLog.ts", "../src/entitlement/selection.ts"],
|
|
4
|
+
"sourcesContent": ["/**\r\n * What the UI's status pane says \u2014 assembled from records that ALREADY exist.\r\n *\r\n * The rule for this file is: invent NO new persistence. Every field is read\r\n * from something the collector already writes for its own reasons \u2014\r\n *\r\n * version package.json, the same answer `--version` gives\r\n * enrolled the loaded config (endpoint + this collector's id)\r\n * daemon timestamps an IN-MEMORY record the daemon stamps as it works\r\n * modules[] the use-log ledger (`core/useLog.ts`) \u2014 the customer's\r\n * own evidence file, folded per module\r\n * logTail `collector-daemon.log`, the transcript the ctl script\r\n * and the re-exec path already append to\r\n *\r\n * \u2500\u2500 WHY THE DAEMON TIMESTAMPS ARE MEMORY, NOT A FILE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * The UI server runs INSIDE the daemon process (started by `src/index.ts` in\r\n * daemon mode), so \"where the daemon records it\" can literally be a module\r\n * variable the daemon stamps and this file reads. A state file would be a new\r\n * artifact on the customer's disk for data whose whole meaning is \"this\r\n * process, now\": after a restart the honest value of lastPollAt IS unknown \u2014\r\n * a file would refill it with a stale answer and make the status pane lie\r\n * about a daemon that has not actually polled yet.\r\n */\r\nimport { existsSync, readFileSync, readdirSync } from \"node:fs\";\r\nimport { dirname, join } from \"node:path\";\r\nimport { configFilePath } from \"../config.js\";\r\nimport { defaultUseLogPath } from \"../core/useLog.js\";\r\nimport type { ScanDomain } from \"@netdoc/collector-contract\";\r\nimport { COLLECTOR_MODULES, findModule } from \"./modules.js\";\r\nimport { readDisabledModules } from \"../entitlement/selection.js\";\r\nimport {\r\n collectorEntitlementStatus,\r\n collectorOwnsModule,\r\n type CollectorEntitlementStatus,\r\n} from \"../entitlement/index.js\";\r\n// Found, not hop-counted: the bundled build flattens this file into dist/, so\r\n// a dirname() count that was right in src/ would be wrong in the artifact.\r\nimport { PKG_ROOT } from \"../pkgRoot.js\";\r\n\r\n// ---------------------------------------------------------------------------\r\n// The daemon's live record. `src/index.ts` stamps it; nothing else writes it.\r\n// ---------------------------------------------------------------------------\r\n\r\nexport interface DaemonRuntime {\r\n /** When THIS daemon process entered daemon mode. Null in one-shot runs. */\r\n startedAt: string | null;\r\n /** Last time a poll of the control plane COMPLETED (claimed a job or not). */\r\n lastPollAt: string | null;\r\n /** Last time any data left this host for the control plane (ledger `data-sent`). */\r\n lastPushAt: string | null;\r\n}\r\n\r\nexport const daemonRuntime: DaemonRuntime = {\r\n startedAt: null,\r\n lastPollAt: null,\r\n lastPushAt: null,\r\n};\r\n\r\nexport function markDaemonStarted(): void {\r\n daemonRuntime.startedAt = new Date().toISOString();\r\n}\r\nexport function markDaemonPoll(): void {\r\n daemonRuntime.lastPollAt = new Date().toISOString();\r\n}\r\nexport function markDaemonPush(): void {\r\n daemonRuntime.lastPushAt = new Date().toISOString();\r\n}\r\n\r\n// ---------------------------------------------------------------------------\r\n// The status payload.\r\n// ---------------------------------------------------------------------------\r\n\r\nexport interface UiModuleStatus {\r\n key: string;\r\n label: string;\r\n /** `ts` of the module's most recent `run-started` ledger record, or null. */\r\n lastRunAt: string | null;\r\n /** true = run-finished, false = run-failed, null = no record / still running. */\r\n ok: boolean | null;\r\n summary: string | null;\r\n}\r\n\r\n/**\r\n * A module row as the STATUS PAYLOAD carries it: the ledger fold plus whether\r\n * the tenant currently OWNS the module.\r\n *\r\n * `owned` is deliberately NOT part of `UiModuleStatus` \u2014 that shape is the pure\r\n * fold over the customer's ledger and knows nothing about entitlement, and\r\n * `cli.ts status` reads it as such. Ownership is a gate answer, decided in\r\n * `buildUiStatus` against the process gate, so it rides on this richer row only\r\n * where the page needs it. The page enables a \"Run scan\" button only when the\r\n * collector may scan AND `owned` is true; an unowned module renders as\r\n * not-available, which is the one bit of the plane's list the UI is handed \u2014\r\n * never the list itself.\r\n */\r\n/**\r\n * `enabled` is the OPERATOR's own switch, and it is a different fact from\r\n * `owned`. The plane decides what may be scanned; this decides what this\r\n * collector is asked to scan of that. The page needs both to explain a row: a\r\n * module can be unavailable (not bought) or simply switched off here, and one\r\n * of those is fixed in the portal while the other is fixed on this screen.\r\n */\r\n/**\r\n * `pending` is the third fact, and it is the only one that is not on disk.\r\n *\r\n * The other two rows fold the LEDGER, and the ledger's first record of a run is\r\n * `run-started` \u2014 which the daemon writes when it picks the job up, not when the\r\n * operator asks for it. Between the click and that record there is a gap of a\r\n * daemon tick plus a status poll, and for the whole of it every column on the\r\n * row still reads exactly as it did before the click: \"never\", \"\u2013\", no summary.\r\n * The operator's own report of the defect was \"it feels like nothing happened\",\r\n * which is precisely right \u2014 nothing observable did.\r\n *\r\n * So the queue's own state rides along. `queued` = submitted, waiting for the\r\n * daemon to take it; `running` = taken, no terminal ledger record yet. It is not\r\n * part of `UiModuleStatus` for the same reason `owned` is not: that shape is the\r\n * pure fold over the customer's ledger, `cli.ts status` reads it as such, and a\r\n * field whose value lives in a daemon's memory has no business in it.\r\n */\r\nexport type UiModuleRow = UiModuleStatus & {\r\n owned: boolean;\r\n enabled: boolean;\r\n pending: \"queued\" | \"running\" | null;\r\n};\r\n\r\nexport interface UiStatus {\r\n version: string;\r\n enrolled: {\r\n endpoint: string;\r\n /**\r\n * The collector's ID. The config deliberately holds no display name \u2014 the\r\n * name lives in the portal, where renaming it must not require touching\r\n * every enrolled host \u2014 so the id is the honest local answer, and it is\r\n * the same string the portal's collector list shows beside the name.\r\n */\r\n collectorName: string;\r\n };\r\n daemon: DaemonRuntime;\r\n modules: UiModuleRow[];\r\n /** Last ~100 lines of the daemon transcript (already redacted at write time). */\r\n logTail: string[];\r\n /**\r\n * Whether this collector is currently permitted to scan, and if not, which of\r\n * the three refusals it is.\r\n *\r\n * \u2500\u2500 WHY THIS IS IN THE STATUS PAYLOAD AND NOT A ROUTE OF ITS OWN \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * The refusal has to reach the operator on WHATEVER screen they are looking\r\n * at, and the status poll is the only thing that runs on every tab every five\r\n * seconds. A separate `/ui/api/entitlement` would be a second request that\r\n * some renders make and others do not, which is how a page ends up showing a\r\n * stale \"all fine\" beside a licence that lapsed ten minutes ago.\r\n *\r\n * `collectorEntitlementStatus()` costs nothing \u2014 no network, no config, no\r\n * gate construction \u2014 and it is explicitly documented as the export a UI\r\n * reads. It answers before anything has started work, and on a host that has\r\n * never confirmed anything it answers `unconfirmed`, which is the truth.\r\n *\r\n * It carries NO secret: a tier name, an expiry, two timestamps, an HTTP\r\n * status and the plane's own sentence. No key, no signature, no licence\r\n * payload. That matters because this route is behind the session and the\r\n * session is behind a cloud login, but the page it feeds is also the thing a\r\n * support engineer is most likely to screenshot.\r\n */\r\n entitlement: CollectorEntitlementStatus;\r\n /**\r\n * What a manual run starts from, before the operator edits it.\r\n *\r\n * The page needs both because the Run button now BUILDS A JOB rather than\r\n * spawning a one-shot process, and a job has to say which site it belongs to\r\n * and (for a network sweep) what to sweep. Sending the collector's own\r\n * configured values means the common case is one click, the all-sites case is\r\n * one field, and neither asks anybody to remember a site id.\r\n *\r\n * Both are routinely empty, and that is not a fault to hide: an all-sites\r\n * collector has no site and enrolment writes no targets. The page renders the\r\n * empty box; `createScanRunner` refuses a run that would still be empty.\r\n */\r\n scanDefaults: { siteId: string; targets: string[] };\r\n}\r\n\r\n/** The two config fields the status pane needs \u2014 never the key. */\r\nexport interface EnrolmentView {\r\n controlPlaneUrl: string;\r\n collectorId: string;\r\n}\r\n\r\nexport interface UiStatusSources {\r\n config: EnrolmentView;\r\n /** The site and targets a manual run pre-fills with. Empty when unset. */\r\n scanDefaults?: { siteId: string; targets: readonly string[] };\r\n /**\r\n * What the Run button's queue is holding right now, by scan DOMAIN \u2014 the\r\n * plane's name for a module, which is what a job carries and what the queue\r\n * therefore knows. `buildUiStatus` maps it back onto module keys through\r\n * `COLLECTOR_MODULES`, so the page never has to hold that mapping.\r\n *\r\n * Omitted by every caller that has no daemon behind it (`cli.ts status`, the\r\n * tests that only want the ledger fold), and an omitted queue means no row is\r\n * pending \u2014 never that the answer is unknown, because a status built without\r\n * a queue is one where nothing could have been submitted.\r\n */\r\n manualRun?: { queued: readonly ScanDomain[]; running: ScanDomain | null };\r\n /** Defaults to the live singleton; injectable for tests. */\r\n runtime?: DaemonRuntime;\r\n /** Defaults to the ledger the daemon writes; injectable for tests. */\r\n ledgerPath?: string;\r\n /** Defaults to `collector-daemon.log` beside the package; injectable for tests. */\r\n daemonLogPath?: string;\r\n /**\r\n * Where the last entitlement verdict was cached. Injectable for the same\r\n * reason every other path here is: a test must be able to show the pane a\r\n * refusal without a control plane. Left undefined in production, where\r\n * `collectorEntitlementStatus` reads the process gate if one exists and the\r\n * default cache file if it does not.\r\n */\r\n entitlementCachePath?: string;\r\n}\r\n\r\n/** How much of the tail of each file is worth reading. Both files rotate/are\r\n * capped elsewhere; this only bounds OUR read so a status poll costs the same\r\n * on day one and day four hundred. */\r\nconst LEDGER_TAIL_BYTES = 512 * 1024;\r\nconst LOG_TAIL_BYTES = 64 * 1024;\r\nconst LOG_TAIL_LINES = 100;\r\n\r\n/** Read the last `maxBytes` of a file as utf8, tolerating its absence. */\r\nfunction readTail(path: string, maxBytes: number): string {\r\n try {\r\n if (!existsSync(path)) return \"\";\r\n const buf = readFileSync(path);\r\n const slice = buf.length > maxBytes ? buf.subarray(buf.length - maxBytes) : buf;\r\n return slice.toString(\"utf8\");\r\n } catch {\r\n // A locked or vanished file yields an empty view, not a broken status page.\r\n return \"\";\r\n }\r\n}\r\n\r\n/** One parsed ledger line. Only the fields the fold below reads. */\r\ninterface LedgerEvent {\r\n ts?: string;\r\n event?: string;\r\n mode?: string;\r\n devices?: number;\r\n durationMs?: number;\r\n reason?: string;\r\n}\r\n\r\n/** Parse JSONL, skipping anything that does not parse \u2014 a truncated first line\r\n * is EXPECTED when reading a byte-bounded tail. */\r\nexport function parseLedgerEvents(text: string): LedgerEvent[] {\r\n const out: LedgerEvent[] = [];\r\n for (const line of text.split(\"\\n\")) {\r\n const trimmed = line.trim();\r\n if (!trimmed) continue;\r\n try {\r\n const parsed = JSON.parse(trimmed) as unknown;\r\n if (parsed && typeof parsed === \"object\") out.push(parsed as LedgerEvent);\r\n } catch {\r\n // Not a complete JSON line (tail cut, or a corrupt write) \u2014 skip it.\r\n }\r\n }\r\n return out;\r\n}\r\n\r\n/**\r\n * Fold ledger events into one row per module.\r\n *\r\n * Matching a run's END to its START leans on a property the daemon actually\r\n * has: jobs run SEQUENTIALLY (`runJob` is awaited in the poll loop), so the\r\n * events between a `run-started` and the next terminal event belong to that\r\n * run. The ledger's `runId` cannot do this job \u2014 the daemon opens ONE ledger\r\n * for its whole life, so every job in a session shares one runId. Concretely:\r\n * from the module's LAST matching `run-started`, the next event that is\r\n * `run-finished`/`run-failed` is its outcome \u2014 unless another `run-started`\r\n * appears first, in which case the run never recorded an end (a crash or a\r\n * kill) and the row says so rather than borrowing the next run's verdict.\r\n */\r\nexport function moduleStatusesFromEvents(events: LedgerEvent[]): UiModuleStatus[] {\r\n return COLLECTOR_MODULES.map((mod) => {\r\n let startIdx = -1;\r\n for (let i = events.length - 1; i >= 0; i--) {\r\n const e = events[i];\r\n if (!e) continue;\r\n if (e.event === \"run-started\" && typeof e.mode === \"string\" && mod.ledgerModes.includes(e.mode)) {\r\n startIdx = i;\r\n break;\r\n }\r\n }\r\n if (startIdx === -1) {\r\n return { key: mod.key, label: mod.label, lastRunAt: null, ok: null, summary: null };\r\n }\r\n const lastRunAt = events[startIdx]?.ts ?? null;\r\n for (let i = startIdx + 1; i < events.length; i++) {\r\n const e = events[i];\r\n if (!e) continue;\r\n if (e.event === \"run-finished\") {\r\n const secs = e.durationMs !== undefined ? `${Math.max(1, Math.round(e.durationMs / 1000))}s` : \"\";\r\n const devices = e.devices ?? 0;\r\n // Domain scans finish with devices 0 (an inventory is not a device\r\n // list); \"completed\" reads better than a false-looking zero.\r\n const summary = devices > 0 ? `${devices} device(s)${secs ? ` in ${secs}` : \"\"}` : `completed${secs ? ` in ${secs}` : \"\"}`;\r\n return { key: mod.key, label: mod.label, lastRunAt, ok: true, summary };\r\n }\r\n if (e.event === \"run-failed\") {\r\n const reason = (e.reason ?? \"failed\").slice(0, 200);\r\n return { key: mod.key, label: mod.label, lastRunAt, ok: false, summary: reason };\r\n }\r\n if (e.event === \"run-started\") {\r\n // A newer run began before this one recorded an end: interrupted.\r\n return { key: mod.key, label: mod.label, lastRunAt, ok: null, summary: \"did not record an end\" };\r\n }\r\n }\r\n // No terminal event yet and nothing newer: still running right now.\r\n return { key: mod.key, label: mod.label, lastRunAt, ok: null, summary: \"running\" };\r\n });\r\n}\r\n\r\n/** Module rows straight from the on-disk ledger (used by `cli.ts status` too). */\r\nexport function moduleStatusesFromLedger(ledgerPath = defaultUseLogPath()): UiModuleStatus[] {\r\n return moduleStatusesFromEvents(parseLedgerEvents(readTail(ledgerPath, LEDGER_TAIL_BYTES)));\r\n}\r\n\r\n/** The version `--version` prints, read from the same file. */\r\nexport function readPackageVersion(): string {\r\n try {\r\n const pkg = JSON.parse(readFileSync(join(PKG_ROOT, \"package.json\"), \"utf8\")) as {\r\n version?: string;\r\n };\r\n return pkg.version ?? \"0.0.0\";\r\n } catch {\r\n return \"0.0.0\";\r\n }\r\n}\r\n\r\n/**\r\n * Which files the log pane is tailing.\r\n *\r\n * \u2500\u2500 WHY THIS IS NOT JUST ONE PATH ANY MORE \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * The pane used to read exactly `collector-daemon.log` beside the package, and\r\n * for a collector started by `collector-ctl` (dev, self-host) that file IS the\r\n * daemon's stdout, so it worked. Under the WINDOWS SERVICE it never did: winsw\r\n * is told to log into `<ProgramData>/InfraCensus Collector/logs`, so the\r\n * daemon's own transcript went there and the pane sat empty \u2014 the only thing\r\n * that ever reached the file it was reading was the output of the one-shot\r\n * children the Run button used to spawn. An operator's whole experience of this\r\n * pane was therefore the failure of those children, which is exactly how a\r\n * configuration error from a doomed child came to read as \"the daemon is\r\n * broken\". Now that no child is spawned, that file would be empty forever.\r\n *\r\n * The service's log directory is derived from `configFilePath()` \u2014 the same one\r\n * place that already answers \"where does this collector keep its state\", and\r\n * the value the service registration itself sets (`COLLECTOR_CONFIG`) \u2014 rather\r\n * than a second copy of the ProgramData path. A second copy is how the two stop\r\n * agreeing on a host where the operator moved either one.\r\n *\r\n * BOTH streams are read, because the logger splits by level: info and debug go\r\n * to stdout, warn and error to stderr, and winsw keeps those in separate files.\r\n * Tailing one would hide precisely the lines an operator opens this pane for.\r\n */\r\nexport function daemonLogPaths(explicit?: string): string[] {\r\n if (explicit) return [explicit];\r\n // An operator (or a supervisor unit) that redirected the daemon somewhere of\r\n // its own gets the last word, and no guessing.\r\n const chosen = process.env.COLLECTOR_LOG_FILE;\r\n if (chosen) return [chosen];\r\n\r\n if (process.platform === \"win32\") {\r\n // Enumerated, never guessed by name: winsw derives its filenames from a\r\n // service id this file does not own, and the last version of this code that\r\n // hand-guessed a name list found nothing and said nothing (see\r\n // `service/windows.ts`, which reads its own logs the same way).\r\n const dir = join(dirname(configFilePath()), \"logs\");\r\n try {\r\n const found = readdirSync(dir)\r\n .filter((name) => /\\.(out|err)\\.log$/i.test(name))\r\n .map((name) => join(dir, name));\r\n if (found.length > 0) return found;\r\n } catch {\r\n // No directory, or no permission to list it: fall through to the file the\r\n // dev/self-host launcher writes.\r\n }\r\n }\r\n return [join(PKG_ROOT, \"collector-daemon.log\")];\r\n}\r\n\r\n/**\r\n * Merge log lines from several files into one transcript.\r\n *\r\n * \u2500\u2500 ONE FILE IS NEVER REORDERED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * A single file's own order IS the transcript, and sorting it would be a\r\n * lie dressed as tidiness: timestamps repeat within a millisecond, and they go\r\n * BACKWARDS across a clock adjustment, so a sort turns a correct sequence into\r\n * a plausible-looking wrong one. The interleave below exists only because winsw\r\n * splits one stream into two files (stdout and stderr), and putting those back\r\n * together needs some ordering key.\r\n *\r\n * That key is the ISO timestamp every line starts with (`makeLogger`). A line\r\n * without one \u2014 a stack trace's continuation, a library writing straight to the\r\n * stream \u2014 inherits the timestamp of the line above it, so a multi-line error\r\n * stays in one piece instead of being scattered to the top of the pane.\r\n */\r\nfunction mergeLogLines(texts: string[]): string[] {\r\n const nonEmpty = texts.filter((t) => t.length > 0);\r\n if (nonEmpty.length <= 1) {\r\n return (nonEmpty[0] ?? \"\").split(\"\\n\").filter((l) => l.length > 0);\r\n }\r\n\r\n const stamped: Array<{ at: number; seq: number; line: string }> = [];\r\n let seq = 0;\r\n for (const text of nonEmpty) {\r\n let last = 0;\r\n for (const line of text.split(\"\\n\")) {\r\n if (line.length === 0) continue;\r\n const iso = /^(\\d{4}-\\d{2}-\\d{2}T[\\d:.]+Z)/.exec(line);\r\n const at = iso ? Date.parse(iso[1] as string) : last;\r\n last = Number.isFinite(at) ? at : last;\r\n stamped.push({ at: last, seq: seq++, line });\r\n }\r\n }\r\n // Stable: equal timestamps keep the order they were read in, which is what\r\n // keeps a burst of same-millisecond lines from one file readable.\r\n stamped.sort((a, b) => (a.at === b.at ? a.seq - b.seq : a.at - b.at));\r\n return stamped.map((s) => s.line);\r\n}\r\n\r\n/**\r\n * How much of one log entry the pane carries.\r\n *\r\n * The pane is a glance, not an archive: the whole line is in the log file, and\r\n * `netdoc-collector uselog` reads the ledger. What broke this was a single AD\r\n * error arriving as nine wrapped lines of nested PowerShell exceptions, which\r\n * pushed everything an operator actually wanted out of a hundred-line window.\r\n */\r\nconst LOG_ENTRY_MAX_CHARS = 240;\r\n\r\n/** `2026-09-04T22:09:05.636Z WARN message` \u2014 the shape `makeLogger` writes. */\r\nconst LOG_ENTRY = /^(\\d{4}-\\d{2}-\\d{2}T(\\d{2}:\\d{2}:\\d{2})[\\d.]*Z)\\s+(DEBUG|INFO|WARN|ERROR)\\s+([\\s\\S]*)$/;\r\n\r\n/**\r\n * Turn raw log lines into what the pane shows: NEWEST FIRST, one line per\r\n * event, trimmed.\r\n *\r\n * Three things happen here, and each answers something an operator said about\r\n * reading this pane:\r\n *\r\n * - **Newest first.** The interesting line is the one that just happened, and\r\n * it was at the bottom of a scrolling box that had to be chased.\r\n * - **One event, one line.** A continuation line \u2014 a wrapped exception, a\r\n * stack frame \u2014 has no timestamp of its own, and belongs to the entry above\r\n * it. Folded in, an error that spans nine lines stops costing nine of the\r\n * hundred the pane holds.\r\n * - **Trimmed.** What is kept is time, level and the start of the message,\r\n * which is where the meaning is; the rest is in the log file, unchanged.\r\n *\r\n * The clock is kept as HH:MM:SS: the date is on every line and identical on\r\n * almost all of them, and it is the seconds that tell an operator whether the\r\n * thing they are watching just happened.\r\n */\r\nexport function condenseLogTail(lines: readonly string[]): string[] {\r\n const entries: Array<{ prefix: string; text: string }> = [];\r\n for (const line of lines) {\r\n const m = LOG_ENTRY.exec(line);\r\n if (m) {\r\n // The level is padded so the messages line up in a column \u2014 the point of\r\n // the pane is scanning it, and a ragged left edge is what makes a WARN\r\n // among forty INFOs hard to find.\r\n entries.push({ prefix: `${m[2]} ${(m[3] ?? \"\").padEnd(5)} `, text: (m[4] ?? \"\").trim() });\r\n continue;\r\n }\r\n // A line the logger did not write (a child process's output, a stack\r\n // frame). It belongs to the entry above; with nothing above, it stands\r\n // alone rather than being dropped \u2014 an unrecognised line is still evidence.\r\n const trimmed = line.trim();\r\n if (trimmed.length === 0) continue;\r\n const last = entries[entries.length - 1];\r\n if (!last) entries.push({ prefix: \"\", text: trimmed });\r\n else last.text += ` ${trimmed}`;\r\n }\r\n return entries.reverse().map(({ prefix, text }) => {\r\n const flat = text.replace(/\\s+/g, \" \").trim();\r\n const room = LOG_ENTRY_MAX_CHARS - prefix.length;\r\n return prefix + (flat.length > room ? `${flat.slice(0, room - 1)}\u2026` : flat);\r\n });\r\n}\r\n\r\n/**\r\n * Which of the two pending states this module is in, if either.\r\n *\r\n * `running` is checked first because a domain cannot be both, and because if a\r\n * bug ever let it be, \"running\" is the answer that stops an operator clicking\r\n * again.\r\n */\r\nfunction pendingFor(\r\n domain: ScanDomain | null,\r\n queue: UiStatusSources[\"manualRun\"],\r\n): \"queued\" | \"running\" | null {\r\n if (domain === null || queue === undefined) return null;\r\n if (queue.running === domain) return \"running\";\r\n // `includes`, because several sites of the SAME domain can be waiting at\r\n // once \u2014 that is what selecting six sites means. The badge says \"queued\"\r\n // whether one or six of them are, which is the honest answer for a button\r\n // whose only job is to stop the operator clicking again.\r\n if (queue.queued.includes(domain)) return \"queued\";\r\n return null;\r\n}\r\n\r\nexport function buildUiStatus(src: UiStatusSources): UiStatus {\r\n const runtime = src.runtime ?? daemonRuntime;\r\n const logLines = mergeLogLines(\r\n daemonLogPaths(src.daemonLogPath).map((path) => readTail(path, LOG_TAIL_BYTES)),\r\n );\r\n // ONE read for the whole page, not one per row.\r\n const disabledModules = readDisabledModules();\r\n\r\n return {\r\n version: readPackageVersion(),\r\n enrolled: {\r\n endpoint: src.config.controlPlaneUrl,\r\n collectorName: src.config.collectorId,\r\n },\r\n // A copy, not the live object: the payload is JSON-serialised on another\r\n // tick, and a snapshot keeps one response internally consistent.\r\n daemon: { ...runtime },\r\n // The ledger fold, each row enriched with whether the tenant OWNS the\r\n // module. Ownership is read fail-closed from the process gate\r\n // (`collectorOwnsModule`): with no confirmed licence \u2014 a fresh process, a\r\n // lapsed one \u2014 every module is `owned: false`, which matches the `entitlement`\r\n // field's own `allowed: false` in the same state, so the page never shows an\r\n // enabled button beside a \"not scanning\" banner. The raw list never reaches\r\n // the wire; only this per-module boolean does.\r\n modules: moduleStatusesFromLedger(src.ledgerPath).map((row) => ({\r\n ...row,\r\n owned: collectorOwnsModule(findModule(row.key)?.moduleKey ?? row.key),\r\n // Matched on the module's DOMAIN, not its key: the queue holds jobs, a job\r\n // carries the plane's name for the scan, and the two differ often enough\r\n // (`lb` is `load-balancer`, `identity` is `cloud-identity`) that comparing\r\n // keys would light up the wrong row on exactly the modules where a wrong\r\n // row is hardest to notice. `config-import` has no domain at all and can\r\n // never match, which is correct \u2014 the console refuses to queue it.\r\n pending: pendingFor(findModule(row.key)?.domain ?? null, src.manualRun),\r\n // Read fresh on every status build rather than cached: the switch is a\r\n // local file an operator may also edit by hand, and a page that showed a\r\n // stale answer would be arguing with the daemon, which reads it per job.\r\n enabled: !disabledModules.has(findModule(row.key)?.moduleKey ?? row.key),\r\n })),\r\n logTail: condenseLogTail(logLines).slice(0, LOG_TAIL_LINES),\r\n entitlement: collectorEntitlementStatus(src.entitlementCachePath),\r\n scanDefaults: {\r\n siteId: src.scanDefaults?.siteId ?? \"\",\r\n targets: [...(src.scanDefaults?.targets ?? [])],\r\n },\r\n };\r\n}\r\n", "/**\r\n * The use log \u2014 a durable local record of what this collector touched.\r\n *\r\n * \u2500\u2500 WHO IT IS FOR \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * The customer, not us. It answers one question \u2014 \"what did this thing touch on\r\n * my network, and when\" \u2014 for somebody who has just been asked to run our\r\n * software on their hardware with their credentials, and who is entitled to\r\n * check the answer themselves. That framing decides every choice below, and it\r\n * is the reason this is not telemetry: nothing here is sent anywhere, and a\r\n * customer who deletes it loses only their own evidence.\r\n *\r\n * \u2500\u2500 WHY IT IS SEPARATE FROM `collector-daemon.log` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * The daemon log is a transcript: raw stdout, every debug line, written for\r\n * whoever is debugging a failure. The two documents want opposite things. An\r\n * evidence ledger has to stay short enough to read and stable enough to cite; a\r\n * transcript has to be exhaustive. Merging them produces a file that is bad at\r\n * both, so the ledger gets its own file and records EVENTS \u2014 a run started,\r\n * these hosts were contacted, this much was sent \u2014 rather than log lines.\r\n *\r\n * \u2500\u2500 FORMAT: JSON LINES \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * One JSON object per line, newline-terminated. \"Readable without our tooling\"\r\n * rules out anything binary or indexed, and the realistic alternatives are plain\r\n * prose and CSV. Prose cannot be filtered; CSV cannot carry events with\r\n * different fields without a column for every field any event might need. JSONL\r\n * is plain text a person can read in Notepad, `grep` for an IP address, and load\r\n * into anything \u2014 while each line stays self-describing, so a line means the\r\n * same thing in isolation as it does in context. Every record carries `v` so a\r\n * later format change is legible rather than silently different.\r\n *\r\n * \u2500\u2500 WHY THE WRITES ARE SYNCHRONOUS \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * The runs an operator most needs evidence for are the ones that ended badly.\r\n * Buffered writes lose exactly that: the process dies and the last minutes \u2014 the\r\n * interesting minutes \u2014 were never flushed. Appending synchronously, one line\r\n * per event, means a `SIGKILL` costs at most the line in flight. Events are\r\n * per-phase and per-contacted-host, not per-packet, so the cost is bounded by\r\n * the size of the estate rather than by the length of the scan.\r\n *\r\n * \u2500\u2500 ROTATION \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * An unbounded log on a customer's jump box is its own incident: it fills a\r\n * disk that other things need, on a machine we do not monitor and cannot see.\r\n * So the ledger has a HARD ceiling \u2014 {@link DEFAULT_MAX_BYTES} per generation \u00D7\r\n * {@link DEFAULT_GENERATIONS} generations, about 25 MB, a number an operator can\r\n * be told in advance and plan around. Rotation is by rename, oldest dropped\r\n * first, so the current file is always the one with the plain name and history\r\n * reads backwards through the numbered ones.\r\n *\r\n * \u2500\u2500 REDACTION \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * Every record is serialised, then redacted, then written \u2014 in that order, so\r\n * the redactor sees the exact bytes that are about to hit the disk rather than\r\n * a field the caller remembered to clean.\r\n */\r\nimport { appendFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from \"node:fs\";\r\nimport { dirname, join } from \"node:path\";\r\nimport { redactLocal, type SecretRegistry } from \"./localRedaction.js\";\r\n// Found, not hop-counted: the bundled build flattens this file into dist/, so\r\n// a dirname() count that was right in src/ would be wrong in the artifact.\r\nimport { PKG_ROOT } from \"../pkgRoot.js\";\r\n\r\n/** Bump when the record shape changes in a way a reader must notice. */\r\nexport const USE_LOG_VERSION = 1;\r\n\r\n/** Per-generation size cap. */\r\nexport const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;\r\n\r\n/**\r\n * How many generations are kept, current file included.\r\n *\r\n * Five \u00D7 5 MB is a ~25 MB ceiling. A record runs a few hundred bytes, so one\r\n * generation holds tens of thousands of events \u2014 months of scanning on an\r\n * ordinary site, which is the horizon over which somebody actually asks \"what\r\n * did this thing do\".\r\n */\r\nexport const DEFAULT_GENERATIONS = 5;\r\n\r\n/**\r\n * Default location: beside the PID file and the daemon log, so everything the\r\n * collector leaves on the host is in one directory an uninstall can enumerate.\r\n * `COLLECTOR_USE_LOG` moves it \u2014 a host with a managed log directory should be\r\n * able to put it there.\r\n */\r\nexport function defaultUseLogPath(): string {\r\n return process.env.COLLECTOR_USE_LOG ?? join(PKG_ROOT, \"collector-use.log\");\r\n}\r\n\r\n/** What a collector did. One of these per line in the ledger. */\r\nexport type UseLogEvent =\r\n /** A scan began: the identity it ran under and the address space it was pointed at. */\r\n | {\r\n event: \"run-started\";\r\n mode: string;\r\n controlPlaneUrl: string;\r\n tenantId: string;\r\n siteId: string;\r\n collectorId: string;\r\n targets: string[];\r\n jobId?: string;\r\n }\r\n /** A stage of the scan reached a point worth citing. */\r\n | { event: \"phase\"; phase: string; done?: number; total?: number; note?: string }\r\n /** A host was contacted over a credentialed protocol, and what came of it. */\r\n | {\r\n event: \"host-contacted\";\r\n protocol: \"ssh\" | \"snmp\";\r\n ip: string;\r\n hostname?: string;\r\n outcome: string;\r\n /** Which credential authenticated \u2014 the username, never the password. */\r\n username?: string;\r\n detail?: string;\r\n }\r\n /** Work the collector declined to do, and the reason it declined. */\r\n | { event: \"skipped\"; what: string; why: string }\r\n /** Something left the building: what, how much, and the receipt for it. */\r\n | { event: \"data-sent\"; kind: string; count: number; destination: string; reference?: string }\r\n /** The run ended. */\r\n | { event: \"run-finished\"; devices: number; durationMs: number }\r\n /** The run did not end. Written from the failure path, so a half-run still has a record. */\r\n | { event: \"run-failed\"; reason: string; durationMs: number };\r\n\r\nexport interface UseLogOptions {\r\n /** Ledger path. Defaults to {@link defaultUseLogPath}. */\r\n path?: string;\r\n /** Per-generation size cap in bytes. */\r\n maxBytes?: number;\r\n /** Generations kept, current included. */\r\n generations?: number;\r\n /**\r\n * Credential values to strike from every record.\r\n *\r\n * A live {@link SecretRegistry} is read PER RECORD; a plain array is the old\r\n * frozen snapshot and covers only what the config file held when the process\r\n * started. The daemon must pass the registry \u2014 its jobs bring credentials\r\n * the file never had, and the ledger outlives every one of them.\r\n */\r\n secrets?: readonly string[] | SecretRegistry;\r\n /**\r\n * Called once, the first time a write fails.\r\n *\r\n * A ledger that cannot be written must not fail a scan, but it must not be\r\n * silent either \u2014 \"there is no file\" and \"there is a file and it is empty\"\r\n * are different findings, and an operator who is told neither will assume the\r\n * first. The console wires this to a warning.\r\n */\r\n onError?: (message: string) => void;\r\n}\r\n\r\nexport interface UseLog {\r\n /** Absolute path of the current generation. */\r\n readonly path: string;\r\n /** The run id stamped on every record from this instance. */\r\n readonly runId: string;\r\n /** Append one event. Never throws \u2014 see {@link UseLog.record}. */\r\n record(event: UseLogEvent): void;\r\n}\r\n\r\n/**\r\n * Rotate if this write would push the current generation past its cap.\r\n *\r\n * Checked before the append rather than after, so the cap is a ceiling the file\r\n * never exceeds rather than one it crosses and then corrects. Renames run oldest\r\n * first so no generation is ever overwritten while it is still the newest copy\r\n * of what it holds.\r\n */\r\nfunction rotateIfNeeded(path: string, incomingBytes: number, maxBytes: number, generations: number): void {\r\n if (!existsSync(path)) return;\r\n const size = statSync(path).size;\r\n if (size + incomingBytes <= maxBytes) return;\r\n\r\n // The oldest generation is dropped rather than merged: the alternative is a\r\n // ceiling that is not a ceiling.\r\n rmSync(`${path}.${generations - 1}`, { force: true });\r\n for (let i = generations - 2; i >= 1; i--) {\r\n const from = `${path}.${i}`;\r\n if (existsSync(from)) renameSync(from, `${path}.${i + 1}`);\r\n }\r\n renameSync(path, `${path}.1`);\r\n}\r\n\r\n/**\r\n * Open (or re-open) the ledger.\r\n *\r\n * Opening is APPEND, never truncate, which is the whole of \"survives a restart\":\r\n * a collector that is stopped and started keeps writing into the same file\r\n * behind the same run of history, and only the `runId` changes. Nothing is read\r\n * back at open time, so a ledger that grew under an older format is still\r\n * appended to rather than rewritten.\r\n */\r\nexport function openUseLog(opts: UseLogOptions = {}): UseLog {\r\n const path = opts.path ?? defaultUseLogPath();\r\n const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;\r\n const generations = opts.generations ?? DEFAULT_GENERATIONS;\r\n const secretsOpt = opts.secrets ?? [];\r\n const secretsNow = (): readonly string[] =>\r\n Array.isArray(secretsOpt) ? secretsOpt : (secretsOpt as SecretRegistry).values();\r\n // Correlates every record of one scan. Timestamps alone do not: two\r\n // collectors can share a host, and a restart mid-estate produces two runs an\r\n // hour apart that an operator must be able to tell apart.\r\n //\r\n // The random tail is not decoration. Time-to-the-second plus PID looks unique\r\n // and is not: a container that restarts comes back as PID 1 again, and a\r\n // supervisor restarting a crashed daemon can land inside the same second. Two\r\n // runs sharing an id is worse than no id, because it silently merges one\r\n // scan's evidence into another's.\r\n const stamp = new Date().toISOString().replace(/[-:.]/g, \"\").slice(0, 15);\r\n const runId = `${stamp}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;\r\n let reportedError = false;\r\n\r\n return {\r\n path,\r\n runId,\r\n record(event: UseLogEvent): void {\r\n try {\r\n const line = `${redactLocal(JSON.stringify({ v: USE_LOG_VERSION, ts: new Date().toISOString(), runId, ...event }), secretsNow())}\\n`;\r\n mkdirSync(dirname(path), { recursive: true });\r\n rotateIfNeeded(path, Buffer.byteLength(line), maxBytes, generations);\r\n appendFileSync(path, line, \"utf8\");\r\n } catch (err) {\r\n // A read-only directory, a full disk, a locked file \u2014 none of those are\r\n // reasons to abandon a scan the operator asked for. The ledger is\r\n // evidence ABOUT the scan, not a precondition FOR it. Reported once so\r\n // a failing disk does not itself become the log.\r\n if (!reportedError) {\r\n reportedError = true;\r\n opts.onError?.(`use log ${path} is not writable: ${(err as Error).message}`);\r\n }\r\n }\r\n },\r\n };\r\n}\r\n", "import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\r\nimport { dirname, join } from \"node:path\";\r\nimport { PKG_ROOT } from \"../pkgRoot.js\";\r\n\r\n/**\r\n * THE OPERATOR'S OWN CHOICE OF WHAT TO SCAN \u2014 a second filter under the plane's.\r\n *\r\n * \u2500\u2500 TWO QUESTIONS, AND ONLY ONE OF THEM IS OURS TO ANSWER \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * The control plane answers \"which modules may this tenant scan\". That answer is\r\n * authoritative, fail-closed, and never computed on this host \u2014 see\r\n * `CollectorEntitlementResult.modules` and the gate's `ownsModule`.\r\n *\r\n * This file answers a different question: of the modules the tenant OWNS, which\r\n * does the operator running this collector actually want it to touch. A site\r\n * that has bought `vmware` may still not want THIS collector opening vCenter \u2014\r\n * it may be the wrong network, a maintenance window, or a box being trialled.\r\n *\r\n * \u2500\u2500 IT CAN ONLY EVER NARROW \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * `core/planRefusal.ts` states the rule: \"a client-side gate is advisory at\r\n * best, and a client-side grant is a bypass.\" Nothing here can grant. The daemon\r\n * asks the plane first and this second, and a module absent from the plane's\r\n * list is refused whatever this file says. Deleting this file scans MORE, never\r\n * less than the plane allows \u2014 which is the safe direction for a local file an\r\n * operator can edit by hand.\r\n *\r\n * \u2500\u2500 WHY IT STORES WHAT IS OFF, NOT WHAT IS ON \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * The natural shape is a list of enabled modules, and it is the wrong one. A\r\n * tenant who buys `sensitive-data` tomorrow would find it silently not scanning,\r\n * because it was not in a list written before they owned it \u2014 and the failure is\r\n * invisible: nothing errors, a module simply never runs. Storing the DISABLED\r\n * set means a newly-bought module scans by default and the operator turns it off\r\n * if they mean to, which is the direction that fails loudly rather than quietly.\r\n *\r\n * A key that stops being owned stays in the file harmlessly: it is already\r\n * refused a layer above, and removing it would erase the operator's intent for a\r\n * module they may buy again.\r\n */\r\n\r\n/**\r\n * Where the selection lives \u2014 beside the schedule store, resolved the same way\r\n * and overridable by the same kind of env var, so an operator who has moved one\r\n * collector's state has moved all of it.\r\n */\r\nexport function moduleSelectionFilePath(): string {\r\n return (\r\n process.env.COLLECTOR_MODULE_SELECTION_FILE ?? join(PKG_ROOT, \"collector-module-selection.json\")\r\n );\r\n}\r\n\r\ninterface SelectionFile {\r\n /** Module keys this operator has switched OFF on this collector. */\r\n disabled: string[];\r\n}\r\n\r\n/**\r\n * The disabled set, or an EMPTY one on any problem.\r\n *\r\n * A missing file is the ordinary state \u2014 nothing is disabled until somebody\r\n * disables something. A corrupt one is treated the same way ON PURPOSE: the\r\n * alternative is a parse error that stops every scan on the host, which turns a\r\n * damaged preferences file into an outage. The cost of this direction is that a\r\n * mangled file scans modules the operator had turned off; the plane still gates\r\n * every one of them, so the worst case is work nobody wanted, not data nobody\r\n * bought.\r\n */\r\nexport function readDisabledModules(path = moduleSelectionFilePath()): Set<string> {\r\n try {\r\n if (!existsSync(path)) return new Set();\r\n const parsed = JSON.parse(readFileSync(path, \"utf8\")) as Partial<SelectionFile>;\r\n if (!Array.isArray(parsed.disabled)) return new Set();\r\n return new Set(parsed.disabled.filter((k): k is string => typeof k === \"string\"));\r\n } catch {\r\n return new Set();\r\n }\r\n}\r\n\r\n/**\r\n * Replace the disabled set.\r\n *\r\n * Written the way `schedule/store.ts` writes: to a temporary file and renamed\r\n * over, so a process that dies mid-write leaves the previous selection intact\r\n * rather than a truncated file. `0o600` because it records what an operator has\r\n * chosen about their estate, which is nobody else's business on a shared host.\r\n */\r\nexport function writeDisabledModules(\r\n disabled: Iterable<string>,\r\n path = moduleSelectionFilePath(),\r\n): void {\r\n mkdirSync(dirname(path), { recursive: true });\r\n const body: SelectionFile = { disabled: [...new Set(disabled)].sort() };\r\n const tmp = `${path}.tmp`;\r\n writeFileSync(tmp, `${JSON.stringify(body, null, 2)}\\n`, { encoding: \"utf8\", mode: 0o600 });\r\n renameSync(tmp, path);\r\n}\r\n\r\n/** Turn one module off or on, returning the set as it now stands. */\r\nexport function setModuleEnabled(\r\n moduleKey: string,\r\n enabled: boolean,\r\n path = moduleSelectionFilePath(),\r\n): Set<string> {\r\n const disabled = readDisabledModules(path);\r\n if (enabled) disabled.delete(moduleKey);\r\n else disabled.add(moduleKey);\r\n writeDisabledModules(disabled, path);\r\n return disabled;\r\n}\r\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": ["existsSync", "readFileSync", "dirname", "join", "existsSync", "mkdirSync", "renameSync", "dirname", "join", "join", "existsSync", "mkdirSync", "dirname", "renameSync", "existsSync", "readFileSync", "join", "dirname"]
|
|
7
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ApiError,
|
|
3
3
|
moduleForScanDomain
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-RCSMKY5W.js";
|
|
5
5
|
|
|
6
6
|
// src/core/planRefusal.ts
|
|
7
7
|
var PLAN_REFUSAL_CODES = [
|
|
@@ -112,4 +112,4 @@ export {
|
|
|
112
112
|
asPlanRefusal,
|
|
113
113
|
createRefusalLatch
|
|
114
114
|
};
|
|
115
|
-
//# sourceMappingURL=chunk-
|
|
115
|
+
//# sourceMappingURL=chunk-6GUY42OX.js.map
|
|
@@ -155,11 +155,11 @@ async function extractFile(path, extension) {
|
|
|
155
155
|
}
|
|
156
156
|
if (bytes.length === 0) return { kind: "skipped", reason: "zero-length" };
|
|
157
157
|
if (kind === "pdf") {
|
|
158
|
-
const { extractPdf } = await import("./pdf-
|
|
158
|
+
const { extractPdf } = await import("./pdf-7V5KSOI3.js");
|
|
159
159
|
return extractPdf(bytes);
|
|
160
160
|
}
|
|
161
161
|
if (kind === "archive") {
|
|
162
|
-
const { extractArchive } = await import("./archive-
|
|
162
|
+
const { extractArchive } = await import("./archive-RCFXOPI6.js");
|
|
163
163
|
const out = extractArchive(bytes);
|
|
164
164
|
return out.entries.length > 0 ? { kind: "archive", entries: out.entries, unread: out.unread, truncated: out.truncated } : {
|
|
165
165
|
kind: "unreadable",
|
|
@@ -360,4 +360,4 @@ export {
|
|
|
360
360
|
extractOpenXml,
|
|
361
361
|
csvCells
|
|
362
362
|
};
|
|
363
|
-
//# sourceMappingURL=chunk-
|
|
363
|
+
//# sourceMappingURL=chunk-C5MDCEUD.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/sensitiveData/extract.ts", "../src/sensitiveData/encoding.ts"],
|
|
4
|
-
"sourcesContent": ["import { readFile } from \"node:fs/promises\";\r\nimport { unzipSync } from \"fflate\";\r\nimport { decodeText, decodedLooksLikeText } from \"./encoding.js\";\r\n\r\n/**\r\n * Getting text out of a file (PLAN-010 Phase 3).\r\n *\r\n * \u2500\u2500 EVERY OUTCOME IS NAMED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * There is no silent skip in this file. A file is text, or it is deliberately\r\n * ignored, or it is `unreadable` WITH A REASON \u2014 and the reason is counted and\r\n * reported per share. \"1,900 password-protected spreadsheets\" and \"1,900 corrupt\r\n * files\" and \"1,900 scanned PDFs with no text layer\" are three different facts\r\n * about an estate, and only one of them is somebody's problem to fix.\r\n *\r\n * Every competing tool collapses all three into \"skipped\". A report that quietly\r\n * omitted a third of the estate is worse than one that says which third, which\r\n * is why the unreadable table is a first-class output rather than a debug log.\r\n *\r\n * \u2500\u2500 WHY `fflate` AND NOT A HAND-ROLLED ZIP READER \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * An OpenXML document is a ZIP, and reading one looks like 250 lines over\r\n * `node:zlib`. It is not: a real implementation must survive zip64, mismatched\r\n * local and central headers, and malformed central directories \u2014 on input that\r\n * arrives from a file share and is therefore attacker-influenced. That is a\r\n * security liability written to save a dependency. `fflate` is ~8 KB, has no\r\n * dependencies of its own, and is runtime-portable, which keeps the option of\r\n * running this same extractor in a Worker later.\r\n */\r\n\r\nexport type UnreadableReason =\r\n | \"encrypted\"\r\n | \"corrupt\"\r\n | \"timeout\"\r\n | \"access-denied\"\r\n | \"in-use\"\r\n | \"no-text-layer\"\r\n | \"unsupported-format\"\r\n | \"too-large\"\r\n | \"binary\";\r\n\r\nexport interface TabularCell {\r\n sheet: string;\r\n row: number;\r\n column: number;\r\n header: string | null;\r\n value: string;\r\n}\r\n\r\nexport type ExtractOutcome =\r\n | {\r\n kind: \"text\";\r\n text: string;\r\n /** True when the extracted text hit the cap. Reported, never silent. */\r\n truncated: boolean;\r\n /** Populated for csv/xlsx. The single biggest precision lever \u2014 see below. */\r\n cells?: TabularCell[];\r\n }\r\n /**\r\n * An archive, as its READABLE MEMBERS rather than as one blob.\r\n *\r\n * Separate from `text` because a finding has to be able to say\r\n * `Records_2019.zip -> registrar/2018.csv`. Concatenating the members would\r\n * report the archive as the file holding an SSN, which is true and useless:\r\n * nobody can act on it without opening the archive themselves.\r\n */\r\n | {\r\n kind: \"archive\";\r\n entries: Array<{ path: string; text: string }>;\r\n /** Members that could not be read, by reason, so the gap is countable. */\r\n unread: Record<string, number>;\r\n truncated: boolean;\r\n }\r\n | { kind: \"skipped\"; reason: \"ignored-extension\" | \"zero-length\" }\r\n | { kind: \"unreadable\"; reason: UnreadableReason; detail: string };\r\n\r\n/** Extracted text is capped; past this the file is classified on its head. */\r\nexport const MAX_TEXT_CHARS = 20 * 1024 * 1024;\r\n\r\nconst PLAIN_TEXT = new Set([\r\n \"txt\", \"csv\", \"tsv\", \"log\", \"json\", \"xml\", \"sql\", \"md\", \"htm\", \"html\", \"ini\", \"cfg\",\r\n \"conf\", \"yaml\", \"yml\", \"ps1\", \"bat\", \"cmd\", \"sh\", \"py\", \"js\", \"ts\", \"css\", \"rtf\",\r\n]);\r\nconst OPENXML = new Set([\"docx\", \"xlsx\", \"pptx\", \"docm\", \"xlsm\", \"pptm\"]);\r\n/**\r\n * Recognised, deliberately not supported yet, and COUNTED rather than ignored.\r\n *\r\n * `pdf` left this set in Phase 6 and now has its own kind. It is worth saying\r\n * why it was the one that earned the work: a scanned PDF extracts to nothing,\r\n * and \"extracted nothing\" and \"contains nothing\" are the same value with\r\n * opposite meanings. Every other format here fails loudly; that one would have\r\n * failed as a clean result. See pdf.ts.\r\n */\r\nconst KNOWN_UNSUPPORTED = new Set([\"doc\", \"xls\", \"ppt\", \"msg\", \"pst\", \"7z\", \"rar\", \"eml\"]);\r\n\r\n/**\r\n * The OLE compound-file signature.\r\n *\r\n * An ECMA-376 encrypted OpenXML document is NOT a ZIP \u2014 it is an OLE container\r\n * holding the encrypted package. Recognising it is the difference between\r\n * reporting \"1,900 password-protected spreadsheets\", which is a finding, and\r\n * \"1,900 corrupt files\", which is noise.\r\n */\r\nconst OLE_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];\r\n\r\nconst isOle = (b: Uint8Array): boolean =>\r\n b.length >= 8 && OLE_MAGIC.every((v, i) => b[i] === v);\r\n\r\nconst isZip = (b: Uint8Array): boolean =>\r\n b.length >= 4 && b[0] === 0x50 && b[1] === 0x4b && (b[2] === 0x03 || b[2] === 0x05 || b[2] === 0x07);\r\n\r\nexport function extensionKind(\r\n ext: string,\r\n): \"text\" | \"openxml\" | \"pdf\" | \"archive\" | \"unsupported\" | \"ignore\" {\r\n const e = ext.toLowerCase().replace(/^\\./, \"\");\r\n if (PLAIN_TEXT.has(e)) return \"text\";\r\n if (OPENXML.has(e)) return \"openxml\";\r\n if (e === \"pdf\") return \"pdf\";\r\n if (e === \"zip\") return \"archive\";\r\n if (KNOWN_UNSUPPORTED.has(e)) return \"unsupported\";\r\n return \"ignore\";\r\n}\r\n\r\nexport async function extractFile(path: string, extension: string): Promise<ExtractOutcome> {\r\n const kind = extensionKind(extension);\r\n if (kind === \"ignore\") return { kind: \"skipped\", reason: \"ignored-extension\" };\r\n if (kind === \"unsupported\") {\r\n return {\r\n kind: \"unreadable\",\r\n reason: \"unsupported-format\",\r\n detail: `.${extension} is recognised but not yet extracted \u2014 counted so the coverage gap is visible`,\r\n };\r\n }\r\n\r\n let bytes: Uint8Array;\r\n try {\r\n bytes = new Uint8Array(await readFile(path));\r\n } catch (err) {\r\n const code = (err as NodeJS.ErrnoException | undefined)?.code ?? \"\";\r\n const detail = err instanceof Error ? err.message : String(err);\r\n if (code === \"EACCES\" || code === \"EPERM\") return { kind: \"unreadable\", reason: \"access-denied\", detail };\r\n if (code === \"EBUSY\") return { kind: \"unreadable\", reason: \"in-use\", detail };\r\n return { kind: \"unreadable\", reason: \"corrupt\", detail };\r\n }\r\n\r\n if (bytes.length === 0) return { kind: \"skipped\", reason: \"zero-length\" };\r\n if (kind === \"pdf\") {\r\n // Imported lazily inside the extractor, so a run over a share with no PDFs\r\n // never loads several megabytes of parser. See pdf.ts.\r\n const { extractPdf } = await import(\"./pdf.js\");\r\n return extractPdf(bytes);\r\n }\r\n if (kind === \"archive\") {\r\n // Guarded by @netdoc/config-ir's archive budget \u2014 an archive is\r\n // attacker-controlled input that expands, and is the most dangerous thing\r\n // this module reads. See archive.ts.\r\n const { extractArchive } = await import(\"./archive.js\");\r\n const out = extractArchive(bytes);\r\n // Nothing readable inside is not the same as a corrupt archive, and both\r\n // differ from an archive that was refused by the bomb guard. The reasons\r\n // travel in `unread`; this only decides whether there is text to classify.\r\n return out.entries.length > 0\r\n ? { kind: \"archive\", entries: out.entries, unread: out.unread, truncated: out.truncated }\r\n : {\r\n kind: \"unreadable\",\r\n reason: firstReason(out.unread),\r\n detail: describeUnread(out.unread),\r\n };\r\n }\r\n return kind === \"openxml\" ? extractOpenXml(bytes, extension) : extractPlainText(bytes);\r\n}\r\n\r\nexport function extractPlainText(bytes: Uint8Array): ExtractOutcome {\r\n const decoded = decodeText(bytes);\r\n if (decoded === null) {\r\n return { kind: \"unreadable\", reason: \"binary\", detail: \"not text in any recognised encoding\" };\r\n }\r\n if (!decodedLooksLikeText(decoded.text)) {\r\n return {\r\n kind: \"unreadable\",\r\n reason: \"binary\",\r\n detail: `decoded as ${decoded.encoding} but is mostly replacement characters`,\r\n };\r\n }\r\n const truncated = decoded.text.length > MAX_TEXT_CHARS;\r\n return {\r\n kind: \"text\",\r\n text: truncated ? decoded.text.slice(0, MAX_TEXT_CHARS) : decoded.text,\r\n truncated,\r\n };\r\n}\r\n\r\nexport function extractOpenXml(bytes: Uint8Array, extension: string): ExtractOutcome {\r\n if (isOle(bytes)) {\r\n return {\r\n kind: \"unreadable\",\r\n reason: \"encrypted\",\r\n detail: \"an OLE container \u2014 an ECMA-376 password-protected document\",\r\n };\r\n }\r\n if (!isZip(bytes)) {\r\n return { kind: \"unreadable\", reason: \"corrupt\", detail: \"not a ZIP container\" };\r\n }\r\n\r\n let files: Record<string, Uint8Array>;\r\n try {\r\n files = unzipSync(bytes);\r\n } catch (err) {\r\n return {\r\n kind: \"unreadable\",\r\n reason: \"corrupt\",\r\n detail: err instanceof Error ? err.message : \"could not read the ZIP container\",\r\n };\r\n }\r\n\r\n const e = extension.toLowerCase();\r\n if (e.startsWith(\"xls\")) return extractXlsx(files);\r\n\r\n // docx/pptx: the text runs, in document order. Small enough that a\r\n // whole-document scan is fine \u2014 unlike a worksheet, which is not.\r\n const parts = Object.keys(files).filter((n) =>\r\n e.startsWith(\"doc\") ? n === \"word/document.xml\" : /^ppt\\/slides\\/slide\\d+\\.xml$/.test(n),\r\n );\r\n if (parts.length === 0) {\r\n return { kind: \"unreadable\", reason: \"corrupt\", detail: \"no document part inside the container\" };\r\n }\r\n\r\n let text = \"\";\r\n for (const part of parts.sort()) {\r\n text += `${textRuns(new TextDecoder().decode(files[part]!))}\\n`;\r\n if (text.length > MAX_TEXT_CHARS) break;\r\n }\r\n const truncated = text.length > MAX_TEXT_CHARS;\r\n return { kind: \"text\", text: truncated ? text.slice(0, MAX_TEXT_CHARS) : text, truncated };\r\n}\r\n\r\n/** `<w:t>` / `<a:t>` runs, joined. A regex, because a DOM here buys nothing. */\r\nfunction textRuns(xml: string): string {\r\n const out: string[] = [];\r\n for (const m of xml.matchAll(/<(?:w|a):t(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:w|a):t>/g)) {\r\n out.push(unescapeXml(m[1] ?? \"\"));\r\n }\r\n return out.join(\" \");\r\n}\r\n\r\nfunction unescapeXml(s: string): string {\r\n return s\r\n .replace(/</g, \"<\")\r\n .replace(/>/g, \">\")\r\n .replace(/"/g, '\"')\r\n .replace(/'/g, \"'\")\r\n .replace(/&#(\\d+);/g, (_, d: string) => String.fromCodePoint(Number(d)))\r\n .replace(/&/g, \"&\");\r\n}\r\n\r\n/**\r\n * Worksheets, cell by cell, WITHOUT building a DOM.\r\n *\r\n * \u2500\u2500 WHY THIS IS HAND-WRITTEN AND NOT `fast-xml-parser` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * A worksheet's `sheet1.xml` is routinely tens or hundreds of megabytes \u2014 it is\r\n * one XML element per cell. Handing that to a DOM parser allocates an object\r\n * graph many times the file size and will OOM the collector on the exact files\r\n * that matter most, which are the big ones. A linear scan for `<c \u2026>\u2026</c>` costs\r\n * one pass and constant memory.\r\n *\r\n * \u2500\u2500 AND WHY CELLS AND NOT JUST TEXT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * The COLUMN HEADER is the single strongest precision signal the classifier has.\r\n * Most real SSN volume on a share is in a spreadsheet column headed `SSN`, and a\r\n * header match lifts a bare nine-digit value \u2014 which the catalog otherwise drops\r\n * outright \u2014 to high confidence. In the other direction it vetoes a Luhn-passing\r\n * value under `Invoice`. Flattening a worksheet to a blob of text throws that\r\n * away, so the cells are carried structurally.\r\n */\r\nfunction extractXlsx(files: Record<string, Uint8Array>): ExtractOutcome {\r\n const sharedStrings = files[\"xl/sharedStrings.xml\"]\r\n ? [...new TextDecoder().decode(files[\"xl/sharedStrings.xml\"]).matchAll(/<si>([\\s\\S]*?)<\\/si>/g)].map(\r\n (m) => textRunsGeneric(m[1] ?? \"\"),\r\n )\r\n : [];\r\n\r\n const sheetNames = sheetNameMap(files);\r\n const cells: TabularCell[] = [];\r\n let text = \"\";\r\n\r\n const sheetParts = Object.keys(files)\r\n .filter((n) => /^xl\\/worksheets\\/sheet\\d+\\.xml$/.test(n))\r\n .sort();\r\n\r\n if (sheetParts.length === 0) {\r\n return { kind: \"unreadable\", reason: \"corrupt\", detail: \"no worksheet part inside the container\" };\r\n }\r\n\r\n for (const part of sheetParts) {\r\n const index = Number(/sheet(\\d+)\\.xml$/.exec(part)?.[1] ?? \"1\");\r\n const sheet = sheetNames[index - 1] ?? `Sheet${index}`;\r\n const xml = new TextDecoder().decode(files[part]!);\r\n\r\n /** Row 1's values, keyed by column index \u2014 the header row. */\r\n const headers = new Map<number, string>();\r\n\r\n for (const m of xml.matchAll(/<c\\s+r=\"([A-Z]+)(\\d+)\"([^>]*)>([\\s\\S]*?)<\\/c>/g)) {\r\n const column = columnIndex(m[1]!);\r\n const row = Number(m[2]);\r\n const attrs = m[3] ?? \"\";\r\n const inner = m[4] ?? \"\";\r\n\r\n const raw = /<v>([\\s\\S]*?)<\\/v>/.exec(inner)?.[1] ?? \"\";\r\n const isShared = /\\bt=\"s\"/.test(attrs);\r\n const isInline = /\\bt=\"inlineStr\"/.test(attrs);\r\n\r\n const value = isShared\r\n ? (sharedStrings[Number(raw)] ?? \"\")\r\n : isInline\r\n ? textRunsGeneric(inner)\r\n : unescapeXml(raw);\r\n\r\n if (value.length === 0) continue;\r\n\r\n if (row === 1) {\r\n headers.set(column, value);\r\n continue;\r\n }\r\n\r\n cells.push({ sheet, row, column, header: headers.get(column) ?? null, value });\r\n text += `${value}\\n`;\r\n if (text.length > MAX_TEXT_CHARS) break;\r\n }\r\n if (text.length > MAX_TEXT_CHARS) break;\r\n }\r\n\r\n const truncated = text.length > MAX_TEXT_CHARS;\r\n return {\r\n kind: \"text\",\r\n text: truncated ? text.slice(0, MAX_TEXT_CHARS) : text,\r\n truncated,\r\n cells,\r\n };\r\n}\r\n\r\n/** `<t>` runs without a namespace prefix, as used inside sharedStrings. */\r\nfunction textRunsGeneric(xml: string): string {\r\n const out: string[] = [];\r\n for (const m of xml.matchAll(/<t(?:\\s[^>]*)?>([\\s\\S]*?)<\\/t>/g)) out.push(unescapeXml(m[1] ?? \"\"));\r\n return out.join(\"\");\r\n}\r\n\r\n/** Sheet display names in workbook order, so a hint reads `Payroll!C12`. */\r\nfunction sheetNameMap(files: Record<string, Uint8Array>): string[] {\r\n const wb = files[\"xl/workbook.xml\"];\r\n if (!wb) return [];\r\n return [...new TextDecoder().decode(wb).matchAll(/<sheet\\b[^>]*\\bname=\"([^\"]*)\"/g)].map((m) =>\r\n unescapeXml(m[1] ?? \"\"),\r\n );\r\n}\r\n\r\n/** `A` -> 0, `AA` -> 26. */\r\nexport function columnIndex(label: string): number {\r\n let n = 0;\r\n for (const c of label) n = n * 26 + (c.charCodeAt(0) - 64);\r\n return n - 1;\r\n}\r\n\r\n/**\r\n * Split CSV/TSV text into cells, so a delimited file gets the same column-header\r\n * treatment a spreadsheet does.\r\n *\r\n * Deliberately not a full RFC 4180 parser \u2014 the classifier needs cell VALUES and\r\n * their headers, not a faithful round-trip, and a mis-split cell costs a little\r\n * context rather than a wrong answer. `physicalWrite.ts` makes the same call for\r\n * the same reason.\r\n */\r\nexport function csvCells(text: string, delimiter = \",\"): TabularCell[] {\r\n const lines = text.split(/\\r?\\n/).filter((l) => l.length > 0);\r\n if (lines.length < 2) return [];\r\n const headers = splitLine(lines[0]!, delimiter);\r\n const cells: TabularCell[] = [];\r\n for (let r = 1; r < lines.length; r++) {\r\n const values = splitLine(lines[r]!, delimiter);\r\n for (let c = 0; c < values.length; c++) {\r\n const value = values[c]!.trim();\r\n if (value.length === 0) continue;\r\n cells.push({ sheet: \"\", row: r + 1, column: c, header: headers[c]?.trim() ?? null, value });\r\n }\r\n }\r\n return cells;\r\n}\r\n\r\nfunction splitLine(line: string, delimiter: string): string[] {\r\n const out: string[] = [];\r\n let cur = \"\";\r\n let quoted = false;\r\n for (let i = 0; i < line.length; i++) {\r\n const ch = line[i]!;\r\n if (ch === '\"') {\r\n if (quoted && line[i + 1] === '\"') {\r\n cur += '\"';\r\n i++;\r\n } else quoted = !quoted;\r\n } else if (ch === delimiter && !quoted) {\r\n out.push(cur);\r\n cur = \"\";\r\n } else cur += ch;\r\n }\r\n out.push(cur);\r\n return out;\r\n}\r\n\r\n/**\r\n * The reason to report for an archive nothing could be read from.\r\n *\r\n * Picks the MOST COMMON, not the first: an archive of four hundred images and\r\n * one corrupt entry is an archive of images, and reporting it as corrupt sends\r\n * an operator looking for damage that is not there. Ties break toward the\r\n * alphabetically first reason so the answer is stable between runs.\r\n */\r\nfunction firstReason(unread: Record<string, number>): UnreadableReason {\r\n const ranked = Object.entries(unread).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));\r\n const top = ranked[0]?.[0];\r\n // Everything `archive.ts` records is either an UnreadableReason already or a\r\n // path/size rejection, which is a refusal to read rather than a failure to.\r\n const known: readonly string[] = [\r\n \"encrypted\",\r\n \"corrupt\",\r\n \"timeout\",\r\n \"access-denied\",\r\n \"in-use\",\r\n \"no-text-layer\",\r\n \"unsupported-format\",\r\n \"too-large\",\r\n \"offline-stub\",\r\n ];\r\n return (top && known.includes(top) ? top : \"unsupported-format\") as UnreadableReason;\r\n}\r\n\r\n/** Every reason and its count, for the detail line an operator reads. */\r\nfunction describeUnread(unread: Record<string, number>): string {\r\n const parts = Object.entries(unread)\r\n .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))\r\n .map(([reason, n]) => `${n} ${reason}`);\r\n return parts.length > 0 ? `nothing readable inside: ${parts.join(\", \")}` : \"archive holds no readable files\";\r\n}\r\n", "/**\r\n * Deciding what bytes actually say (PLAN-010 Phase 3).\r\n *\r\n * \u2500\u2500 THE BUG THIS FILE EXISTS TO PREVENT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * `looksBinary()` in `@netdoc/config-ir` returns true on the first NUL byte, and\r\n * that is exactly right for the job it was written for: a Cisco configuration is\r\n * ASCII, and a NUL means somebody handed the importer a `.pcap`.\r\n *\r\n * It is exactly wrong here. **UTF-16LE text is roughly half NUL bytes** \u2014 every\r\n * ASCII character is stored as `XX 00` \u2014 and UTF-16LE is what `Out-File`\r\n * produces by default, what `Set-Content` produced for years, what Notepad\r\n * writes when you pick \"Unicode\", and what a large share of Windows log and\r\n * report exporters emit. Reusing `looksBinary` on raw bytes would have silently\r\n * skipped a substantial fraction of exactly the Windows text files this module\r\n * exists to read, with no error and no count \u2014 the files would simply never\r\n * appear in any total.\r\n *\r\n * So the order is: sniff, decode, and only THEN ask whether the result looks\r\n * like text. Judging encoded bytes by a rule written for ASCII is the mistake;\r\n * judging the decoded string is the fix.\r\n *\r\n * `config-ir`'s `looksBinary` is deliberately not changed \u2014 its callers want the\r\n * old behaviour, and widening it would loosen a guard that is correct where it\r\n * is used.\r\n */\r\n\r\nexport type Encoding = \"utf-8\" | \"utf-16le\" | \"utf-16be\" | \"windows-1252\" | \"binary\";\r\n\r\nexport interface SniffResult {\r\n encoding: Encoding;\r\n /** Bytes to skip: the byte-order mark, when one was present. */\r\n bomLength: number;\r\n /** How the decision was reached, for the unreadable-file report. */\r\n reason: string;\r\n}\r\n\r\n/** Bytes inspected when guessing. Enough to be confident, small enough to be free. */\r\nconst SAMPLE_BYTES = 4096;\r\n\r\n/**\r\n * Identify the encoding of a byte buffer.\r\n *\r\n * A BOM is decisive \u2014 it is the file saying what it is, and second-guessing it\r\n * is how a correctly-labelled file gets mangled. Everything after that is a\r\n * heuristic, and each one is written to fail toward \"text\" rather than\r\n * \"binary\": a file wrongly treated as text yields garbage the classifier finds\r\n * nothing in, which costs one wasted read. A file wrongly treated as binary is\r\n * never examined at all and never appears in any count, which is the failure\r\n * mode that makes a compliance report quietly incomplete.\r\n */\r\nexport function sniffEncoding(bytes: Uint8Array): SniffResult {\r\n if (bytes.length === 0) return { encoding: \"utf-8\", bomLength: 0, reason: \"empty file\" };\r\n\r\n if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {\r\n return { encoding: \"utf-8\", bomLength: 3, reason: \"UTF-8 byte-order mark\" };\r\n }\r\n if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {\r\n return { encoding: \"utf-16le\", bomLength: 2, reason: \"UTF-16LE byte-order mark\" };\r\n }\r\n if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {\r\n return { encoding: \"utf-16be\", bomLength: 2, reason: \"UTF-16BE byte-order mark\" };\r\n }\r\n\r\n const sample = bytes.subarray(0, Math.min(SAMPLE_BYTES, bytes.length));\r\n\r\n // BOM-less UTF-16. PowerShell's redirection operators have emitted this, and\r\n // so do plenty of exporters. The signature is unmistakable once you look for\r\n // it rather than for \"contains NUL\": NULs land on alternating offsets, and on\r\n // only ONE of the two parities.\r\n const parity = nulParity(sample);\r\n if (parity !== null) {\r\n return {\r\n encoding: parity === \"odd\" ? \"utf-16le\" : \"utf-16be\",\r\n bomLength: 0,\r\n reason: `NUL bytes on ${parity} offsets only \u2014 BOM-less UTF-16`,\r\n };\r\n }\r\n\r\n // Now, and only now, is a NUL evidence of binary content.\r\n let nuls = 0;\r\n let controls = 0;\r\n for (const b of sample) {\r\n if (b === 0) nuls++;\r\n // Control characters that are not tab, newline or carriage return.\r\n else if (b < 0x09 || (b > 0x0d && b < 0x20)) controls++;\r\n }\r\n if (nuls > 0) {\r\n return { encoding: \"binary\", bomLength: 0, reason: \"NUL bytes at both parities\" };\r\n }\r\n if (controls / sample.length > 0.1) {\r\n return { encoding: \"binary\", bomLength: 0, reason: \"more than 10% control bytes\" };\r\n }\r\n\r\n // UTF-8 unless the bytes disprove it. `windows-1252` is the fallback because\r\n // it is what a Windows estate produces when it is not producing UTF-8, and\r\n // decoding those bytes as UTF-8 turns every accented name into a replacement\r\n // character \u2014 which matters when the name is the thing being searched for.\r\n return isValidUtf8(sample)\r\n ? { encoding: \"utf-8\", bomLength: 0, reason: \"valid UTF-8 byte sequences\" }\r\n : { encoding: \"windows-1252\", bomLength: 0, reason: \"invalid UTF-8 \u2014 assuming Windows-1252\" };\r\n}\r\n\r\n/**\r\n * Which offset parity the NUL bytes sit on, or null if they are on both / too\r\n * few to judge.\r\n *\r\n * UTF-16LE stores ASCII as `XX 00`, so the NULs are at odd offsets; UTF-16BE is\r\n * `00 XX`, so they are at even ones. A file with NULs at both parities is not\r\n * UTF-16 text \u2014 it is binary that happens to contain zeroes.\r\n */\r\nfunction nulParity(sample: Uint8Array): \"odd\" | \"even\" | null {\r\n let odd = 0;\r\n let even = 0;\r\n for (let i = 0; i < sample.length; i++) {\r\n if (sample[i] !== 0) continue;\r\n if (i % 2 === 0) even++;\r\n else odd++;\r\n }\r\n const total = odd + even;\r\n // A third of the sample being NUL is the signature of half-ASCII UTF-16. A\r\n // handful of stray zeroes is not, and guessing UTF-16 on those would mangle an\r\n // ordinary file.\r\n if (total < sample.length / 4) return null;\r\n if (odd > 0 && even === 0) return \"odd\";\r\n if (even > 0 && odd === 0) return \"even\";\r\n return null;\r\n}\r\n\r\n/** Strict UTF-8 validation \u2014 the continuation bytes have to be right. */\r\nfunction isValidUtf8(bytes: Uint8Array): boolean {\r\n let i = 0;\r\n while (i < bytes.length) {\r\n const b = bytes[i]!;\r\n let extra: number;\r\n if (b < 0x80) extra = 0;\r\n else if ((b & 0xe0) === 0xc0) extra = 1;\r\n else if ((b & 0xf0) === 0xe0) extra = 2;\r\n else if ((b & 0xf8) === 0xf0) extra = 3;\r\n else return false;\r\n\r\n // A truncated sequence at the very end of the SAMPLE is not evidence of\r\n // anything \u2014 the file continues past where we stopped looking.\r\n if (i + extra >= bytes.length) return true;\r\n for (let k = 1; k <= extra; k++) {\r\n if ((bytes[i + k]! & 0xc0) !== 0x80) return false;\r\n }\r\n i += extra + 1;\r\n }\r\n return true;\r\n}\r\n\r\n/**\r\n * Decode to a string, or null when the bytes are not text.\r\n *\r\n * `fatal: false` throughout: a single malformed sequence in a large document\r\n * should cost that character, not the whole file. A report that dropped a\r\n * 40 MB spreadsheet because one cell held a stray byte would be exactly the\r\n * kind of silent incompleteness this module is built to avoid.\r\n */\r\nexport function decodeText(bytes: Uint8Array): { text: string; encoding: Encoding } | null {\r\n const sniff = sniffEncoding(bytes);\r\n if (sniff.encoding === \"binary\") return null;\r\n\r\n const body = sniff.bomLength > 0 ? bytes.subarray(sniff.bomLength) : bytes;\r\n try {\r\n const text = new TextDecoder(sniff.encoding, { fatal: false }).decode(body);\r\n return { text, encoding: sniff.encoding };\r\n } catch {\r\n // An unsupported label on some runtime. Fall back rather than lose the file.\r\n return { text: new TextDecoder(\"utf-8\", { fatal: false }).decode(body), encoding: \"utf-8\" };\r\n }\r\n}\r\n\r\n/**\r\n * Whether a DECODED string reads as text.\r\n *\r\n * The counterpart to `looksBinary`, applied on the right side of the decode.\r\n * A high proportion of replacement characters means the bytes were not what the\r\n * sniff concluded \u2014 which is a real outcome for a file with no BOM and no valid\r\n * UTF-8, and the honest response is to stop rather than classify noise.\r\n */\r\nexport function decodedLooksLikeText(text: string): boolean {\r\n if (text.length === 0) return true;\r\n let replacements = 0;\r\n const limit = Math.min(text.length, 4096);\r\n for (let i = 0; i < limit; i++) {\r\n if (text.charCodeAt(i) === 0xfffd) replacements++;\r\n }\r\n return replacements / limit < 0.05;\r\n}\r\n"],
|
|
4
|
+
"sourcesContent": ["import { readFile } from \"node:fs/promises\";\r\nimport { unzipSync } from \"fflate\";\r\nimport { decodeText, decodedLooksLikeText } from \"./encoding.js\";\r\n\r\n/**\r\n * Getting text out of a file (PLAN-010 Phase 3).\r\n *\r\n * \u2500\u2500 EVERY OUTCOME IS NAMED \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * There is no silent skip in this file. A file is text, or it is deliberately\r\n * ignored, or it is `unreadable` WITH A REASON \u2014 and the reason is counted and\r\n * reported per share. \"1,900 password-protected spreadsheets\" and \"1,900 corrupt\r\n * files\" and \"1,900 scanned PDFs with no text layer\" are three different facts\r\n * about an estate, and only one of them is somebody's problem to fix.\r\n *\r\n * Every competing tool collapses all three into \"skipped\". A report that quietly\r\n * omitted a third of the estate is worse than one that says which third, which\r\n * is why the unreadable table is a first-class output rather than a debug log.\r\n *\r\n * \u2500\u2500 WHY `fflate` AND NOT A HAND-ROLLED ZIP READER \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * An OpenXML document is a ZIP, and reading one looks like 250 lines over\r\n * `node:zlib`. It is not: a real implementation must survive zip64, mismatched\r\n * local and central headers, and malformed central directories \u2014 on input that\r\n * arrives from a file share and is therefore attacker-influenced. That is a\r\n * security liability written to save a dependency. `fflate` is ~8 KB, has no\r\n * dependencies of its own, and is runtime-portable, which keeps the option of\r\n * running this same extractor in a Worker later.\r\n */\r\n\r\nexport type UnreadableReason =\r\n | \"encrypted\"\r\n | \"corrupt\"\r\n | \"timeout\"\r\n | \"access-denied\"\r\n | \"in-use\"\r\n | \"no-text-layer\"\r\n | \"unsupported-format\"\r\n | \"too-large\"\r\n | \"binary\";\r\n\r\nexport interface TabularCell {\r\n sheet: string;\r\n row: number;\r\n column: number;\r\n header: string | null;\r\n value: string;\r\n}\r\n\r\nexport type ExtractOutcome =\r\n | {\r\n kind: \"text\";\r\n text: string;\r\n /** True when the extracted text hit the cap. Reported, never silent. */\r\n truncated: boolean;\r\n /** Populated for csv/xlsx. The single biggest precision lever \u2014 see below. */\r\n cells?: TabularCell[];\r\n }\r\n /**\r\n * An archive, as its READABLE MEMBERS rather than as one blob.\r\n *\r\n * Separate from `text` because a finding has to be able to say\r\n * `Records_2019.zip -> registrar/2018.csv`. Concatenating the members would\r\n * report the archive as the file holding an SSN, which is true and useless:\r\n * nobody can act on it without opening the archive themselves.\r\n */\r\n | {\r\n kind: \"archive\";\r\n entries: Array<{ path: string; text: string }>;\r\n /** Members that could not be read, by reason, so the gap is countable. */\r\n unread: Record<string, number>;\r\n truncated: boolean;\r\n }\r\n | { kind: \"skipped\"; reason: \"ignored-extension\" | \"zero-length\" }\r\n | { kind: \"unreadable\"; reason: UnreadableReason; detail: string };\r\n\r\n/** Extracted text is capped; past this the file is classified on its head. */\r\nexport const MAX_TEXT_CHARS = 20 * 1024 * 1024;\r\n\r\nconst PLAIN_TEXT = new Set([\r\n \"txt\", \"csv\", \"tsv\", \"log\", \"json\", \"xml\", \"sql\", \"md\", \"htm\", \"html\", \"ini\", \"cfg\",\r\n \"conf\", \"yaml\", \"yml\", \"ps1\", \"bat\", \"cmd\", \"sh\", \"py\", \"js\", \"ts\", \"css\", \"rtf\",\r\n]);\r\nconst OPENXML = new Set([\"docx\", \"xlsx\", \"pptx\", \"docm\", \"xlsm\", \"pptm\"]);\r\n/**\r\n * Recognised, deliberately not supported yet, and COUNTED rather than ignored.\r\n *\r\n * `pdf` left this set in Phase 6 and now has its own kind. It is worth saying\r\n * why it was the one that earned the work: a scanned PDF extracts to nothing,\r\n * and \"extracted nothing\" and \"contains nothing\" are the same value with\r\n * opposite meanings. Every other format here fails loudly; that one would have\r\n * failed as a clean result. See pdf.ts.\r\n */\r\nconst KNOWN_UNSUPPORTED = new Set([\"doc\", \"xls\", \"ppt\", \"msg\", \"pst\", \"7z\", \"rar\", \"eml\"]);\r\n\r\n/**\r\n * The OLE compound-file signature.\r\n *\r\n * An ECMA-376 encrypted OpenXML document is NOT a ZIP \u2014 it is an OLE container\r\n * holding the encrypted package. Recognising it is the difference between\r\n * reporting \"1,900 password-protected spreadsheets\", which is a finding, and\r\n * \"1,900 corrupt files\", which is noise.\r\n */\r\nconst OLE_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];\r\n\r\nconst isOle = (b: Uint8Array): boolean =>\r\n b.length >= 8 && OLE_MAGIC.every((v, i) => b[i] === v);\r\n\r\nconst isZip = (b: Uint8Array): boolean =>\r\n b.length >= 4 && b[0] === 0x50 && b[1] === 0x4b && (b[2] === 0x03 || b[2] === 0x05 || b[2] === 0x07);\r\n\r\nexport function extensionKind(\r\n ext: string,\r\n): \"text\" | \"openxml\" | \"pdf\" | \"archive\" | \"unsupported\" | \"ignore\" {\r\n const e = ext.toLowerCase().replace(/^\\./, \"\");\r\n if (PLAIN_TEXT.has(e)) return \"text\";\r\n if (OPENXML.has(e)) return \"openxml\";\r\n if (e === \"pdf\") return \"pdf\";\r\n if (e === \"zip\") return \"archive\";\r\n if (KNOWN_UNSUPPORTED.has(e)) return \"unsupported\";\r\n return \"ignore\";\r\n}\r\n\r\nexport async function extractFile(path: string, extension: string): Promise<ExtractOutcome> {\r\n const kind = extensionKind(extension);\r\n if (kind === \"ignore\") return { kind: \"skipped\", reason: \"ignored-extension\" };\r\n if (kind === \"unsupported\") {\r\n return {\r\n kind: \"unreadable\",\r\n reason: \"unsupported-format\",\r\n detail: `.${extension} is recognised but not yet extracted \u2014 counted so the coverage gap is visible`,\r\n };\r\n }\r\n\r\n let bytes: Uint8Array;\r\n try {\r\n bytes = new Uint8Array(await readFile(path));\r\n } catch (err) {\r\n const code = (err as NodeJS.ErrnoException | undefined)?.code ?? \"\";\r\n const detail = err instanceof Error ? err.message : String(err);\r\n if (code === \"EACCES\" || code === \"EPERM\") return { kind: \"unreadable\", reason: \"access-denied\", detail };\r\n if (code === \"EBUSY\") return { kind: \"unreadable\", reason: \"in-use\", detail };\r\n return { kind: \"unreadable\", reason: \"corrupt\", detail };\r\n }\r\n\r\n if (bytes.length === 0) return { kind: \"skipped\", reason: \"zero-length\" };\r\n if (kind === \"pdf\") {\r\n // Imported lazily inside the extractor, so a run over a share with no PDFs\r\n // never loads several megabytes of parser. See pdf.ts.\r\n const { extractPdf } = await import(\"./pdf.js\");\r\n return extractPdf(bytes);\r\n }\r\n if (kind === \"archive\") {\r\n // Guarded by @netdoc/config-ir's archive budget \u2014 an archive is\r\n // attacker-controlled input that expands, and is the most dangerous thing\r\n // this module reads. See archive.ts.\r\n const { extractArchive } = await import(\"./archive.js\");\r\n const out = extractArchive(bytes);\r\n // Nothing readable inside is not the same as a corrupt archive, and both\r\n // differ from an archive that was refused by the bomb guard. The reasons\r\n // travel in `unread`; this only decides whether there is text to classify.\r\n return out.entries.length > 0\r\n ? { kind: \"archive\", entries: out.entries, unread: out.unread, truncated: out.truncated }\r\n : {\r\n kind: \"unreadable\",\r\n reason: firstReason(out.unread),\r\n detail: describeUnread(out.unread),\r\n };\r\n }\r\n return kind === \"openxml\" ? extractOpenXml(bytes, extension) : extractPlainText(bytes);\r\n}\r\n\r\nexport function extractPlainText(bytes: Uint8Array): ExtractOutcome {\r\n const decoded = decodeText(bytes);\r\n if (decoded === null) {\r\n return { kind: \"unreadable\", reason: \"binary\", detail: \"not text in any recognised encoding\" };\r\n }\r\n if (!decodedLooksLikeText(decoded.text)) {\r\n return {\r\n kind: \"unreadable\",\r\n reason: \"binary\",\r\n detail: `decoded as ${decoded.encoding} but is mostly replacement characters`,\r\n };\r\n }\r\n const truncated = decoded.text.length > MAX_TEXT_CHARS;\r\n return {\r\n kind: \"text\",\r\n text: truncated ? decoded.text.slice(0, MAX_TEXT_CHARS) : decoded.text,\r\n truncated,\r\n };\r\n}\r\n\r\nexport function extractOpenXml(bytes: Uint8Array, extension: string): ExtractOutcome {\r\n if (isOle(bytes)) {\r\n return {\r\n kind: \"unreadable\",\r\n reason: \"encrypted\",\r\n detail: \"an OLE container \u2014 an ECMA-376 password-protected document\",\r\n };\r\n }\r\n if (!isZip(bytes)) {\r\n return { kind: \"unreadable\", reason: \"corrupt\", detail: \"not a ZIP container\" };\r\n }\r\n\r\n let files: Record<string, Uint8Array>;\r\n try {\r\n files = unzipSync(bytes);\r\n } catch (err) {\r\n return {\r\n kind: \"unreadable\",\r\n reason: \"corrupt\",\r\n detail: err instanceof Error ? err.message : \"could not read the ZIP container\",\r\n };\r\n }\r\n\r\n const e = extension.toLowerCase();\r\n if (e.startsWith(\"xls\")) return extractXlsx(files);\r\n\r\n // docx/pptx: the text runs, in document order. Small enough that a\r\n // whole-document scan is fine \u2014 unlike a worksheet, which is not.\r\n const parts = Object.keys(files).filter((n) =>\r\n e.startsWith(\"doc\") ? n === \"word/document.xml\" : /^ppt\\/slides\\/slide\\d+\\.xml$/.test(n),\r\n );\r\n if (parts.length === 0) {\r\n return { kind: \"unreadable\", reason: \"corrupt\", detail: \"no document part inside the container\" };\r\n }\r\n\r\n let text = \"\";\r\n for (const part of parts.sort()) {\r\n text += `${textRuns(new TextDecoder().decode(files[part]!))}\\n`;\r\n if (text.length > MAX_TEXT_CHARS) break;\r\n }\r\n const truncated = text.length > MAX_TEXT_CHARS;\r\n return { kind: \"text\", text: truncated ? text.slice(0, MAX_TEXT_CHARS) : text, truncated };\r\n}\r\n\r\n/** `<w:t>` / `<a:t>` runs, joined. A regex, because a DOM here buys nothing. */\r\nfunction textRuns(xml: string): string {\r\n const out: string[] = [];\r\n for (const m of xml.matchAll(/<(?:w|a):t(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:w|a):t>/g)) {\r\n out.push(unescapeXml(m[1] ?? \"\"));\r\n }\r\n return out.join(\" \");\r\n}\r\n\r\nfunction unescapeXml(s: string): string {\r\n return s\r\n .replace(/</g, \"<\")\r\n .replace(/>/g, \">\")\r\n .replace(/"/g, '\"')\r\n .replace(/'/g, \"'\")\r\n .replace(/&#(\\d+);/g, (_, d: string) => String.fromCodePoint(Number(d)))\r\n .replace(/&/g, \"&\");\r\n}\r\n\r\n/**\r\n * Worksheets, cell by cell, WITHOUT building a DOM.\r\n *\r\n * \u2500\u2500 WHY THIS IS HAND-WRITTEN AND NOT `fast-xml-parser` \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * A worksheet's `sheet1.xml` is routinely tens or hundreds of megabytes \u2014 it is\r\n * one XML element per cell. Handing that to a DOM parser allocates an object\r\n * graph many times the file size and will OOM the collector on the exact files\r\n * that matter most, which are the big ones. A linear scan for `<c \u2026>\u2026</c>` costs\r\n * one pass and constant memory.\r\n *\r\n * \u2500\u2500 AND WHY CELLS AND NOT JUST TEXT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * The COLUMN HEADER is the single strongest precision signal the classifier has.\r\n * Most real SSN volume on a share is in a spreadsheet column headed `SSN`, and a\r\n * header match lifts a bare nine-digit value \u2014 which the catalog otherwise drops\r\n * outright \u2014 to high confidence. In the other direction it vetoes a Luhn-passing\r\n * value under `Invoice`. Flattening a worksheet to a blob of text throws that\r\n * away, so the cells are carried structurally.\r\n */\r\nfunction extractXlsx(files: Record<string, Uint8Array>): ExtractOutcome {\r\n const sharedStrings = files[\"xl/sharedStrings.xml\"]\r\n ? [...new TextDecoder().decode(files[\"xl/sharedStrings.xml\"]).matchAll(/<si>([\\s\\S]*?)<\\/si>/g)].map(\r\n (m) => textRunsGeneric(m[1] ?? \"\"),\r\n )\r\n : [];\r\n\r\n const sheetNames = sheetNameMap(files);\r\n const cells: TabularCell[] = [];\r\n let text = \"\";\r\n\r\n const sheetParts = Object.keys(files)\r\n .filter((n) => /^xl\\/worksheets\\/sheet\\d+\\.xml$/.test(n))\r\n .sort();\r\n\r\n if (sheetParts.length === 0) {\r\n return { kind: \"unreadable\", reason: \"corrupt\", detail: \"no worksheet part inside the container\" };\r\n }\r\n\r\n for (const part of sheetParts) {\r\n const index = Number(/sheet(\\d+)\\.xml$/.exec(part)?.[1] ?? \"1\");\r\n const sheet = sheetNames[index - 1] ?? `Sheet${index}`;\r\n const xml = new TextDecoder().decode(files[part]!);\r\n\r\n /** Row 1's values, keyed by column index \u2014 the header row. */\r\n const headers = new Map<number, string>();\r\n\r\n for (const m of xml.matchAll(/<c\\s+r=\"([A-Z]+)(\\d+)\"([^>]*)>([\\s\\S]*?)<\\/c>/g)) {\r\n const column = columnIndex(m[1]!);\r\n const row = Number(m[2]);\r\n const attrs = m[3] ?? \"\";\r\n const inner = m[4] ?? \"\";\r\n\r\n const raw = /<v>([\\s\\S]*?)<\\/v>/.exec(inner)?.[1] ?? \"\";\r\n const isShared = /\\bt=\"s\"/.test(attrs);\r\n const isInline = /\\bt=\"inlineStr\"/.test(attrs);\r\n\r\n const value = isShared\r\n ? (sharedStrings[Number(raw)] ?? \"\")\r\n : isInline\r\n ? textRunsGeneric(inner)\r\n : unescapeXml(raw);\r\n\r\n if (value.length === 0) continue;\r\n\r\n if (row === 1) {\r\n headers.set(column, value);\r\n continue;\r\n }\r\n\r\n cells.push({ sheet, row, column, header: headers.get(column) ?? null, value });\r\n text += `${value}\\n`;\r\n if (text.length > MAX_TEXT_CHARS) break;\r\n }\r\n if (text.length > MAX_TEXT_CHARS) break;\r\n }\r\n\r\n const truncated = text.length > MAX_TEXT_CHARS;\r\n return {\r\n kind: \"text\",\r\n text: truncated ? text.slice(0, MAX_TEXT_CHARS) : text,\r\n truncated,\r\n cells,\r\n };\r\n}\r\n\r\n/** `<t>` runs without a namespace prefix, as used inside sharedStrings. */\r\nfunction textRunsGeneric(xml: string): string {\r\n const out: string[] = [];\r\n for (const m of xml.matchAll(/<t(?:\\s[^>]*)?>([\\s\\S]*?)<\\/t>/g)) out.push(unescapeXml(m[1] ?? \"\"));\r\n return out.join(\"\");\r\n}\r\n\r\n/** Sheet display names in workbook order, so a hint reads `Payroll!C12`. */\r\nfunction sheetNameMap(files: Record<string, Uint8Array>): string[] {\r\n const wb = files[\"xl/workbook.xml\"];\r\n if (!wb) return [];\r\n return [...new TextDecoder().decode(wb).matchAll(/<sheet\\b[^>]*\\bname=\"([^\"]*)\"/g)].map((m) =>\r\n unescapeXml(m[1] ?? \"\"),\r\n );\r\n}\r\n\r\n/** `A` -> 0, `AA` -> 26. */\r\nexport function columnIndex(label: string): number {\r\n let n = 0;\r\n for (const c of label) n = n * 26 + (c.charCodeAt(0) - 64);\r\n return n - 1;\r\n}\r\n\r\n/**\r\n * Split CSV/TSV text into cells, so a delimited file gets the same column-header\r\n * treatment a spreadsheet does.\r\n *\r\n * Deliberately not a full RFC 4180 parser \u2014 the classifier needs cell VALUES and\r\n * their headers, not a faithful round-trip, and a mis-split cell costs a little\r\n * context rather than a wrong answer. `physicalWrite.ts` makes the same call for\r\n * the same reason.\r\n */\r\nexport function csvCells(text: string, delimiter = \",\"): TabularCell[] {\r\n const lines = text.split(/\\r?\\n/).filter((l) => l.length > 0);\r\n if (lines.length < 2) return [];\r\n const headers = splitLine(lines[0]!, delimiter);\r\n const cells: TabularCell[] = [];\r\n for (let r = 1; r < lines.length; r++) {\r\n const values = splitLine(lines[r]!, delimiter);\r\n for (let c = 0; c < values.length; c++) {\r\n const value = values[c]!.trim();\r\n if (value.length === 0) continue;\r\n cells.push({ sheet: \"\", row: r + 1, column: c, header: headers[c]?.trim() ?? null, value });\r\n }\r\n }\r\n return cells;\r\n}\r\n\r\nfunction splitLine(line: string, delimiter: string): string[] {\r\n const out: string[] = [];\r\n let cur = \"\";\r\n let quoted = false;\r\n for (let i = 0; i < line.length; i++) {\r\n const ch = line[i]!;\r\n if (ch === '\"') {\r\n if (quoted && line[i + 1] === '\"') {\r\n cur += '\"';\r\n i++;\r\n } else quoted = !quoted;\r\n } else if (ch === delimiter && !quoted) {\r\n out.push(cur);\r\n cur = \"\";\r\n } else cur += ch;\r\n }\r\n out.push(cur);\r\n return out;\r\n}\r\n\n/**\n * The reason to report for an archive nothing could be read from.\n *\n * Picks the MOST COMMON, not the first: an archive of four hundred images and\n * one corrupt entry is an archive of images, and reporting it as corrupt sends\n * an operator looking for damage that is not there. Ties break toward the\n * alphabetically first reason so the answer is stable between runs.\n */\nfunction firstReason(unread: Record<string, number>): UnreadableReason {\n const ranked = Object.entries(unread).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));\n const top = ranked[0]?.[0];\n // Everything `archive.ts` records is either an UnreadableReason already or a\n // path/size rejection, which is a refusal to read rather than a failure to.\n const known: readonly string[] = [\n \"encrypted\",\n \"corrupt\",\n \"timeout\",\n \"access-denied\",\n \"in-use\",\n \"no-text-layer\",\n \"unsupported-format\",\n \"too-large\",\n \"offline-stub\",\n ];\n return (top && known.includes(top) ? top : \"unsupported-format\") as UnreadableReason;\n}\n\n/** Every reason and its count, for the detail line an operator reads. */\nfunction describeUnread(unread: Record<string, number>): string {\n const parts = Object.entries(unread)\n .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))\n .map(([reason, n]) => `${n} ${reason}`);\n return parts.length > 0 ? `nothing readable inside: ${parts.join(\", \")}` : \"archive holds no readable files\";\n}\n", "/**\r\n * Deciding what bytes actually say (PLAN-010 Phase 3).\r\n *\r\n * \u2500\u2500 THE BUG THIS FILE EXISTS TO PREVENT \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n *\r\n * `looksBinary()` in `@netdoc/config-ir` returns true on the first NUL byte, and\r\n * that is exactly right for the job it was written for: a Cisco configuration is\r\n * ASCII, and a NUL means somebody handed the importer a `.pcap`.\r\n *\r\n * It is exactly wrong here. **UTF-16LE text is roughly half NUL bytes** \u2014 every\r\n * ASCII character is stored as `XX 00` \u2014 and UTF-16LE is what `Out-File`\r\n * produces by default, what `Set-Content` produced for years, what Notepad\r\n * writes when you pick \"Unicode\", and what a large share of Windows log and\r\n * report exporters emit. Reusing `looksBinary` on raw bytes would have silently\r\n * skipped a substantial fraction of exactly the Windows text files this module\r\n * exists to read, with no error and no count \u2014 the files would simply never\r\n * appear in any total.\r\n *\r\n * So the order is: sniff, decode, and only THEN ask whether the result looks\r\n * like text. Judging encoded bytes by a rule written for ASCII is the mistake;\r\n * judging the decoded string is the fix.\r\n *\r\n * `config-ir`'s `looksBinary` is deliberately not changed \u2014 its callers want the\r\n * old behaviour, and widening it would loosen a guard that is correct where it\r\n * is used.\r\n */\r\n\r\nexport type Encoding = \"utf-8\" | \"utf-16le\" | \"utf-16be\" | \"windows-1252\" | \"binary\";\r\n\r\nexport interface SniffResult {\r\n encoding: Encoding;\r\n /** Bytes to skip: the byte-order mark, when one was present. */\r\n bomLength: number;\r\n /** How the decision was reached, for the unreadable-file report. */\r\n reason: string;\r\n}\r\n\r\n/** Bytes inspected when guessing. Enough to be confident, small enough to be free. */\r\nconst SAMPLE_BYTES = 4096;\r\n\r\n/**\r\n * Identify the encoding of a byte buffer.\r\n *\r\n * A BOM is decisive \u2014 it is the file saying what it is, and second-guessing it\r\n * is how a correctly-labelled file gets mangled. Everything after that is a\r\n * heuristic, and each one is written to fail toward \"text\" rather than\r\n * \"binary\": a file wrongly treated as text yields garbage the classifier finds\r\n * nothing in, which costs one wasted read. A file wrongly treated as binary is\r\n * never examined at all and never appears in any count, which is the failure\r\n * mode that makes a compliance report quietly incomplete.\r\n */\r\nexport function sniffEncoding(bytes: Uint8Array): SniffResult {\r\n if (bytes.length === 0) return { encoding: \"utf-8\", bomLength: 0, reason: \"empty file\" };\r\n\r\n if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {\r\n return { encoding: \"utf-8\", bomLength: 3, reason: \"UTF-8 byte-order mark\" };\r\n }\r\n if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xfe) {\r\n return { encoding: \"utf-16le\", bomLength: 2, reason: \"UTF-16LE byte-order mark\" };\r\n }\r\n if (bytes.length >= 2 && bytes[0] === 0xfe && bytes[1] === 0xff) {\r\n return { encoding: \"utf-16be\", bomLength: 2, reason: \"UTF-16BE byte-order mark\" };\r\n }\r\n\r\n const sample = bytes.subarray(0, Math.min(SAMPLE_BYTES, bytes.length));\r\n\r\n // BOM-less UTF-16. PowerShell's redirection operators have emitted this, and\r\n // so do plenty of exporters. The signature is unmistakable once you look for\r\n // it rather than for \"contains NUL\": NULs land on alternating offsets, and on\r\n // only ONE of the two parities.\r\n const parity = nulParity(sample);\r\n if (parity !== null) {\r\n return {\r\n encoding: parity === \"odd\" ? \"utf-16le\" : \"utf-16be\",\r\n bomLength: 0,\r\n reason: `NUL bytes on ${parity} offsets only \u2014 BOM-less UTF-16`,\r\n };\r\n }\r\n\r\n // Now, and only now, is a NUL evidence of binary content.\r\n let nuls = 0;\r\n let controls = 0;\r\n for (const b of sample) {\r\n if (b === 0) nuls++;\r\n // Control characters that are not tab, newline or carriage return.\r\n else if (b < 0x09 || (b > 0x0d && b < 0x20)) controls++;\r\n }\r\n if (nuls > 0) {\r\n return { encoding: \"binary\", bomLength: 0, reason: \"NUL bytes at both parities\" };\r\n }\r\n if (controls / sample.length > 0.1) {\r\n return { encoding: \"binary\", bomLength: 0, reason: \"more than 10% control bytes\" };\r\n }\r\n\r\n // UTF-8 unless the bytes disprove it. `windows-1252` is the fallback because\r\n // it is what a Windows estate produces when it is not producing UTF-8, and\r\n // decoding those bytes as UTF-8 turns every accented name into a replacement\r\n // character \u2014 which matters when the name is the thing being searched for.\r\n return isValidUtf8(sample)\r\n ? { encoding: \"utf-8\", bomLength: 0, reason: \"valid UTF-8 byte sequences\" }\r\n : { encoding: \"windows-1252\", bomLength: 0, reason: \"invalid UTF-8 \u2014 assuming Windows-1252\" };\r\n}\r\n\r\n/**\r\n * Which offset parity the NUL bytes sit on, or null if they are on both / too\r\n * few to judge.\r\n *\r\n * UTF-16LE stores ASCII as `XX 00`, so the NULs are at odd offsets; UTF-16BE is\r\n * `00 XX`, so they are at even ones. A file with NULs at both parities is not\r\n * UTF-16 text \u2014 it is binary that happens to contain zeroes.\r\n */\r\nfunction nulParity(sample: Uint8Array): \"odd\" | \"even\" | null {\r\n let odd = 0;\r\n let even = 0;\r\n for (let i = 0; i < sample.length; i++) {\r\n if (sample[i] !== 0) continue;\r\n if (i % 2 === 0) even++;\r\n else odd++;\r\n }\r\n const total = odd + even;\r\n // A third of the sample being NUL is the signature of half-ASCII UTF-16. A\r\n // handful of stray zeroes is not, and guessing UTF-16 on those would mangle an\r\n // ordinary file.\r\n if (total < sample.length / 4) return null;\r\n if (odd > 0 && even === 0) return \"odd\";\r\n if (even > 0 && odd === 0) return \"even\";\r\n return null;\r\n}\r\n\r\n/** Strict UTF-8 validation \u2014 the continuation bytes have to be right. */\r\nfunction isValidUtf8(bytes: Uint8Array): boolean {\r\n let i = 0;\r\n while (i < bytes.length) {\r\n const b = bytes[i]!;\r\n let extra: number;\r\n if (b < 0x80) extra = 0;\r\n else if ((b & 0xe0) === 0xc0) extra = 1;\r\n else if ((b & 0xf0) === 0xe0) extra = 2;\r\n else if ((b & 0xf8) === 0xf0) extra = 3;\r\n else return false;\r\n\r\n // A truncated sequence at the very end of the SAMPLE is not evidence of\r\n // anything \u2014 the file continues past where we stopped looking.\r\n if (i + extra >= bytes.length) return true;\r\n for (let k = 1; k <= extra; k++) {\r\n if ((bytes[i + k]! & 0xc0) !== 0x80) return false;\r\n }\r\n i += extra + 1;\r\n }\r\n return true;\r\n}\r\n\r\n/**\r\n * Decode to a string, or null when the bytes are not text.\r\n *\r\n * `fatal: false` throughout: a single malformed sequence in a large document\r\n * should cost that character, not the whole file. A report that dropped a\r\n * 40 MB spreadsheet because one cell held a stray byte would be exactly the\r\n * kind of silent incompleteness this module is built to avoid.\r\n */\r\nexport function decodeText(bytes: Uint8Array): { text: string; encoding: Encoding } | null {\r\n const sniff = sniffEncoding(bytes);\r\n if (sniff.encoding === \"binary\") return null;\r\n\r\n const body = sniff.bomLength > 0 ? bytes.subarray(sniff.bomLength) : bytes;\r\n try {\r\n const text = new TextDecoder(sniff.encoding, { fatal: false }).decode(body);\r\n return { text, encoding: sniff.encoding };\r\n } catch {\r\n // An unsupported label on some runtime. Fall back rather than lose the file.\r\n return { text: new TextDecoder(\"utf-8\", { fatal: false }).decode(body), encoding: \"utf-8\" };\r\n }\r\n}\r\n\r\n/**\r\n * Whether a DECODED string reads as text.\r\n *\r\n * The counterpart to `looksBinary`, applied on the right side of the decode.\r\n * A high proportion of replacement characters means the bytes were not what the\r\n * sniff concluded \u2014 which is a real outcome for a file with no BOM and no valid\r\n * UTF-8, and the honest response is to stop rather than classify noise.\r\n */\r\nexport function decodedLooksLikeText(text: string): boolean {\r\n if (text.length === 0) return true;\r\n let replacements = 0;\r\n const limit = Math.min(text.length, 4096);\r\n for (let i = 0; i < limit; i++) {\r\n if (text.charCodeAt(i) === 0xfffd) replacements++;\r\n }\r\n return replacements / limit < 0.05;\r\n}\r\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
VaultProtection
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-RCSMKY5W.js";
|
|
4
4
|
import {
|
|
5
5
|
PKG_ROOT
|
|
6
6
|
} from "./chunk-CW3YCPEC.js";
|
|
@@ -795,4 +795,4 @@ export {
|
|
|
795
795
|
loadSensitiveDataConfig,
|
|
796
796
|
loadAzureConfig
|
|
797
797
|
};
|
|
798
|
-
//# sourceMappingURL=chunk-
|
|
798
|
+
//# sourceMappingURL=chunk-GD4YRP6B.js.map
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
} from "./chunk-4RUBV5WB.js";
|
|
4
4
|
import {
|
|
5
5
|
VmwareIngestBody
|
|
6
|
-
} from "./chunk-
|
|
6
|
+
} from "./chunk-RCSMKY5W.js";
|
|
7
7
|
|
|
8
8
|
// src/vmware/soap.ts
|
|
9
9
|
import { XMLParser } from "fast-xml-parser";
|
|
@@ -1291,4 +1291,4 @@ export {
|
|
|
1291
1291
|
VsphereClient,
|
|
1292
1292
|
buildVmwareInventory
|
|
1293
1293
|
};
|
|
1294
|
-
//# sourceMappingURL=chunk-
|
|
1294
|
+
//# sourceMappingURL=chunk-I6CYXRMJ.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
isPlanRefusal,
|
|
3
3
|
planRefusalFrom
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-6GUY42OX.js";
|
|
5
5
|
import {
|
|
6
6
|
ApiError,
|
|
7
7
|
COLLECTOR_AUTH_HEADER,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
SddRunCloseResult,
|
|
14
14
|
SddRunOpenResult,
|
|
15
15
|
collectorBearer
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-RCSMKY5W.js";
|
|
17
17
|
|
|
18
18
|
// src/core/push.ts
|
|
19
19
|
import { randomUUID } from "node:crypto";
|
|
@@ -366,4 +366,4 @@ export {
|
|
|
366
366
|
pushSddBatch,
|
|
367
367
|
pushSddRunClose
|
|
368
368
|
};
|
|
369
|
-
//# sourceMappingURL=chunk-
|
|
369
|
+
//# sourceMappingURL=chunk-JB7TTPKL.js.map
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
AzureRbacAssignment,
|
|
9
9
|
AzureResource,
|
|
10
10
|
AzureSubscription
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-RCSMKY5W.js";
|
|
12
12
|
|
|
13
13
|
// src/azure/arm.ts
|
|
14
14
|
var noopLog = () => {
|
|
@@ -839,4 +839,4 @@ export {
|
|
|
839
839
|
listAccessibleSubscriptions,
|
|
840
840
|
buildAzureInventory
|
|
841
841
|
};
|
|
842
|
-
//# sourceMappingURL=chunk-
|
|
842
|
+
//# sourceMappingURL=chunk-JYSJ52ZS.js.map
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
IdentityIngestBody,
|
|
14
14
|
M365Licensing,
|
|
15
15
|
M365Workload
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-RCSMKY5W.js";
|
|
17
17
|
|
|
18
18
|
// src/identity/graph.ts
|
|
19
19
|
var noopLog = () => {
|
|
@@ -1124,4 +1124,4 @@ export {
|
|
|
1124
1124
|
GraphClient,
|
|
1125
1125
|
buildIdentityInventory
|
|
1126
1126
|
};
|
|
1127
|
-
//# sourceMappingURL=chunk-
|
|
1127
|
+
//# sourceMappingURL=chunk-NM4U7JPT.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
SqlIngestBody
|
|
3
|
-
} from "./chunk-
|
|
3
|
+
} from "./chunk-RCSMKY5W.js";
|
|
4
4
|
|
|
5
5
|
// src/sql/scan.ts
|
|
6
6
|
import sql from "mssql";
|
|
@@ -460,4 +460,4 @@ export {
|
|
|
460
460
|
scanSqlServer,
|
|
461
461
|
buildSqlInventory
|
|
462
462
|
};
|
|
463
|
-
//# sourceMappingURL=chunk-
|
|
463
|
+
//# sourceMappingURL=chunk-P635KMDV.js.map
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
VoiceRegion,
|
|
14
14
|
VoiceSystem,
|
|
15
15
|
WebexCalling
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-RCSMKY5W.js";
|
|
17
17
|
|
|
18
18
|
// src/voice/axl.ts
|
|
19
19
|
import { XMLParser } from "fast-xml-parser";
|
|
@@ -1940,4 +1940,4 @@ export {
|
|
|
1940
1940
|
buildUnityInventory,
|
|
1941
1941
|
buildWebexInventory
|
|
1942
1942
|
};
|
|
1943
|
-
//# sourceMappingURL=chunk-
|
|
1943
|
+
//# sourceMappingURL=chunk-QNHCMF7Z.js.map
|
|
@@ -4401,12 +4401,30 @@ var CollectorSiteNetwork = z20.object({
|
|
|
4401
4401
|
/** "Server VLAN", "Guest wifi" — null when nobody named it. */
|
|
4402
4402
|
label: z20.string().nullable().default(null)
|
|
4403
4403
|
});
|
|
4404
|
+
var CollectorBuilding = z20.object({
|
|
4405
|
+
id: z20.string().min(1),
|
|
4406
|
+
/** What the portal calls it — "North High School", "Building C". */
|
|
4407
|
+
name: z20.string(),
|
|
4408
|
+
/** The CIDRs recorded as belonging to it. May be empty. */
|
|
4409
|
+
networks: z20.array(z20.string().min(1))
|
|
4410
|
+
});
|
|
4404
4411
|
var CollectorSite = z20.object({
|
|
4405
4412
|
id: z20.string().min(1),
|
|
4406
4413
|
/** The name the portal shows. The whole reason a picker beats a typed id. */
|
|
4407
4414
|
name: z20.string(),
|
|
4408
4415
|
location: z20.string().nullable().default(null),
|
|
4409
|
-
networks: z20.array(CollectorSiteNetwork)
|
|
4416
|
+
networks: z20.array(CollectorSiteNetwork),
|
|
4417
|
+
/**
|
|
4418
|
+
* The site's buildings, if the tenant has divided it into any.
|
|
4419
|
+
*
|
|
4420
|
+
* DEFAULTED rather than required, which is what makes this additive: a plane
|
|
4421
|
+
* that has not been redeployed sends no such field, and `.default([])` turns
|
|
4422
|
+
* that into "this site has no buildings" rather than a parse failure that
|
|
4423
|
+
* would blank the whole site picker. The same reasoning as the enclosing
|
|
4424
|
+
* schema not being `.strict()`, from the other direction — that one tolerates
|
|
4425
|
+
* a NEWER plane, this one tolerates an OLDER one.
|
|
4426
|
+
*/
|
|
4427
|
+
buildings: z20.array(CollectorBuilding).default([])
|
|
4410
4428
|
});
|
|
4411
4429
|
var CollectorSitesResult = z20.object({
|
|
4412
4430
|
ok: z20.literal(true),
|
|
@@ -15293,4 +15311,4 @@ export {
|
|
|
15293
15311
|
earliestNextRun,
|
|
15294
15312
|
jitterFor
|
|
15295
15313
|
};
|
|
15296
|
-
//# sourceMappingURL=chunk-
|
|
15314
|
+
//# sourceMappingURL=chunk-RCSMKY5W.js.map
|