fullstackgtm 0.25.1 → 0.26.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/dist/bulkUpdate.js +6 -1
  3. package/dist/cli.js +67 -2
  4. package/dist/connector.js +90 -1
  5. package/dist/connectors/hubspot.js +5 -2
  6. package/dist/connectors/salesforce.js +4 -2
  7. package/dist/connectors/stripe.js +4 -2
  8. package/dist/credentials.js +22 -1
  9. package/dist/dedupe.d.ts +6 -0
  10. package/dist/dedupe.js +24 -1
  11. package/dist/enrich.js +24 -2
  12. package/dist/enrichApollo.js +5 -2
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.js +1 -0
  15. package/dist/integrity.d.ts +30 -0
  16. package/dist/integrity.js +128 -0
  17. package/dist/market.d.ts +1 -0
  18. package/dist/market.js +144 -8
  19. package/dist/marketReport.d.ts +9 -0
  20. package/dist/marketReport.js +29 -4
  21. package/dist/marketTaxonomy.d.ts +41 -0
  22. package/dist/marketTaxonomy.js +193 -0
  23. package/dist/planStore.d.ts +6 -0
  24. package/dist/planStore.js +10 -2
  25. package/dist/schedule.d.ts +17 -0
  26. package/dist/schedule.js +87 -2
  27. package/dist/types.d.ts +16 -0
  28. package/package.json +1 -1
  29. package/src/bulkUpdate.ts +6 -1
  30. package/src/cli.ts +80 -1
  31. package/src/connector.ts +96 -1
  32. package/src/connectors/hubspot.ts +5 -2
  33. package/src/connectors/salesforce.ts +4 -2
  34. package/src/connectors/stripe.ts +4 -2
  35. package/src/credentials.ts +24 -0
  36. package/src/dedupe.ts +23 -1
  37. package/src/enrich.ts +25 -2
  38. package/src/enrichApollo.ts +5 -2
  39. package/src/index.ts +8 -0
  40. package/src/integrity.ts +146 -0
  41. package/src/market.ts +129 -8
  42. package/src/marketReport.ts +30 -4
  43. package/src/marketTaxonomy.ts +288 -0
  44. package/src/planStore.ts +23 -4
  45. package/src/schedule.ts +98 -2
  46. package/src/types.ts +16 -0
@@ -78,9 +78,12 @@ export function createApolloClient(options: ApolloClientOptions): ApolloClient {
78
78
  }
79
79
  if (response.status === 404) return null;
80
80
  if (!response.ok) {
81
- const body = await response.text();
81
+ // Status line only — never interpolate the response body. It can echo
82
+ // the submitted query (contact emails / company domains) or the API key,
83
+ // and these errors are persisted verbatim into scheduled-run records.
84
+ await response.text().catch(() => undefined);
82
85
  const exhausted = response.status === 429 ? ` (rate limited; ${maxRetries} retries exhausted)` : "";
83
- throw new Error(`Apollo API error ${response.status}${exhausted}: ${body}`);
86
+ throw new Error(`Apollo API error ${response.status}${exhausted}. Check the API key and request.`);
84
87
  }
85
88
  const text = await response.text();
86
89
  return text ? (JSON.parse(text) as Record<string, unknown>) : null;
package/src/index.ts CHANGED
@@ -115,6 +115,14 @@ export {
115
115
  type MergeSuggestion,
116
116
  } from "./merge.ts";
117
117
  export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
118
+ export {
119
+ computeApprovalDigests,
120
+ loadOrCreateSigningKey,
121
+ loadSigningKey,
122
+ signApproval,
123
+ verifyApprovalDigests,
124
+ type ApprovalVerification,
125
+ } from "./integrity.ts";
118
126
  export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
119
127
  export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
120
128
  export {
@@ -0,0 +1,146 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
5
+ import type { PatchOperation } from "./types.ts";
6
+
7
+ /**
8
+ * Approval integrity.
9
+ *
10
+ * The plan store records WHICH operation ids a human approved, but the apply
11
+ * path re-reads the operation BODIES fresh from the (user-editable) plan file.
12
+ * Nothing bound the approval to the content: an approved op's afterValue or
13
+ * objectId could be changed on disk between `plans approve` and `apply` — by a
14
+ * compromised dependency, a co-tenant, or a plan file synced/edited on another
15
+ * machine — and the changed value would be written under the prior approval.
16
+ *
17
+ * Fix: at approval time, HMAC-sign each approved operation's security-relevant
18
+ * content (including the approved value override) with a per-install secret key
19
+ * stored 0600 alongside the credentials. At apply time, recompute and verify.
20
+ * Any post-approval edit to the operations or the approved overrides changes the
21
+ * signature; a tamper must now also forge an HMAC it cannot compute without the
22
+ * key. The key never leaves the machine, so a plan approved here and applied
23
+ * elsewhere fails closed ("re-approve on this machine") rather than open.
24
+ *
25
+ * This raises the bar from "trust the plan JSON" to "trust the plan JSON only
26
+ * insofar as it still matches what was signed with the local key." It is not a
27
+ * defense against an attacker who already holds the signing key (same-dir, same
28
+ * permissions as the credential store) — that is the documented boundary.
29
+ */
30
+
31
+ const SIGNING_KEY_FILE = ".plan-signing-key";
32
+
33
+ function signingKeyPath(): string {
34
+ return join(credentialsDir(), SIGNING_KEY_FILE);
35
+ }
36
+
37
+ /** Read the signing key, or null if it has not been created yet. */
38
+ export function loadSigningKey(): Buffer | null {
39
+ const path = signingKeyPath();
40
+ if (!existsSync(path)) return null;
41
+ try {
42
+ return Buffer.from(readFileSync(path, "utf8").trim(), "hex");
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** Read the signing key, creating a fresh 32-byte one (0600) on first use. */
49
+ export function loadOrCreateSigningKey(): Buffer {
50
+ const existing = loadSigningKey();
51
+ if (existing && existing.length >= 32) return existing;
52
+ ensureSecureHomeDir();
53
+ const key = randomBytes(32);
54
+ writeSecureFile(signingKeyPath(), `${key.toString("hex")}\n`);
55
+ return key;
56
+ }
57
+
58
+ /**
59
+ * Canonical, stable string of the operation content an approval binds to. Only
60
+ * the fields that determine WHAT gets written: changing any of them must
61
+ * invalidate the approval. `override` is the approved value override for this op
62
+ * (the value actually written when set), so tampering with stored overrides is
63
+ * caught too.
64
+ */
65
+ function canonicalApprovalContent(operation: PatchOperation, override: unknown): string {
66
+ return JSON.stringify([
67
+ operation.id,
68
+ operation.operation,
69
+ operation.objectType,
70
+ operation.objectId,
71
+ operation.field ?? null,
72
+ operation.beforeValue ?? null,
73
+ operation.afterValue ?? null,
74
+ operation.groupId ?? null,
75
+ // Safety-relevant fields too: editing a precondition could relax a drift
76
+ // guard, and forging forceArchiveDuplicate could suppress the archive-of-
77
+ // duplicate refusal — the signed approval must pin apply BEHAVIOR, not just
78
+ // the written value. `reason` is human-reviewed AND written verbatim into
79
+ // create_task bodies (afterValue ?? reason fallback in the connectors), so a
80
+ // create_task with a null afterValue would otherwise let a disk edit to
81
+ // reason write unapproved text under a still-valid digest.
82
+ operation.preconditions ?? null,
83
+ operation.forceArchiveDuplicate ?? false,
84
+ operation.reason ?? null,
85
+ override === undefined ? null : ["__override__", override],
86
+ ]);
87
+ }
88
+
89
+ /** HMAC-SHA256 signature of one operation's approved content. */
90
+ export function signApproval(operation: PatchOperation, override: unknown, key: Buffer): string {
91
+ return createHmac("sha256", key).update(canonicalApprovalContent(operation, override)).digest("hex");
92
+ }
93
+
94
+ /**
95
+ * Compute the approval signature map for a set of approved operation ids,
96
+ * resolving each op from the plan and its (approved) value override.
97
+ */
98
+ export function computeApprovalDigests(
99
+ operations: PatchOperation[],
100
+ approvedOperationIds: string[],
101
+ valueOverrides: Record<string, unknown>,
102
+ key: Buffer,
103
+ ): Record<string, string> {
104
+ const byId = new Map(operations.map((operation) => [operation.id, operation]));
105
+ const digests: Record<string, string> = {};
106
+ for (const id of approvedOperationIds) {
107
+ const operation = byId.get(id);
108
+ if (!operation) continue;
109
+ digests[id] = signApproval(operation, valueOverrides[id], key);
110
+ }
111
+ return digests;
112
+ }
113
+
114
+ export type ApprovalVerification =
115
+ | { ok: true }
116
+ | { ok: false; reason: "no_key"; tampered: string[] }
117
+ | { ok: false; reason: "mismatch"; tampered: string[] };
118
+
119
+ /**
120
+ * Verify that every approved operation still matches what was signed. Returns
121
+ * ok:true when there are no stored digests (a pre-integrity plan — nothing to
122
+ * verify), when all match, or fails with the list of operation ids whose
123
+ * content changed since approval.
124
+ */
125
+ export function verifyApprovalDigests(
126
+ operations: PatchOperation[],
127
+ approvedOperationIds: string[],
128
+ valueOverrides: Record<string, unknown>,
129
+ storedDigests: Record<string, string> | undefined,
130
+ ): ApprovalVerification {
131
+ if (!storedDigests || Object.keys(storedDigests).length === 0) return { ok: true };
132
+ const key = loadSigningKey();
133
+ if (!key) return { ok: false, reason: "no_key", tampered: approvedOperationIds };
134
+ const byId = new Map(operations.map((operation) => [operation.id, operation]));
135
+ const tampered: string[] = [];
136
+ for (const id of approvedOperationIds) {
137
+ const operation = byId.get(id);
138
+ const expected = storedDigests[id];
139
+ if (!operation || !expected) {
140
+ tampered.push(id);
141
+ continue;
142
+ }
143
+ if (signApproval(operation, valueOverrides[id], key) !== expected) tampered.push(id);
144
+ }
145
+ return tampered.length === 0 ? { ok: true } : { ok: false, reason: "mismatch", tampered };
146
+ }
package/src/market.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { lookup } from "node:dns/promises";
2
3
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
+ import { isIP } from "node:net";
3
5
  import { join } from "node:path";
4
6
  import { credentialsDir } from "./credentials.ts";
5
7
  import type { GtmEvidence } from "./types.ts";
@@ -309,15 +311,129 @@ export function extractReadableText(html: string): string {
309
311
 
310
312
  export type FetchPage = (url: string) => Promise<{ status: number; body: string }>;
311
313
 
314
+ /**
315
+ * SSRF guard. market.config.json URLs are operator-authored, but configs are
316
+ * shared/templated in consulting/team use and `market capture|refresh` is on
317
+ * the cron allowlist — an unguarded fetch is an unattended internal-network
318
+ * and cloud-metadata probe. We therefore (1) allow only http/https, (2) refuse
319
+ * any host that is or resolves to a private/loopback/link-local/metadata
320
+ * address, and (3) follow redirects manually, re-validating each hop.
321
+ *
322
+ * Residual gap (documented, not defended here): TOCTOU DNS rebinding between
323
+ * our lookup and fetch's own resolution. Out of scope for fetching public
324
+ * competitor pages; a hardened deployment should fetch through an egress proxy.
325
+ */
326
+ const MAX_REDIRECTS = 5;
327
+ const FETCH_TIMEOUT_MS = 15_000;
328
+ const MAX_BODY_BYTES = 5_000_000;
329
+
330
+ function ipv4IsPrivate(ip: string): boolean {
331
+ const parts = ip.split(".").map((n) => Number(n));
332
+ if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
333
+ const [a, b] = parts;
334
+ if (a === 0 || a === 127) return true; // this-host, loopback
335
+ if (a === 10) return true; // private
336
+ if (a === 172 && b >= 16 && b <= 31) return true; // private
337
+ if (a === 192 && b === 168) return true; // private
338
+ if (a === 169 && b === 254) return true; // link-local incl. 169.254.169.254 metadata
339
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
340
+ if (a >= 224) return true; // multicast / reserved
341
+ return false;
342
+ }
343
+
344
+ function ipIsPrivate(ip: string): boolean {
345
+ const family = isIP(ip);
346
+ if (family === 4) return ipv4IsPrivate(ip);
347
+ if (family === 6) {
348
+ const lower = ip.toLowerCase();
349
+ if (lower === "::1" || lower === "::") return true; // loopback / unspecified
350
+ // IPv4-mapped (::ffff:…) — Node normalizes ::ffff:127.0.0.1 to ::ffff:7f00:1,
351
+ // so accept both the dotted and the hex-pair forms, unwrap, check the v4.
352
+ const mapped = lower.match(/^::ffff:(.+)$/);
353
+ if (mapped) {
354
+ const rest = mapped[1];
355
+ if (rest.includes(".")) return ipv4IsPrivate(rest);
356
+ const groups = rest.split(":");
357
+ if (groups.length === 2) {
358
+ const hi = parseInt(groups[0], 16);
359
+ const lo = parseInt(groups[1], 16);
360
+ if (Number.isNaN(hi) || Number.isNaN(lo)) return true;
361
+ return ipv4IsPrivate(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
362
+ }
363
+ return true; // unrecognized mapped form → refuse
364
+ }
365
+ if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // link-local fe80::/10
366
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique-local fc00::/7
367
+ return false;
368
+ }
369
+ return true; // not a recognizable IP literal → refuse
370
+ }
371
+
372
+ export async function assertPublicUrl(rawUrl: string): Promise<URL> {
373
+ let url: URL;
374
+ try {
375
+ url = new URL(rawUrl);
376
+ } catch {
377
+ throw new Error(`market capture: "${rawUrl}" is not a valid URL.`);
378
+ }
379
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
380
+ throw new Error(`market capture refuses ${url.protocol} URLs (only http/https): ${rawUrl}`);
381
+ }
382
+ const host = url.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
383
+ if (isIP(host)) {
384
+ if (ipIsPrivate(host)) throw new Error(`market capture refuses private/loopback address ${host} (SSRF guard).`);
385
+ return url;
386
+ }
387
+ // Hostname: resolve and refuse if ANY address is private.
388
+ const addrs = await lookup(host, { all: true });
389
+ for (const { address } of addrs) {
390
+ if (ipIsPrivate(address)) {
391
+ throw new Error(`market capture refuses ${host} — it resolves to private/internal address ${address} (SSRF guard).`);
392
+ }
393
+ }
394
+ return url;
395
+ }
396
+
312
397
  const defaultFetchPage: FetchPage = async (url) => {
313
- const response = await fetch(url, {
314
- headers: {
315
- "User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)",
316
- "Accept-Language": "en-US",
317
- },
318
- redirect: "follow",
319
- });
320
- return { status: response.status, body: await response.text() };
398
+ let current = url;
399
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
400
+ await assertPublicUrl(current);
401
+ const controller = new AbortController();
402
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
403
+ let response: Response;
404
+ try {
405
+ response = await fetch(current, {
406
+ headers: {
407
+ "User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)",
408
+ "Accept-Language": "en-US",
409
+ },
410
+ redirect: "manual",
411
+ signal: controller.signal,
412
+ });
413
+ } finally {
414
+ clearTimeout(timer);
415
+ }
416
+ if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
417
+ current = new URL(response.headers.get("location") as string, current).toString();
418
+ continue; // re-validate the redirect target on the next iteration
419
+ }
420
+ const reader = response.body?.getReader();
421
+ if (!reader) return { status: response.status, body: await response.text() };
422
+ const chunks: Uint8Array[] = [];
423
+ let total = 0;
424
+ for (;;) {
425
+ const { done, value } = await reader.read();
426
+ if (done) break;
427
+ total += value.length;
428
+ if (total > MAX_BODY_BYTES) {
429
+ await reader.cancel();
430
+ break;
431
+ }
432
+ chunks.push(value);
433
+ }
434
+ return { status: response.status, body: Buffer.concat(chunks).toString("utf8") };
435
+ }
436
+ throw new Error(`market capture: too many redirects (>${MAX_REDIRECTS}) for ${url}`);
321
437
  };
322
438
 
323
439
  export type CaptureOptions = {
@@ -478,6 +594,11 @@ export function validateObservationSet(config: MarketConfig, set: ObservationSet
478
594
  if (!INTENSITY_RANK[obs.intensity] && obs.intensity !== "unobservable") {
479
595
  problems.push(`${cell}: invalid intensity "${obs.intensity}"`);
480
596
  }
597
+ // confidence is rendered into the HTML report; only the enum is allowed, so
598
+ // an `observe --from` file can't smuggle markup through a free-text value.
599
+ if (obs.confidence !== "high" && obs.confidence !== "medium" && obs.confidence !== "low") {
600
+ problems.push(`${cell}: invalid confidence "${String(obs.confidence)}" (expected high, medium, or low)`);
601
+ }
481
602
  if ((obs.intensity === "loud" || obs.intensity === "quiet") && obs.evidence.length === 0) {
482
603
  problems.push(`${cell}: ${obs.intensity} reading with no quoted evidence`);
483
604
  }
@@ -40,6 +40,23 @@ function escapeHtml(value: string): string {
40
40
  .replace(/"/g, "&quot;");
41
41
  }
42
42
 
43
+ /**
44
+ * Serialize JSON for embedding inside an inline <script> block. JSON.stringify
45
+ * does not escape `<`, `>`, `&`, or the U+2028/U+2029 line separators, so a
46
+ * vendor name containing `</script>` (these are untrusted, competitor-authored
47
+ * strings) would close the tag and inject markup. Replacing them with their
48
+ * \uXXXX escapes keeps the parsed value identical while making the breakout
49
+ * sequence unrepresentable in the HTML source.
50
+ */
51
+ export function safeJsonForScript(value: unknown): string {
52
+ return JSON.stringify(value)
53
+ .replace(/</g, "\\u003c")
54
+ .replace(/>/g, "\\u003e")
55
+ .replace(/&/g, "\\u0026")
56
+ .replace(/\u2028/g, "\\u2028")
57
+ .replace(/\u2029/g, "\\u2029");
58
+ }
59
+
43
60
  type MapModel = {
44
61
  config: MarketConfig;
45
62
  set: ObservationSet;
@@ -374,7 +391,7 @@ function axisSectionsHtml(
374
391
  <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
375
392
  </div>
376
393
  <div class="map-tip" id="map-tip" hidden></div>
377
- <script type="application/json" id="map-data">${JSON.stringify(tipData)}</script>
394
+ <script type="application/json" id="map-data">${safeJsonForScript(tipData)}</script>
378
395
  <script>
379
396
  (function () {
380
397
  var data = JSON.parse(document.getElementById("map-data").textContent);
@@ -385,7 +402,16 @@ function axisSectionsHtml(
385
402
  function show(v, evt) {
386
403
  var d = data[v];
387
404
  if (!d) return;
388
- tip.innerHTML = "<b>" + d.n + " · " + d.name + "</b>" + d.lines.map(function (l) { return "<div>" + l + "</div>"; }).join("");
405
+ // textContent only vendor names / axis labels are untrusted (competitor-controlled).
406
+ tip.textContent = "";
407
+ var head = document.createElement("b");
408
+ head.textContent = d.n + " · " + d.name;
409
+ tip.appendChild(head);
410
+ d.lines.forEach(function (l) {
411
+ var div = document.createElement("div");
412
+ div.textContent = l;
413
+ tip.appendChild(div);
414
+ });
389
415
  tip.hidden = false;
390
416
  var box = fig.getBoundingClientRect();
391
417
  tip.style.left = Math.min(evt.clientX - box.left + 14, box.width - tip.offsetWidth - 8) + "px";
@@ -481,7 +507,7 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
481
507
  const anchorLoud = anchor
482
508
  ? claimIds.filter((claimId) => model.cell(anchor, claimId)?.intensity === "loud").length
483
509
  : 0;
484
- const anchorNote = anchor ? ` · ${vendorNamesById.get(anchor) ?? anchor} loud on ${anchorLoud}` : "";
510
+ const anchorNote = anchor ? ` · ${e(vendorNamesById.get(anchor) ?? anchor)} loud on ${anchorLoud}` : "";
485
511
  return `<details class="claim-group"><summary><b>${e(group.title)}</b> — ${claimIds.length} claim${claimIds.length === 1 ? "" : "s"} <span class="sum-soft">(${e(group.blurb)}${anchorNote})</span></summary>
486
512
  <table><thead><tr><th></th>${vendorHeads}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
487
513
  </details>`;
@@ -543,7 +569,7 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
543
569
  if (!obs || obs.evidence.length === 0) return [];
544
570
  return obs.evidence.map(
545
571
  (evidence) =>
546
- `<div class="ev"><span class="ev-head">${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
572
+ `<div class="ev"><span class="ev-head">${e(claimId)} · ${e(obs.intensity.toUpperCase())} (${e(String(obs.confidence ?? ""))})</span>` +
547
573
  `<blockquote>“${e(evidence.text)}”</blockquote>` +
548
574
  `<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`,
549
575
  );