tacilytics 2.1.0 → 2.2.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/tz.js ADDED
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+ // Per-site time zones (Fabian, 2.2/2.3). Stored data stays UTC (events.day, salts, imports — Tyto a);
3
+ // only the boundaries of "a day" in a query move. Offsets are taken per instant from Intl in MINUTES —
4
+ // never rounded to hours (Kiro d: +5:30, +5:45, Lord Howe's 30-min DST) — and every local day gets its
5
+ // own boundaries, so DST days are 23 or 25 hours long instead of 24 (Tyto c).
6
+ const DAY = 864e5;
7
+ const fmtCache = new Map();
8
+ function fmt(tz) {
9
+ let f = fmtCache.get(tz);
10
+ if (!f) {
11
+ f = new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
12
+ fmtCache.set(tz, f);
13
+ }
14
+ return f;
15
+ }
16
+ // Only IANA names Intl knows (Kiro e). Returns the name as given (ICU's resolvedOptions would turn
17
+ // Asia/Kathmandu into the legacy Asia/Katmandu) or null.
18
+ function validTz(tz) {
19
+ if (typeof tz !== 'string' || !tz || tz.length > 64 || !/^[A-Za-z0-9_+\-/]+$/.test(tz)) return null;
20
+ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return tz; } catch (e) { return null; }
21
+ }
22
+ function parts(tz, ms) {
23
+ const p = {};
24
+ for (const x of fmt(tz).formatToParts(new Date(ms))) p[x.type] = x.value;
25
+ return p;
26
+ }
27
+ // minutes east of UTC at instant ms
28
+ function offsetMin(tz, ms) {
29
+ if (!tz || tz === 'UTC') return 0;
30
+ const p = parts(tz, ms);
31
+ return Math.round((Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute, +p.second) - Math.floor(ms / 1000) * 1000) / 60000);
32
+ }
33
+ // local calendar day (YYYY-MM-DD) of instant ms
34
+ function localDay(tz, ms) {
35
+ if (!tz || tz === 'UTC') return new Date(ms).toISOString().slice(0, 10);
36
+ return new Date(ms + offsetMin(tz, ms) * 60000).toISOString().slice(0, 10);
37
+ }
38
+ // UTC instant of local midnight that starts day (YYYY-MM-DD). Two passes settle the offset in force
39
+ // at that midnight even when the day before had a different one.
40
+ function dayStart(tz, day) {
41
+ const wall = Date.parse(day + 'T00:00:00Z');
42
+ if (!tz || tz === 'UTC') return wall;
43
+ let t = wall - offsetMin(tz, wall) * 60000;
44
+ t = wall - offsetMin(tz, t) * 60000;
45
+ // Clock jumps over midnight (America/Santiago, Asia/Beirut, Havana, Azores …): 00:00 does not exist and
46
+ // t lands on 23:00 of the day before. Move it to the next local midnight = the day's first instant (Tyto).
47
+ if (localDay(tz, t) !== day) { const o = offsetMin(tz, t); const lm = ((t + o * 60000) % DAY + DAY) % DAY; t += DAY - lm; }
48
+ return t;
49
+ }
50
+ function addDays(day, n) { return new Date(Date.parse(day + 'T00:00:00Z') + n * DAY).toISOString().slice(0, 10); }
51
+ // local days from..to (inclusive) with their UTC [start, end)
52
+ function days(tz, from, to) {
53
+ const out = [];
54
+ for (let d = from; d <= to; d = addDays(d, 1)) out.push({ day: d, start: dayStart(tz, d), end: dayStart(tz, addDays(d, 1)) });
55
+ return out;
56
+ }
57
+ // ISO weekday of a local day (0 = Sunday, like getUTCDay)
58
+ function weekday(day) { return new Date(day + 'T12:00:00Z').getUTCDay(); }
59
+ // Offset segments covering [start, end): [{ from, to, off }] with off in minutes. Transitions are found
60
+ // day by day and pinned to the minute by bisection, so a DST switch (or Lord Howe's 30-min one) lands
61
+ // exactly where Intl says.
62
+ function segments(tz, start, end) {
63
+ if (!tz || tz === 'UTC') return [{ from: start, to: end, off: 0 }];
64
+ const out = [];
65
+ let from = start, off = offsetMin(tz, start);
66
+ for (let t = start; t < end;) {
67
+ const next = Math.min(end, t + 6 * 36e5);
68
+ const o2 = offsetMin(tz, next === end ? end - 1 : next);
69
+ if (o2 !== off) {
70
+ let lo = t, hi = next === end ? end - 1 : next;
71
+ while (hi - lo > 60000) { const mid = Math.floor((lo + hi) / 2 / 60000) * 60000; if (offsetMin(tz, mid) === off) lo = mid; else hi = mid; }
72
+ out.push({ from, to: hi, off }); from = hi; off = o2;
73
+ }
74
+ t = next;
75
+ }
76
+ out.push({ from, to: end, off });
77
+ return out;
78
+ }
79
+ // SQL expression: ts shifted to local wall-clock ms. Only integers from segments() are inlined.
80
+ function shiftSql(tz, start, end, col = 'ts') {
81
+ const seg = segments(tz, start, end).filter((s) => s.to > s.from);
82
+ const ms = (s) => String(Math.trunc(s.off) * 60000);
83
+ if (seg.length === 1) return `(${col} + ${ms(seg[0])})`;
84
+ let sql = '(' + col + ' + CASE';
85
+ for (let i = 0; i < seg.length - 1; i++) sql += ` WHEN ${col} < ${Math.trunc(seg[i].to)} THEN ${ms(seg[i])}`;
86
+ return sql + ` ELSE ${ms(seg[seg.length - 1])} END)`;
87
+ }
88
+ // SQL: local day 'YYYY-MM-DD' of ts
89
+ function daySql(tz, start, end, col = 'ts') { return `date(${shiftSql(tz, start, end, col)} / 1000, 'unixepoch')`; }
90
+ // same local wall-clock time d days earlier (= t - d×24h unless a DST switch lies between) — the alert
91
+ // baseline compares "this hour" with the same LOCAL hour of earlier days
92
+ function sameLocalTime(tz, t, d) {
93
+ const back = t - d * DAY;
94
+ return back + (offsetMin(tz, t) - offsetMin(tz, back)) * 60000;
95
+ }
96
+ module.exports = { sameLocalTime, segments, shiftSql, daySql, validTz, offsetMin, localDay, dayStart, addDays, days, weekday, DAY };
package/webhook.js ADDED
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+ // SSRF guard for webhooks typed into the dashboard (alerts are sent by the server, so a
3
+ // teammate must not be able to point them at internal services).
4
+ //
5
+ // Two layers:
6
+ // 1. webhookProblem / resolvesPublic — friendly errors when the alert is saved.
7
+ // 2. postJson — the connection itself goes through guardedLookup, so the address that is
8
+ // actually dialled is checked. Without this a name could resolve to a public IP for the
9
+ // check and to 127.0.0.1 a moment later for the request (DNS rebinding / TOCTOU).
10
+ // No redirects are followed. The operator's own DIGEST_WEBHOOK_URL does not come through here.
11
+
12
+ const dns = require('dns');
13
+ const https = require('https');
14
+ const net = require('net');
15
+
16
+ const PRIVATE_V4 = [
17
+ ['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
18
+ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
19
+ ['224.0.0.0', 4], ['240.0.0.0', 4],
20
+ ];
21
+ const v4num = (a) => a.split('.').reduce((n, x) => n * 256 + Number(x), 0);
22
+ function privateV4(a) {
23
+ const n = v4num(a);
24
+ return PRIVATE_V4.some(([base, bits]) => Math.floor(n / 2 ** (32 - bits)) === Math.floor(v4num(base) / 2 ** (32 - bits)));
25
+ }
26
+ // IPv6 → 8 groups of 16 bit (handles ::, embedded IPv4)
27
+ function v6groups(a) {
28
+ let s = a.toLowerCase().split('%')[0];
29
+ const m = s.match(/(\d+\.\d+\.\d+\.\d+)$/);
30
+ if (m) { const n = v4num(m[1]); s = s.slice(0, -m[1].length) + ((n >>> 16) & 0xffff).toString(16) + ':' + (n & 0xffff).toString(16); }
31
+ const [head, tail] = s.split('::');
32
+ const h = head ? head.split(':') : [], t = tail != null && tail !== '' ? tail.split(':') : [];
33
+ const fill = tail != null ? Array(8 - h.length - t.length).fill('0') : [];
34
+ return [...h, ...fill, ...t].map((x) => parseInt(x || '0', 16));
35
+ }
36
+ function privateV6(a) {
37
+ const g = v6groups(a);
38
+ if (g.length !== 8 || g.some((x) => !Number.isFinite(x))) return true;
39
+ const embedded = () => `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
40
+ if (g.every((x) => x === 0)) return true; // ::
41
+ if (g.slice(0, 7).every((x) => x === 0) && g[7] === 1) return true; // ::1
42
+ if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) return privateV4(embedded()); // ::ffff:a.b.c.d
43
+ if (g.slice(0, 6).every((x) => x === 0)) return true; // ::a.b.c.d (deprecated, compatible)
44
+ if (g[0] === 0x64 && g[1] === 0xff9b) return true; // 64:ff9b::/96 + /48 NAT64 — would reach an IPv4 behind the gateway
45
+ if ((g[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 unique local
46
+ if ((g[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 link local
47
+ if ((g[0] & 0xffc0) === 0xfec0) return true; // fec0::/10 site local (deprecated)
48
+ if ((g[0] & 0xff00) === 0xff00) return true; // ff00::/8 multicast
49
+ if (g[0] === 0x2001 && g[1] === 0x0db8) return true; // documentation
50
+ if (g[0] === 0x2002) return privateV4(`${g[1] >> 8}.${g[1] & 255}.${g[2] >> 8}.${g[2] & 255}`); // 6to4
51
+ return false;
52
+ }
53
+ function isPrivateAddress(a) {
54
+ const v = net.isIP(a);
55
+ if (v === 4) return privateV4(a);
56
+ if (v === 6) return privateV6(a);
57
+ return true; // not an address at all → refuse
58
+ }
59
+
60
+ function webhookProblem(u) {
61
+ let url; try { url = new URL(String(u)); } catch { return 'Enter a full https:// URL'; }
62
+ if (url.protocol !== 'https:') return 'Webhook must use https://';
63
+ // 'localhost.' / 'foo.localhost.' are the same names (fully-qualified, trailing dot) — strip it before comparing
64
+ const h = url.hostname.toLowerCase().replace(/^\[|\]$/g, '').replace(/\.+$/, '');
65
+ if (net.isIP(h)) return isPrivateAddress(h) ? 'Webhook must point to a public address' : null;
66
+ if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal') || !h.includes('.')) return 'Webhook must point to a public address';
67
+ return null;
68
+ }
69
+
70
+ // dns.lookup signature, but refuses if any resolved address is private
71
+ function makeGuardedLookup(lookup = dns.lookup) {
72
+ return function guardedLookup(hostname, options, cb) {
73
+ if (typeof options === 'function') { cb = options; options = {}; }
74
+ lookup(hostname, { ...options, all: true }, (err, addrs) => {
75
+ if (err) return cb(err);
76
+ const list = Array.isArray(addrs) ? addrs : [{ address: addrs, family: net.isIP(addrs) }];
77
+ if (!list.length || list.some((x) => isPrivateAddress(x.address))) {
78
+ const e = new Error('Webhook address is not public'); e.code = 'EWEBHOOKPRIVATE'; return cb(e);
79
+ }
80
+ if (options && options.all) return cb(null, list);
81
+ cb(null, list[0].address, list[0].family);
82
+ });
83
+ };
84
+ }
85
+
86
+ async function resolvesPublic(u, lookup = dns.lookup) {
87
+ const h = new URL(u).hostname.replace(/^\[|\]$/g, '');
88
+ if (net.isIP(h)) return !isPrivateAddress(h);
89
+ return new Promise((resolve) => makeGuardedLookup(lookup)(h, { all: true }, (err) => resolve(!err)));
90
+ }
91
+
92
+ // POST JSON over https with the guarded lookup. Resolves { ok, status } or rejects.
93
+ function postJson(u, body, { lookup = dns.lookup, timeout = 10000 } = {}) {
94
+ return new Promise((resolve, reject) => {
95
+ const url = new URL(u);
96
+ const host = url.hostname.replace(/^\[|\]$/g, '');
97
+ if (net.isIP(host) && isPrivateAddress(host)) return reject(Object.assign(new Error('Webhook address is not public'), { code: 'EWEBHOOKPRIVATE' }));
98
+ const data = Buffer.from(JSON.stringify(body));
99
+ const req = https.request(url, {
100
+ method: 'POST', lookup: makeGuardedLookup(lookup), timeout, agent: false,
101
+ headers: { 'Content-Type': 'application/json', 'Content-Length': data.length, 'User-Agent': 'Tacilytics' },
102
+ }, (res) => { res.resume(); res.on('end', () => resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode })); });
103
+ req.on('timeout', () => req.destroy(new Error('timeout')));
104
+ req.on('error', reject);
105
+ req.end(data);
106
+ });
107
+ }
108
+
109
+ module.exports = { webhookProblem, isPrivateAddress, makeGuardedLookup, resolvesPublic, postJson };