hazo_secure 1.5.0 → 1.6.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/CHANGE_LOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Change Log
2
2
 
3
+ ## Unreleased — fetch dispatcher bugfix (2026-07-22)
4
+
5
+ **NOTE (extractor_asx build session):** found and fixed while wiring
6
+ `extractor_asx`'s live dividends-extraction smoke test. Left un-numbered
7
+ since `1.6.0` above already has substantial unrelated in-progress work
8
+ staged in this working tree — fold into `1.6.0` or ship as `1.6.1`,
9
+ whichever fits how you're cutting the release.
10
+
11
+ ### Fixed: `hazo_secure/fetch` — `createSecureDispatcher`'s DNS lookup broke EVERY real fetch
12
+
13
+ - `src/fetch/dispatcher.ts`'s custom undici `Agent` `connect.lookup` ignored
14
+ the `options` undici actually passes (`{hints, all:true}` on every real
15
+ request — confirmed by logging it), always calling back with a single
16
+ `(address, family)` pair regardless. Node's `net` connect logic expects an
17
+ array of `{address,family}` records when `all:true` was requested; getting
18
+ a bare string instead threw `TypeError [ERR_INVALID_IP_ADDRESS]: Invalid
19
+ IP address: undefined` deep inside `net`, which undici's `fetch` wrapped
20
+ as an opaque `TypeError: fetch failed` with the real cause hidden in
21
+ `.cause`. **This broke every real HTTPS fetch through `safeFetch`** —
22
+ reproduced against multiple unrelated hosts, not specific to one site.
23
+ - Fix: `lookup` now always resolves via `dns.lookup(hostname, {all:true},
24
+ cb)` internally, validates (SSRF-checks) **every** returned address (not
25
+ just the first — a multi-record response could mix a public and a
26
+ private/rebound IP), then reshapes the result to match what the caller
27
+ actually asked for (single pair vs array) via a new exported pure
28
+ function, `shapeLookupResult`, added specifically so this logic is
29
+ unit-testable without mocking `node:dns` (this package's `test/` has no
30
+ existing DNS-mocking seam).
31
+ - New tests in `test/fetch/dispatcher.test.ts` (`describe('shapeLookupResult', ...)`)
32
+ cover the `all:true`/`all:false` shaping and confirm a private IP anywhere
33
+ in a multi-record response is caught, not just the first record.
34
+ - Verified against the real failing case: `safeFetch('https://www.macquarie.com/...')`
35
+ went from throwing `fetch failed` to a real `200` with the full page body.
36
+
37
+ ## 1.6.0 — 2026-07-22
38
+
39
+ ### New: `hazo_secure/mask/display` — client-safe display masks
40
+
41
+ - **`maskTFN` / `maskABN` moved to a new `hazo_secure/mask/display` subpath**
42
+ (`src/mask/display.ts`), a pure module with **no Node built-in imports**.
43
+ `hazo_secure/mask` (`src/mask/index.ts`) statically imports `node:crypto`
44
+ and `node:module` (`createRequire`, for the lazy `wink-nlp` load in
45
+ `redact_pii`), so it cannot be bundled for the browser. Consumers that only
46
+ need the display masks in client/browser code must import from
47
+ `hazo_secure/mask/display`.
48
+ - **Back-compat:** `hazo_secure/mask` still re-exports `maskTFN`/`maskABN`
49
+ (via `export { maskTFN, maskABN } from './display'`), so existing
50
+ server-side imports are unchanged. No API surface removed.
51
+
3
52
  ## 1.5.0 — 2026-07-21
4
53
 
5
54
  ### New: `hazo_secure/mask` — `maskTFN` / `maskABN` display masks
@@ -16,6 +16,23 @@ declare function validateConnectIp(ip: string): void;
16
16
  * record flips after the pre-flight check, the IP is re-validated here.
17
17
  *
18
18
  * Node 18+ ships undici as a built-in; we pin an explicit dep for stability.
19
+ *
20
+ * BUGFIX (2026-07-22): undici's Agent invokes this `lookup` with
21
+ * `{hints, all:true}` on every real request (confirmed by logging the actual
22
+ * options undici passes) — the previous implementation ignored `_options`
23
+ * entirely and always called back with a single `(address, family)` pair.
24
+ * When `net`'s internal connect logic expects an array (because it asked for
25
+ * `all:true`) but gets a bare string instead, it throws
26
+ * `TypeError [ERR_INVALID_IP_ADDRESS]: Invalid IP address: undefined` —
27
+ * which undici's fetch wraps as a generic `TypeError: fetch failed` with no
28
+ * indication of the real cause. This broke EVERY real HTTPS fetch through
29
+ * this dispatcher (reproduced against multiple unrelated hosts, not
30
+ * something specific to one site) — pre-flight `validateUrl`'s DNS check
31
+ * passing gave false confidence that fetches would actually succeed.
32
+ * `dns.lookup` is now always called with `{all:true}` internally regardless
33
+ * of what the caller asked for, so every resolved address gets SSRF-checked
34
+ * (see `shapeLookupResult`'s doc comment), then reshaped to match what the
35
+ * caller actually requested.
19
36
  */
20
37
  declare function createSecureDispatcher(): Agent;
21
38
 
@@ -86,18 +86,30 @@ function validateConnectIp(ip) {
86
86
  throw err;
87
87
  }
88
88
  }
89
+ function shapeLookupResult(records, wantsAll) {
90
+ for (const record of records) validateConnectIp(record.address);
91
+ if (wantsAll) {
92
+ return { address: records.map((r) => ({ address: r.address, family: r.family })), family: 0 };
93
+ }
94
+ const first = records[0];
95
+ return { address: first.address, family: first.family };
96
+ }
89
97
  function createSecureDispatcher() {
90
98
  return new Agent({
91
99
  connect: {
92
- lookup(hostname, _options, callback) {
93
- dns.lookup(hostname, (err, address, family) => {
94
- if (err) return callback(err, "", 4);
100
+ lookup(hostname, options, callback) {
101
+ const wantsAll = typeof options === "object" && options !== null && options.all === true;
102
+ dns.lookup(hostname, { all: true }, (err, records) => {
103
+ if (err) return callback(err, wantsAll ? [] : "", 4);
104
+ if (records.length === 0) {
105
+ return callback(new Error(`DNS lookup for ${hostname} returned no records`), wantsAll ? [] : "", 4);
106
+ }
95
107
  try {
96
- validateConnectIp(address);
108
+ const shaped = shapeLookupResult(records, wantsAll);
109
+ callback(null, shaped.address, shaped.family);
97
110
  } catch (ssrfErr) {
98
- return callback(ssrfErr, "", 4);
111
+ callback(ssrfErr, wantsAll ? [] : "", 4);
99
112
  }
100
- callback(null, address, family);
101
113
  });
102
114
  }
103
115
  }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * maskTFN — mask an Australian Tax File Number for display, revealing only
3
+ * the last 3 digits.
4
+ *
5
+ * TFNs are commonly rendered/entered as `123 456 789` or `123456789`;
6
+ * whitespace is stripped before masking so both forms produce the same
7
+ * output. Returns `"***"` for inputs too short to safely reveal 3 digits,
8
+ * and `""` for null/undefined/empty input.
9
+ *
10
+ * @param tfn - raw or space-formatted TFN, or null/undefined
11
+ * @returns a `"***-***-789"`-shaped string, `"***"`, or `""`
12
+ */
13
+ declare function maskTFN(tfn: string | null | undefined): string;
14
+ /**
15
+ * maskABN — mask an Australian Business Number for display, revealing only
16
+ * the last 3 digits.
17
+ *
18
+ * ABNs are commonly rendered/entered as `12 345 678 901` or `12345678901`;
19
+ * whitespace is stripped before masking so both forms produce the same
20
+ * output. Returns `"***"` for inputs too short to safely reveal 3 digits,
21
+ * and `""` for null/undefined/empty input.
22
+ *
23
+ * @param abn - raw or space-formatted ABN, or null/undefined
24
+ * @returns a `"**-***-***-901"`-shaped string, `"***"`, or `""`
25
+ */
26
+ declare function maskABN(abn: string | null | undefined): string;
27
+
28
+ export { maskABN, maskTFN };
@@ -0,0 +1,17 @@
1
+ // src/mask/display.ts
2
+ function maskTFN(tfn) {
3
+ if (!tfn) return "";
4
+ const cleaned = tfn.replace(/\s/g, "");
5
+ if (cleaned.length < 3) return "***";
6
+ return `***-***-${cleaned.slice(-3)}`;
7
+ }
8
+ function maskABN(abn) {
9
+ if (!abn) return "";
10
+ const cleaned = abn.replace(/\s/g, "");
11
+ if (cleaned.length < 3) return "***";
12
+ return `**-***-***-${cleaned.slice(-3)}`;
13
+ }
14
+ export {
15
+ maskABN,
16
+ maskTFN
17
+ };
@@ -1,3 +1,5 @@
1
+ export { maskABN, maskTFN } from './display.js';
2
+
1
3
  /**
2
4
  * mask_email — replace with deterministic masked email.
3
5
  * alice@example.com → a3f9b12c1d2e@masked.example
@@ -34,32 +36,7 @@ declare function drop(_value: unknown, _col: string, _maskKey: string): unknown;
34
36
  * nullify — replace with null (less aggressive than drop; preserves column semantics).
35
37
  */
36
38
  declare function nullify(_value: unknown, _col: string, _maskKey: string): unknown;
37
- /**
38
- * maskTFN — mask an Australian Tax File Number for display, revealing only
39
- * the last 3 digits.
40
- *
41
- * TFNs are commonly rendered/entered as `123 456 789` or `123456789`;
42
- * whitespace is stripped before masking so both forms produce the same
43
- * output. Returns `"***"` for inputs too short to safely reveal 3 digits,
44
- * and `""` for null/undefined/empty input.
45
- *
46
- * @param tfn - raw or space-formatted TFN, or null/undefined
47
- * @returns a `"***-***-789"`-shaped string, `"***"`, or `""`
48
- */
49
- declare function maskTFN(tfn: string | null | undefined): string;
50
- /**
51
- * maskABN — mask an Australian Business Number for display, revealing only
52
- * the last 3 digits.
53
- *
54
- * ABNs are commonly rendered/entered as `12 345 678 901` or `12345678901`;
55
- * whitespace is stripped before masking so both forms produce the same
56
- * output. Returns `"***"` for inputs too short to safely reveal 3 digits,
57
- * and `""` for null/undefined/empty input.
58
- *
59
- * @param abn - raw or space-formatted ABN, or null/undefined
60
- * @returns a `"**-***-***-901"`-shaped string, `"***"`, or `""`
61
- */
62
- declare function maskABN(abn: string | null | undefined): string;
39
+
63
40
  /**
64
41
  * redact_pii — NER-based PII redaction (wink-nlp lite model).
65
42
  * Detects named entities (dates, cardinals, etc.) and replaces them with [REDACTED_TYPE].
@@ -71,4 +48,4 @@ declare function redact_pii(value: unknown, _col: string, _maskKey: string): unk
71
48
  declare const BUILTIN_TRANSFORMS: Record<string, (value: unknown, col: string, maskKey: string) => unknown>;
72
49
  type MaskTransformFn = (value: unknown, col: string, maskKey: string) => unknown;
73
50
 
74
- export { BUILTIN_TRANSFORMS, type MaskTransformFn, drop, fake_name, hash, jitter_date, maskABN, maskTFN, mask_email, mask_phone, nullify, redact_pii, tokenize };
51
+ export { BUILTIN_TRANSFORMS, type MaskTransformFn, drop, fake_name, hash, jitter_date, mask_email, mask_phone, nullify, redact_pii, tokenize };
@@ -1,5 +1,21 @@
1
1
  // src/mask/index.ts
2
2
  import { createHmac } from "crypto";
3
+
4
+ // src/mask/display.ts
5
+ function maskTFN(tfn) {
6
+ if (!tfn) return "";
7
+ const cleaned = tfn.replace(/\s/g, "");
8
+ if (cleaned.length < 3) return "***";
9
+ return `***-***-${cleaned.slice(-3)}`;
10
+ }
11
+ function maskABN(abn) {
12
+ if (!abn) return "";
13
+ const cleaned = abn.replace(/\s/g, "");
14
+ if (cleaned.length < 3) return "***";
15
+ return `**-***-***-${cleaned.slice(-3)}`;
16
+ }
17
+
18
+ // src/mask/index.ts
3
19
  import { createRequire } from "module";
4
20
  function hmacHex(value, maskKey, context) {
5
21
  return createHmac("sha256", maskKey).update(`${context}:${value}`).digest("hex");
@@ -58,18 +74,6 @@ function drop(_value, _col, _maskKey) {
58
74
  function nullify(_value, _col, _maskKey) {
59
75
  return null;
60
76
  }
61
- function maskTFN(tfn) {
62
- if (!tfn) return "";
63
- const cleaned = tfn.replace(/\s/g, "");
64
- if (cleaned.length < 3) return "***";
65
- return `***-***-${cleaned.slice(-3)}`;
66
- }
67
- function maskABN(abn) {
68
- if (!abn) return "";
69
- const cleaned = abn.replace(/\s/g, "");
70
- if (cleaned.length < 3) return "***";
71
- return `**-***-***-${cleaned.slice(-3)}`;
72
- }
73
77
  var _esmReq = createRequire(import.meta.url);
74
78
  var _nlpInstance = null;
75
79
  var _nlpIts = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_secure",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Security & compliance primitives — SSRF-safe fetch, rate limiting, field-level encryption, GDPR orchestration, CSRF. Five subpath modules, pick the ones you need.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -42,6 +42,11 @@
42
42
  "import": "./dist/mask/index.js",
43
43
  "default": "./dist/mask/index.js"
44
44
  },
45
+ "./mask/display": {
46
+ "types": "./dist/mask/display.d.ts",
47
+ "import": "./dist/mask/display.js",
48
+ "default": "./dist/mask/display.js"
49
+ },
45
50
  "./secrets": {
46
51
  "types": "./dist/secrets/index.d.ts",
47
52
  "import": "./dist/secrets/index.js",