infracensus-collector 1.4.2 → 1.4.3

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.
@@ -2,7 +2,7 @@ import {
2
2
  extensionKind,
3
3
  extractOpenXml,
4
4
  extractPlainText
5
- } from "./chunk-O3Z4CFCD.js";
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-O5IFKUA6.js.map
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
+ }
@@ -1263,4 +1263,4 @@ export {
1263
1263
  plaintextKeyWarning,
1264
1264
  collectorVersion
1265
1265
  };
1266
- //# sourceMappingURL=chunk-YIKST7R3.js.map
1266
+ //# sourceMappingURL=chunk-7T2LVP6E.js.map