@traffic-twin/sdk 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +171 -2
- package/dist/cjs/agent.d.ts +143 -0
- package/dist/cjs/agent.js +927 -0
- package/dist/cjs/capture/body.d.ts +11 -0
- package/dist/cjs/capture/body.js +73 -0
- package/dist/cjs/capture/common.d.ts +75 -0
- package/dist/cjs/capture/common.js +180 -0
- package/dist/cjs/capture/context.d.ts +28 -0
- package/dist/cjs/capture/context.js +92 -0
- package/dist/cjs/capture/inbound.d.ts +12 -0
- package/dist/cjs/capture/inbound.js +195 -0
- package/dist/cjs/capture/outbound-http.d.ts +4 -0
- package/dist/cjs/capture/outbound-http.js +200 -0
- package/dist/cjs/capture/outbound-undici.d.ts +2 -0
- package/dist/cjs/capture/outbound-undici.js +242 -0
- package/dist/cjs/capture/wire.d.ts +27 -0
- package/dist/cjs/capture/wire.js +68 -0
- package/dist/cjs/export/event.d.ts +46 -0
- package/dist/cjs/export/event.js +91 -0
- package/dist/cjs/export/exporter.d.ts +50 -0
- package/dist/cjs/export/exporter.js +192 -0
- package/dist/cjs/export/pipeline.d.ts +20 -0
- package/dist/cjs/export/pipeline.js +40 -0
- package/dist/cjs/fleet/asn1.d.ts +44 -0
- package/dist/cjs/fleet/asn1.js +150 -0
- package/dist/cjs/fleet/client.d.ts +84 -0
- package/dist/cjs/fleet/client.js +216 -0
- package/dist/cjs/fleet/http.d.ts +17 -0
- package/dist/cjs/fleet/http.js +80 -0
- package/dist/cjs/fleet/store.d.ts +18 -0
- package/dist/cjs/fleet/store.js +115 -0
- package/dist/cjs/index.d.ts +14 -0
- package/dist/cjs/index.js +69 -0
- package/dist/cjs/log.d.ts +4 -0
- package/dist/cjs/log.js +30 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/register.d.ts +1 -0
- package/dist/cjs/register.js +18 -0
- package/dist/cjs/sanitizer/bodies.d.ts +55 -0
- package/dist/cjs/sanitizer/bodies.js +710 -0
- package/dist/cjs/sanitizer/formats.d.ts +26 -0
- package/dist/cjs/sanitizer/formats.js +110 -0
- package/dist/cjs/sanitizer/graphql.d.ts +23 -0
- package/dist/cjs/sanitizer/graphql.js +478 -0
- package/dist/cjs/sanitizer/hostpat.d.ts +15 -0
- package/dist/cjs/sanitizer/hostpat.js +129 -0
- package/dist/cjs/sanitizer/json.d.ts +60 -0
- package/dist/cjs/sanitizer/json.js +460 -0
- package/dist/cjs/sanitizer/policy.d.ts +224 -0
- package/dist/cjs/sanitizer/policy.js +820 -0
- package/dist/cjs/sanitizer/sanitizer.d.ts +149 -0
- package/dist/cjs/sanitizer/sanitizer.js +1473 -0
- package/dist/cjs/sanitizer/utf8.d.ts +22 -0
- package/dist/cjs/sanitizer/utf8.js +180 -0
- package/dist/cjs/sanitizer/vectors.d.ts +22 -0
- package/dist/cjs/sanitizer/vectors.js +130 -0
- package/dist/cjs/version.d.ts +2 -0
- package/dist/cjs/version.js +5 -0
- package/dist/cjs/worker/dir.d.ts +1 -0
- package/dist/cjs/worker/dir.js +8 -0
- package/dist/cjs/worker/pool.d.ts +47 -0
- package/dist/cjs/worker/pool.js +179 -0
- package/dist/cjs/worker/protocol.d.ts +37 -0
- package/dist/cjs/worker/protocol.js +3 -0
- package/dist/cjs/worker/sanitizer-worker.d.ts +1 -0
- package/dist/cjs/worker/sanitizer-worker.js +53 -0
- package/dist/esm/agent.d.ts +143 -0
- package/dist/esm/agent.js +921 -0
- package/dist/esm/capture/body.d.ts +11 -0
- package/dist/esm/capture/body.js +69 -0
- package/dist/esm/capture/common.d.ts +75 -0
- package/dist/esm/capture/common.js +166 -0
- package/dist/esm/capture/context.d.ts +28 -0
- package/dist/esm/capture/context.js +84 -0
- package/dist/esm/capture/inbound.d.ts +12 -0
- package/dist/esm/capture/inbound.js +187 -0
- package/dist/esm/capture/outbound-http.d.ts +4 -0
- package/dist/esm/capture/outbound-http.js +193 -0
- package/dist/esm/capture/outbound-undici.d.ts +2 -0
- package/dist/esm/capture/outbound-undici.js +236 -0
- package/dist/esm/capture/wire.d.ts +27 -0
- package/dist/esm/capture/wire.js +65 -0
- package/dist/esm/export/event.d.ts +46 -0
- package/dist/esm/export/event.js +85 -0
- package/dist/esm/export/exporter.d.ts +50 -0
- package/dist/esm/export/exporter.js +188 -0
- package/dist/esm/export/pipeline.d.ts +20 -0
- package/dist/esm/export/pipeline.js +37 -0
- package/dist/esm/fleet/asn1.d.ts +44 -0
- package/dist/esm/fleet/asn1.js +128 -0
- package/dist/esm/fleet/client.d.ts +84 -0
- package/dist/esm/fleet/client.js +204 -0
- package/dist/esm/fleet/http.d.ts +17 -0
- package/dist/esm/fleet/http.js +73 -0
- package/dist/esm/fleet/store.d.ts +18 -0
- package/dist/esm/fleet/store.js +110 -0
- package/dist/esm/index.d.ts +14 -0
- package/dist/esm/index.js +48 -0
- package/dist/esm/log.d.ts +4 -0
- package/dist/esm/log.js +25 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/register.d.ts +1 -0
- package/dist/esm/register.js +16 -0
- package/dist/esm/sanitizer/bodies.d.ts +55 -0
- package/dist/esm/sanitizer/bodies.js +694 -0
- package/dist/esm/sanitizer/formats.d.ts +26 -0
- package/dist/esm/sanitizer/formats.js +101 -0
- package/dist/esm/sanitizer/graphql.d.ts +23 -0
- package/dist/esm/sanitizer/graphql.js +472 -0
- package/dist/esm/sanitizer/hostpat.d.ts +15 -0
- package/dist/esm/sanitizer/hostpat.js +122 -0
- package/dist/esm/sanitizer/json.d.ts +60 -0
- package/dist/esm/sanitizer/json.js +447 -0
- package/dist/esm/sanitizer/policy.d.ts +224 -0
- package/dist/esm/sanitizer/policy.js +800 -0
- package/dist/esm/sanitizer/sanitizer.d.ts +149 -0
- package/dist/esm/sanitizer/sanitizer.js +1459 -0
- package/dist/esm/sanitizer/utf8.d.ts +22 -0
- package/dist/esm/sanitizer/utf8.js +166 -0
- package/dist/esm/sanitizer/vectors.d.ts +22 -0
- package/dist/esm/sanitizer/vectors.js +125 -0
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +2 -0
- package/dist/esm/worker/dir.d.ts +1 -0
- package/dist/esm/worker/dir.js +5 -0
- package/dist/esm/worker/pool.d.ts +47 -0
- package/dist/esm/worker/pool.js +175 -0
- package/dist/esm/worker/protocol.d.ts +37 -0
- package/dist/esm/worker/protocol.js +2 -0
- package/dist/esm/worker/sanitizer-worker.d.ts +1 -0
- package/dist/esm/worker/sanitizer-worker.js +51 -0
- package/package.json +75 -4
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Fleet API client (api/fleet-api.v1.openapi.yaml): enrollment with a `tte_` token and CA
|
|
2
|
+
// pin, then mTLS calls for heartbeat, policy and certificate renewal.
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import tls from 'node:tls';
|
|
5
|
+
import { X509Certificate } from 'node:crypto';
|
|
6
|
+
import { generateCsr, pem, pemBlocks, pinOf } from './asn1.js';
|
|
7
|
+
import { parseJsonBody, request } from './http.js';
|
|
8
|
+
import { internalAgents } from '../capture/common.js';
|
|
9
|
+
export const TOKEN_RE = /^tte_[A-Za-z0-9_-]{32,128}\.([A-Za-z0-9_-]{43})$/;
|
|
10
|
+
export class FleetError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
status;
|
|
13
|
+
constructor(code, status) {
|
|
14
|
+
super(code);
|
|
15
|
+
this.code = code;
|
|
16
|
+
this.status = status;
|
|
17
|
+
this.name = 'FleetError';
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Extracts the CA pin from an enrollment token. */
|
|
21
|
+
export function tokenPin(token) {
|
|
22
|
+
const m = TOKEN_RE.exec(token);
|
|
23
|
+
return m ? m[1] : null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Opens a TLS connection without trusting anything, walks the chain the server presents
|
|
27
|
+
* to its top (the root, which tt-core must include) and checks it against the pin.
|
|
28
|
+
* Returns the root as PEM, to be used as the only trust anchor for the real request.
|
|
29
|
+
*/
|
|
30
|
+
export function fetchPinnedRoot(url, pin, timeoutMs = 10_000) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const host = url.hostname.replace(/^\[|\]$/g, '');
|
|
33
|
+
const sock = tls.connect({
|
|
34
|
+
host,
|
|
35
|
+
port: Number(url.port || 443),
|
|
36
|
+
servername: /^[0-9.]+$|:/.test(host) ? undefined : host,
|
|
37
|
+
rejectUnauthorized: false,
|
|
38
|
+
});
|
|
39
|
+
const fail = (code) => {
|
|
40
|
+
sock.destroy();
|
|
41
|
+
reject(new FleetError(code));
|
|
42
|
+
};
|
|
43
|
+
sock.setTimeout(timeoutMs, () => fail('enroll_tls_timeout'));
|
|
44
|
+
sock.once('error', () => fail('enroll_tls_error'));
|
|
45
|
+
sock.once('secureConnect', () => {
|
|
46
|
+
try {
|
|
47
|
+
let c = sock.getPeerCertificate(true);
|
|
48
|
+
if (!c || !c.raw)
|
|
49
|
+
return fail('enroll_no_peer_cert');
|
|
50
|
+
for (let i = 0; i < 16; i++) {
|
|
51
|
+
const up = c.issuerCertificate;
|
|
52
|
+
if (!up || up === c || up.fingerprint256 === c.fingerprint256 || !up.raw)
|
|
53
|
+
break;
|
|
54
|
+
c = up;
|
|
55
|
+
}
|
|
56
|
+
const root = new X509Certificate(c.raw);
|
|
57
|
+
if (!root.ca || root.issuer !== root.subject)
|
|
58
|
+
return fail('enroll_root_missing');
|
|
59
|
+
if (pinOf(c.raw) !== pin)
|
|
60
|
+
return fail('enroll_pin_mismatch');
|
|
61
|
+
sock.end();
|
|
62
|
+
resolve(pem('CERTIFICATE', c.raw));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
fail('enroll_pin_check');
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function isHttpsUrl(v) {
|
|
71
|
+
try {
|
|
72
|
+
return typeof v === 'string' && new URL(v).protocol === 'https:';
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Enrolls with a token. Returns the enrollment and the private key PEM (generated locally when useCsr). */
|
|
79
|
+
export async function enroll(o) {
|
|
80
|
+
const pin = tokenPin(o.token);
|
|
81
|
+
if (!pin)
|
|
82
|
+
throw new FleetError('enroll_token_malformed');
|
|
83
|
+
const base = new URL(o.enrollUrl);
|
|
84
|
+
if (base.protocol !== 'https:')
|
|
85
|
+
throw new FleetError('enroll_url_not_https');
|
|
86
|
+
const rootPem = await fetchPinnedRoot(base, pin, o.timeoutMs);
|
|
87
|
+
const agent = new https.Agent({ ca: rootPem, keepAlive: false });
|
|
88
|
+
internalAgents.add(agent);
|
|
89
|
+
let keyPem = '';
|
|
90
|
+
const body = { token: o.token, source: o.source };
|
|
91
|
+
if (o.useCsr) {
|
|
92
|
+
const g = generateCsr(o.source.service_name);
|
|
93
|
+
keyPem = g.keyPem;
|
|
94
|
+
body.csr = g.csrPem;
|
|
95
|
+
}
|
|
96
|
+
const url = new URL('/fleet/v1/enroll', base).toString();
|
|
97
|
+
let res;
|
|
98
|
+
try {
|
|
99
|
+
res = await request({ method: 'POST', url, agent, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), timeoutMs: o.timeoutMs });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new FleetError('enroll_network');
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
agent.destroy();
|
|
106
|
+
}
|
|
107
|
+
if (res.status !== 201 && res.status !== 200)
|
|
108
|
+
throw new FleetError('enroll_http_' + res.status, res.status);
|
|
109
|
+
const j = parseJsonBody(res);
|
|
110
|
+
if (!j || typeof j !== 'object')
|
|
111
|
+
throw new FleetError('enroll_bad_response');
|
|
112
|
+
if (!o.useCsr) {
|
|
113
|
+
if (typeof j.client_key !== 'string')
|
|
114
|
+
throw new FleetError('enroll_no_key');
|
|
115
|
+
keyPem = j.client_key;
|
|
116
|
+
}
|
|
117
|
+
const keys = Array.isArray(j.policy_signing_keys) ? j.policy_signing_keys : [];
|
|
118
|
+
const enrollment = {
|
|
119
|
+
source_id: String(j.source_id ?? ''),
|
|
120
|
+
group: String(j.group ?? ''),
|
|
121
|
+
service_name: String(j.service_name ?? ''),
|
|
122
|
+
client_cert: String(j.client_cert ?? ''),
|
|
123
|
+
ca_chain: String(j.ca_chain ?? ''),
|
|
124
|
+
not_after: typeof j.not_after === 'string' ? j.not_after : undefined,
|
|
125
|
+
policy_signing_keys: keys
|
|
126
|
+
.filter((k) => typeof k?.kid === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(k.kid) && k.alg === 'Ed25519' &&
|
|
127
|
+
typeof k.public_key === 'string' && /^[A-Za-z0-9_-]{43}$/.test(k.public_key))
|
|
128
|
+
.map((k) => ({ kid: k.kid, alg: 'Ed25519', public_key: k.public_key })),
|
|
129
|
+
intake_url: String(j.intake_url ?? ''),
|
|
130
|
+
fleet_url: String(j.fleet_url ?? ''),
|
|
131
|
+
policy_max_age_s: typeof j.policy_max_age_s === 'number' ? j.policy_max_age_s : 86400,
|
|
132
|
+
heartbeat_interval_s: typeof j.heartbeat_interval_s === 'number' ? j.heartbeat_interval_s : 30,
|
|
133
|
+
policy_poll_interval_s: typeof j.policy_poll_interval_s === 'number' ? j.policy_poll_interval_s : 60,
|
|
134
|
+
enroll_url: o.enrollUrl,
|
|
135
|
+
pin,
|
|
136
|
+
};
|
|
137
|
+
if (!/^src-[0-9A-Za-z]{1,32}$/.test(enrollment.source_id))
|
|
138
|
+
throw new FleetError('enroll_bad_source_id');
|
|
139
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(enrollment.group))
|
|
140
|
+
throw new FleetError('enroll_bad_group');
|
|
141
|
+
if (!/^[a-z0-9][a-z0-9._-]{0,62}$/.test(enrollment.service_name))
|
|
142
|
+
throw new FleetError('enroll_bad_service_name');
|
|
143
|
+
if (pemBlocks(enrollment.client_cert, 'CERTIFICATE').length === 0)
|
|
144
|
+
throw new FleetError('enroll_bad_cert');
|
|
145
|
+
if (!pemBlocks(enrollment.ca_chain, 'CERTIFICATE').some((d) => pinOf(d) === pin))
|
|
146
|
+
throw new FleetError('enroll_chain_without_pinned_root');
|
|
147
|
+
if (enrollment.policy_signing_keys.length === 0)
|
|
148
|
+
throw new FleetError('enroll_no_signing_keys');
|
|
149
|
+
if (!isHttpsUrl(enrollment.intake_url) || !isHttpsUrl(enrollment.fleet_url))
|
|
150
|
+
throw new FleetError('enroll_bad_urls');
|
|
151
|
+
return { enrollment, keyPem };
|
|
152
|
+
}
|
|
153
|
+
/** mTLS agent for the intake and Fleet calls. Trust anchors: the CA chain received over the pinned channel. */
|
|
154
|
+
export function mtlsAgent(e, keyPem) {
|
|
155
|
+
const a = new https.Agent({ cert: e.client_cert, key: keyPem, ca: e.ca_chain, keepAlive: true, maxSockets: 2 });
|
|
156
|
+
internalAgents.add(a);
|
|
157
|
+
return a;
|
|
158
|
+
}
|
|
159
|
+
export class FleetClient {
|
|
160
|
+
e;
|
|
161
|
+
agent;
|
|
162
|
+
constructor(e, agent) {
|
|
163
|
+
this.e = e;
|
|
164
|
+
this.agent = agent;
|
|
165
|
+
}
|
|
166
|
+
setAgent(agent) {
|
|
167
|
+
this.agent = agent;
|
|
168
|
+
}
|
|
169
|
+
url(path) {
|
|
170
|
+
return new URL(path, this.e.fleet_url).toString();
|
|
171
|
+
}
|
|
172
|
+
async heartbeat(body) {
|
|
173
|
+
const r = await request({ method: 'POST', url: this.url('/fleet/v1/heartbeat'), agent: this.agent, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
174
|
+
return { status: r.status, json: r.status === 200 ? parseJsonBody(r) : undefined };
|
|
175
|
+
}
|
|
176
|
+
async getPolicy(etag) {
|
|
177
|
+
const headers = {};
|
|
178
|
+
if (etag)
|
|
179
|
+
headers['if-none-match'] = etag;
|
|
180
|
+
const r = await request({ method: 'GET', url: this.url('/fleet/v1/policy'), agent: this.agent, headers, maxResponseBytes: 8 << 20 });
|
|
181
|
+
const et = r.headers.etag;
|
|
182
|
+
return { status: r.status, body: r.status === 200 ? r.body : undefined, etag: typeof et === 'string' ? et : undefined };
|
|
183
|
+
}
|
|
184
|
+
async renew(useCsr, commonName) {
|
|
185
|
+
let keyPem;
|
|
186
|
+
const body = {};
|
|
187
|
+
if (useCsr) {
|
|
188
|
+
const g = generateCsr(commonName);
|
|
189
|
+
keyPem = g.keyPem;
|
|
190
|
+
body.csr = g.csrPem;
|
|
191
|
+
}
|
|
192
|
+
const r = await request({ method: 'POST', url: this.url('/fleet/v1/renew'), agent: this.agent, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
193
|
+
if (r.status !== 200)
|
|
194
|
+
return null;
|
|
195
|
+
const j = parseJsonBody(r);
|
|
196
|
+
if (!j || typeof j.client_cert !== 'string')
|
|
197
|
+
return null;
|
|
198
|
+
if (!useCsr)
|
|
199
|
+
keyPem = typeof j.client_key === 'string' ? j.client_key : undefined;
|
|
200
|
+
if (!keyPem)
|
|
201
|
+
return null;
|
|
202
|
+
return { cert: j.client_cert, keyPem, notAfter: typeof j.not_after === 'string' ? j.not_after : undefined };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
export interface HttpResult {
|
|
3
|
+
status: number;
|
|
4
|
+
headers: Record<string, string | string[] | undefined>;
|
|
5
|
+
body: Buffer;
|
|
6
|
+
}
|
|
7
|
+
export interface HttpOptions {
|
|
8
|
+
method: 'GET' | 'POST';
|
|
9
|
+
url: string;
|
|
10
|
+
agent: https.Agent;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
body?: string | Buffer;
|
|
13
|
+
timeoutMs?: number;
|
|
14
|
+
maxResponseBytes?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function request(o: HttpOptions): Promise<HttpResult>;
|
|
17
|
+
export declare function parseJsonBody(r: HttpResult): unknown;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Minimal HTTPS client used by the exporter and the Fleet client. Requests made here are
|
|
2
|
+
// marked internal so the capture hooks never see them.
|
|
3
|
+
import https from 'node:https';
|
|
4
|
+
import { runInternal } from '../capture/common.js';
|
|
5
|
+
export function request(o) {
|
|
6
|
+
return new Promise((resolve, reject) => {
|
|
7
|
+
let settled = false;
|
|
8
|
+
const done = (err, res) => {
|
|
9
|
+
if (settled)
|
|
10
|
+
return;
|
|
11
|
+
settled = true;
|
|
12
|
+
if (err)
|
|
13
|
+
reject(err);
|
|
14
|
+
else
|
|
15
|
+
resolve(res);
|
|
16
|
+
};
|
|
17
|
+
const u = new URL(o.url);
|
|
18
|
+
if (u.protocol !== 'https:') {
|
|
19
|
+
done(new Error('https_required'));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const body = o.body === undefined ? undefined : Buffer.isBuffer(o.body) ? o.body : Buffer.from(o.body, 'utf8');
|
|
23
|
+
const headers = { ...(o.headers ?? {}) };
|
|
24
|
+
if (body)
|
|
25
|
+
headers['content-length'] = String(body.length);
|
|
26
|
+
const max = o.maxResponseBytes ?? 1 << 20;
|
|
27
|
+
const req = runInternal(() => https.request({
|
|
28
|
+
method: o.method,
|
|
29
|
+
hostname: u.hostname.replace(/^\[|\]$/g, ''),
|
|
30
|
+
port: u.port || 443,
|
|
31
|
+
path: u.pathname + u.search,
|
|
32
|
+
agent: o.agent,
|
|
33
|
+
headers,
|
|
34
|
+
servername: /^[0-9.]+$|:/.test(u.hostname) ? undefined : u.hostname,
|
|
35
|
+
}, (res) => {
|
|
36
|
+
const chunks = [];
|
|
37
|
+
let n = 0;
|
|
38
|
+
const tooBig = () => {
|
|
39
|
+
// Over the cap: hang up now instead of reading what the peer decides to send.
|
|
40
|
+
const e = Object.assign(new Error('response_too_large'), { code: 'RESPONSE_TOO_LARGE' });
|
|
41
|
+
done(e);
|
|
42
|
+
req.destroy(e);
|
|
43
|
+
};
|
|
44
|
+
const declared = Number(res.headers['content-length']);
|
|
45
|
+
if (Number.isFinite(declared) && declared > max) {
|
|
46
|
+
tooBig();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
res.on('data', (c) => {
|
|
50
|
+
n += c.length;
|
|
51
|
+
if (n > max) {
|
|
52
|
+
chunks.length = 0;
|
|
53
|
+
tooBig();
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
chunks.push(c);
|
|
57
|
+
});
|
|
58
|
+
res.on('end', () => done(null, { status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks) }));
|
|
59
|
+
res.on('error', (e) => done(e));
|
|
60
|
+
}));
|
|
61
|
+
req.setTimeout(o.timeoutMs ?? 10_000, () => req.destroy(Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' })));
|
|
62
|
+
req.on('error', (e) => done(e));
|
|
63
|
+
req.end(body);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export function parseJsonBody(r) {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(r.body.toString('utf8'));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare function defaultStateDir(enrollUrl: string, serviceName: string): string;
|
|
2
|
+
export declare class StateStore {
|
|
3
|
+
readonly dir: string;
|
|
4
|
+
/** Called with the file name when a state file or the directory is not trusted (reported, never the content). */
|
|
5
|
+
onUntrusted: ((what: string) => void) | undefined;
|
|
6
|
+
constructor(dir: string);
|
|
7
|
+
/**
|
|
8
|
+
* The directory must be ours. If it is ours but open to others (a pre-existing 0755
|
|
9
|
+
* directory) it is tightened to 0700; anything else is refused. Files are checked one by one.
|
|
10
|
+
*/
|
|
11
|
+
private dirTrusted;
|
|
12
|
+
private ensure;
|
|
13
|
+
readText(name: string): string | null;
|
|
14
|
+
readJson<T>(name: string): T | null;
|
|
15
|
+
writeText(name: string, text: string): void;
|
|
16
|
+
writeJson(name: string, v: unknown): void;
|
|
17
|
+
remove(name: string): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Local state of the source: enrollment (certificate, CA chain, pinned keys), private key,
|
|
2
|
+
// signed policy cache, kill switch. Directory 0700, files 0600, atomic replace.
|
|
3
|
+
import { chmodSync, closeSync, constants, fstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
export function defaultStateDir(enrollUrl, serviceName) {
|
|
8
|
+
const id = createHash('sha256').update(enrollUrl + '\0' + serviceName).digest('hex').slice(0, 16);
|
|
9
|
+
return join(homedir(), '.traffic-twin', id);
|
|
10
|
+
}
|
|
11
|
+
function statExists(p) {
|
|
12
|
+
try {
|
|
13
|
+
statSync(p);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const POSIX = process.platform !== 'win32';
|
|
21
|
+
const myUid = typeof process.getuid === 'function' ? process.getuid() : -1;
|
|
22
|
+
export class StateStore {
|
|
23
|
+
dir;
|
|
24
|
+
/** Called with the file name when a state file or the directory is not trusted (reported, never the content). */
|
|
25
|
+
onUntrusted;
|
|
26
|
+
constructor(dir) {
|
|
27
|
+
this.dir = dir;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The directory must be ours. If it is ours but open to others (a pre-existing 0755
|
|
31
|
+
* directory) it is tightened to 0700; anything else is refused. Files are checked one by one.
|
|
32
|
+
*/
|
|
33
|
+
dirTrusted() {
|
|
34
|
+
if (!POSIX)
|
|
35
|
+
return true;
|
|
36
|
+
try {
|
|
37
|
+
const st = statSync(this.dir);
|
|
38
|
+
if (!st.isDirectory())
|
|
39
|
+
return false;
|
|
40
|
+
if (myUid >= 0 && st.uid !== myUid)
|
|
41
|
+
return false;
|
|
42
|
+
if ((st.mode & 0o077) !== 0) {
|
|
43
|
+
chmodSync(this.dir, 0o700);
|
|
44
|
+
if ((statSync(this.dir).mode & 0o077) !== 0)
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
ensure() {
|
|
54
|
+
mkdirSync(this.dir, { recursive: true, mode: 0o700 });
|
|
55
|
+
if (!this.dirTrusted()) {
|
|
56
|
+
this.onUntrusted?.('dir');
|
|
57
|
+
throw new Error('state_dir_untrusted');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
readText(name) {
|
|
61
|
+
let fd = -1;
|
|
62
|
+
try {
|
|
63
|
+
if (!this.dirTrusted()) {
|
|
64
|
+
if (statExists(this.dir))
|
|
65
|
+
this.onUntrusted?.('dir');
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
// O_NOFOLLOW: a symlink planted in place of a state file is not followed; the checks
|
|
69
|
+
// run on the opened descriptor, so they apply to the bytes that are then read.
|
|
70
|
+
fd = openSync(join(this.dir, name), constants.O_RDONLY | (POSIX ? constants.O_NOFOLLOW : 0));
|
|
71
|
+
const st = fstatSync(fd);
|
|
72
|
+
if (POSIX && (!st.isFile() || (myUid >= 0 && st.uid !== myUid) || (st.mode & 0o077) !== 0)) {
|
|
73
|
+
this.onUntrusted?.(name);
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return readFileSync(fd, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
if (fd >= 0)
|
|
83
|
+
closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
readJson(name) {
|
|
87
|
+
const t = this.readText(name);
|
|
88
|
+
if (t === null)
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(t);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
writeText(name, text) {
|
|
98
|
+
this.ensure();
|
|
99
|
+
const tmp = join(this.dir, '.' + name + '.' + process.pid + '.tmp');
|
|
100
|
+
writeFileSync(tmp, text, { mode: 0o600 });
|
|
101
|
+
chmodSync(tmp, 0o600);
|
|
102
|
+
renameSync(tmp, join(this.dir, name));
|
|
103
|
+
}
|
|
104
|
+
writeJson(name, v) {
|
|
105
|
+
this.writeText(name, JSON.stringify(v));
|
|
106
|
+
}
|
|
107
|
+
remove(name) {
|
|
108
|
+
rmSync(join(this.dir, name), { force: true });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Agent, optionsFromEnv, type AgentOptions, type SourceState } from './agent.js';
|
|
2
|
+
export type { AgentOptions, SourceState };
|
|
3
|
+
export { Agent, optionsFromEnv };
|
|
4
|
+
export { SDK_VERSION } from './version.js';
|
|
5
|
+
export { Sanitizer, type Exchange, type Result, type Decision } from './sanitizer/sanitizer.js';
|
|
6
|
+
export { Policy, Refused, parseKeyRing, selectKey, verifyEnvelope, validateBodySchema } from './sanitizer/policy.js';
|
|
7
|
+
export { runVector, parseVector, vectorExchange, type VectorResult } from './sanitizer/vectors.js';
|
|
8
|
+
export { parseJson, writeCanonical, canonicalNumber } from './sanitizer/json.js';
|
|
9
|
+
/** Starts the agent once per process (later calls return the same one). Never throws. */
|
|
10
|
+
export declare function start(options?: AgentOptions): Promise<Agent>;
|
|
11
|
+
/** The running agent, if any. */
|
|
12
|
+
export declare function current(): Agent | undefined;
|
|
13
|
+
/** Stops capture and timers; the process keeps working untouched. */
|
|
14
|
+
export declare function stop(): Promise<void>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Public API of @traffic-twin/sdk.
|
|
2
|
+
//
|
|
3
|
+
// import { start } from '@traffic-twin/sdk';
|
|
4
|
+
// await start({ enrollUrl, enrollToken, serviceName, hmacKeyFile });
|
|
5
|
+
//
|
|
6
|
+
// or zero-config: node --import @traffic-twin/sdk/register app.js
|
|
7
|
+
import { Agent, optionsFromEnv } from './agent.js';
|
|
8
|
+
export { Agent, optionsFromEnv };
|
|
9
|
+
export { SDK_VERSION } from './version.js';
|
|
10
|
+
// Sanitizer and conformance test function (sanitizer.md 16).
|
|
11
|
+
export { Sanitizer } from './sanitizer/sanitizer.js';
|
|
12
|
+
export { Policy, Refused, parseKeyRing, selectKey, verifyEnvelope, validateBodySchema } from './sanitizer/policy.js';
|
|
13
|
+
export { runVector, parseVector, vectorExchange } from './sanitizer/vectors.js';
|
|
14
|
+
export { parseJson, writeCanonical, canonicalNumber } from './sanitizer/json.js';
|
|
15
|
+
// One agent per process, shared by the ESM and CommonJS builds.
|
|
16
|
+
const KEY = Symbol.for('traffic-twin.sdk.agent');
|
|
17
|
+
/** Starts the agent once per process (later calls return the same one). Never throws. */
|
|
18
|
+
export function start(options = {}) {
|
|
19
|
+
const g = globalThis;
|
|
20
|
+
const existing = g[KEY];
|
|
21
|
+
if (existing)
|
|
22
|
+
return existing.ready.then(() => existing.agent);
|
|
23
|
+
const merged = { ...optionsFromEnv(), ...stripUndefined(options) };
|
|
24
|
+
const agent = new Agent(merged);
|
|
25
|
+
const ready = agent.start();
|
|
26
|
+
g[KEY] = { agent, ready };
|
|
27
|
+
return ready.then(() => agent);
|
|
28
|
+
}
|
|
29
|
+
/** The running agent, if any. */
|
|
30
|
+
export function current() {
|
|
31
|
+
return globalThis[KEY]?.agent;
|
|
32
|
+
}
|
|
33
|
+
/** Stops capture and timers; the process keeps working untouched. */
|
|
34
|
+
export async function stop() {
|
|
35
|
+
const g = globalThis;
|
|
36
|
+
const e = g[KEY];
|
|
37
|
+
if (!e)
|
|
38
|
+
return;
|
|
39
|
+
delete g[KEY];
|
|
40
|
+
await e.agent.stop();
|
|
41
|
+
}
|
|
42
|
+
function stripUndefined(o) {
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const k of Object.keys(o))
|
|
45
|
+
if (o[k] !== undefined)
|
|
46
|
+
out[k] = o[k];
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function setDebug(on: boolean): void;
|
|
2
|
+
export declare function debug(code: string, detail?: string | number): void;
|
|
3
|
+
/** A line that is always written (configuration problems that stop the capture). Codes and fixed text only. */
|
|
4
|
+
export declare function warn(text: string): void;
|
package/dist/esm/log.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Diagnostic log. Only error codes and counts are ever written, never payload values
|
|
2
|
+
// (threat model M14). Off unless TT_DEBUG is set.
|
|
3
|
+
let enabled = /^(1|true|yes|debug)$/i.test(process.env.TT_DEBUG ?? '');
|
|
4
|
+
export function setDebug(on) {
|
|
5
|
+
enabled = on;
|
|
6
|
+
}
|
|
7
|
+
export function debug(code, detail) {
|
|
8
|
+
if (!enabled)
|
|
9
|
+
return;
|
|
10
|
+
try {
|
|
11
|
+
process.stderr.write('[traffic-twin] ' + code + (detail !== undefined ? ' ' + String(detail) : '') + '\n');
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// ignore
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** A line that is always written (configuration problems that stop the capture). Codes and fixed text only. */
|
|
18
|
+
export function warn(text) {
|
|
19
|
+
try {
|
|
20
|
+
process.stderr.write('[traffic-twin] ' + text + '\n');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
// ignore
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"module"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Zero-config entry point:
|
|
2
|
+
//
|
|
3
|
+
// TT_ENROLL_URL=https://tt-core.internal:4318 TT_ENROLL_TOKEN=tte_... TT_HMAC_KEY_FILE=/run/tt/key \
|
|
4
|
+
// node --import @traffic-twin/sdk/register app.js
|
|
5
|
+
//
|
|
6
|
+
// (or `node --require @traffic-twin/sdk/register app.js` for CommonJS apps).
|
|
7
|
+
// Hooks are installed synchronously before the app loads; enrollment, heartbeat and policy
|
|
8
|
+
// fetch run in the background and never delay or break the app. TT_DISABLED=1 turns it off.
|
|
9
|
+
import { isMainThread, workerData } from 'node:worker_threads';
|
|
10
|
+
import { start } from './index.js';
|
|
11
|
+
const ownWorker = !isMainThread && workerData?.ttSanitizer === true;
|
|
12
|
+
if (!ownWorker && !/^(1|true|yes)$/i.test(process.env.TT_DISABLED ?? '')) {
|
|
13
|
+
start().catch(() => {
|
|
14
|
+
// never surface SDK failures to the app
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** 22.4: document class on the decoded length. */
|
|
2
|
+
export declare function docClass(n: number): string;
|
|
3
|
+
/** 22.4 point 3: kind of a document from its bytes, or null. */
|
|
4
|
+
export declare function magicKind(b: Buffer): string | null;
|
|
5
|
+
/** 22.4: 'drop:doc:<kind>:<class>' if the string is the base64 of a document, else null. */
|
|
6
|
+
export declare function base64Doc(str: string): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Parameters of a normalized content-type value: lowercase name -> value, first occurrence
|
|
9
|
+
* wins. Split on ';', each piece trimmed and split on the first '='; a value starting with '"'
|
|
10
|
+
* must end with '"' (length >= 2) and loses both quotes, no escapes. Not UTF-8: no parameters.
|
|
11
|
+
*/
|
|
12
|
+
export declare function mediaParams(value: Buffer | null | undefined): Map<string, string>;
|
|
13
|
+
export declare class InvalidMultipart extends Error {
|
|
14
|
+
}
|
|
15
|
+
export interface MultipartPart {
|
|
16
|
+
name: string;
|
|
17
|
+
isFile: boolean;
|
|
18
|
+
content: Buffer;
|
|
19
|
+
ct: string | null;
|
|
20
|
+
}
|
|
21
|
+
/** 22.3 points 1-5: the parts of a multipart body. Throws InvalidMultipart. */
|
|
22
|
+
export declare function parseMultipart(data: Buffer, boundary: string | undefined): MultipartPart[];
|
|
23
|
+
/** 22.3: drop:file:<kind>:<class> of a file part. */
|
|
24
|
+
export declare function fileToken(content: Buffer, ct: string | null): string;
|
|
25
|
+
export declare const NS_XML = "http://www.w3.org/XML/1998/namespace";
|
|
26
|
+
export declare const SOAP11 = "http://schemas.xmlsoap.org/soap/envelope/";
|
|
27
|
+
export declare const SOAP12 = "http://www.w3.org/2003/05/soap-envelope";
|
|
28
|
+
export declare const XML_MAX_NESTING = 256;
|
|
29
|
+
export declare class InvalidXml extends Error {
|
|
30
|
+
}
|
|
31
|
+
export interface XmlElem {
|
|
32
|
+
local: string;
|
|
33
|
+
ns: string;
|
|
34
|
+
/** (local name, value) without namespace declarations. */
|
|
35
|
+
attrs: Array<[string, string]>;
|
|
36
|
+
children: XmlElem[];
|
|
37
|
+
text: string[];
|
|
38
|
+
}
|
|
39
|
+
/** 22.2 points 1-6 -> root element. Throws InvalidXml for anything outside the accepted subset. */
|
|
40
|
+
export declare function parseXml(input: Buffer): XmlElem;
|
|
41
|
+
/** Tree value of an element (22.2 point 7): a string, or an object as ordered members. */
|
|
42
|
+
export type XmlValue = string | XmlObj | XmlValue[];
|
|
43
|
+
export interface XmlObj {
|
|
44
|
+
members: Array<[string, XmlValue]>;
|
|
45
|
+
}
|
|
46
|
+
export declare function xmlValue(el: XmlElem): XmlValue;
|
|
47
|
+
/** 22.2 point 9: 'ns:' + pattern of a namespace URI. */
|
|
48
|
+
export declare function nsPattern(uri: string): string;
|
|
49
|
+
/** 22.2 points 9-10: xmlns entries [(path, token)] in document order (no limit) and fmt. */
|
|
50
|
+
export declare function xmlNamespaces(root: XmlElem, literal: (k: string) => boolean, maxDepth: number): {
|
|
51
|
+
entries: Array<[string, string]>;
|
|
52
|
+
fmt: string;
|
|
53
|
+
};
|
|
54
|
+
/** 22.2 point 11: the operation name of a SOAPAction value, or null when empty. */
|
|
55
|
+
export declare function soapActionName(raw: string, literal: (k: string) => boolean): string | null;
|