fullstackgtm 0.26.0 → 0.28.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/CHANGELOG.md +83 -0
- package/DATA-FLOWS.md +52 -0
- package/NOTICE +5 -0
- package/README.md +18 -2
- package/SECURITY.md +82 -0
- package/dist/auditLog.d.ts +58 -0
- package/dist/auditLog.js +112 -0
- package/dist/bulkUpdate.d.ts +16 -4
- package/dist/bulkUpdate.js +209 -10
- package/dist/cli.d.ts +8 -1
- package/dist/cli.js +93 -1
- package/dist/connector.js +6 -2
- package/dist/credentials.js +85 -11
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/keychain.d.ts +30 -0
- package/dist/keychain.js +85 -0
- package/dist/llm.js +48 -0
- package/dist/mcp.js +8 -1
- package/dist/types.d.ts +6 -0
- package/docs/api.md +5 -2
- package/llms.txt +7 -1
- package/package.json +7 -4
- package/src/auditLog.ts +173 -0
- package/src/bulkUpdate.ts +226 -10
- package/src/cli.ts +90 -1
- package/src/connector.ts +6 -2
- package/src/credentials.ts +82 -11
- package/src/index.ts +7 -0
- package/src/keychain.ts +112 -0
- package/src/llm.ts +47 -0
- package/src/mcp.ts +8 -1
- package/src/types.ts +10 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional OS-keychain backing for the credential store. Off by default;
|
|
3
|
+
* enabled with FSGTM_KEYCHAIN=1. When on, the credential blob is stored in the
|
|
4
|
+
* OS secret store instead of a 0600 file, so a cloned home, a restored backup,
|
|
5
|
+
* or another tool reading `~/.fullstackgtm/credentials.json` finds nothing.
|
|
6
|
+
*
|
|
7
|
+
* Backends shell out to the OS tool — no native dependency, so the package
|
|
8
|
+
* stays zero-dep:
|
|
9
|
+
* - Linux: `secret-tool` (libsecret) — reads the secret from STDIN (no argv leak).
|
|
10
|
+
* - macOS: `security` — `add-generic-password` only accepts the secret via the
|
|
11
|
+
* `-w` argv flag, so it is briefly visible to same-user `ps` during the call.
|
|
12
|
+
* That transient, same-user exposure is strictly smaller than a persistent
|
|
13
|
+
* plaintext file (which the same processes can read at any time), but it is a
|
|
14
|
+
* real caveat, documented in SECURITY.md.
|
|
15
|
+
*
|
|
16
|
+
* Keychain entries are NOT scoped by $FSGTM_HOME (the OS store is machine-wide),
|
|
17
|
+
* so the account name is derived from the credential file path to keep distinct
|
|
18
|
+
* homes/profiles from colliding. This is also why keychain is opt-in: defaulting
|
|
19
|
+
* it on would make throwaway-home test/eval runs write to the machine keychain.
|
|
20
|
+
*/
|
|
21
|
+
export type KeychainBackend = {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
get(account: string): string | null;
|
|
24
|
+
set(account: string, secret: string): void;
|
|
25
|
+
delete(account: string): void;
|
|
26
|
+
};
|
|
27
|
+
/** Test seam: force a backend (or null to force "none"). undefined = re-detect. */
|
|
28
|
+
export declare function setKeychainBackendForTests(backend: KeychainBackend | null | undefined): void;
|
|
29
|
+
/** The active backend for this platform, or null if none is available. */
|
|
30
|
+
export declare function detectKeychainBackend(): KeychainBackend | null;
|
package/dist/keychain.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { platform } from "node:os";
|
|
3
|
+
const SERVICE = "fullstackgtm";
|
|
4
|
+
function hasBinary(bin) {
|
|
5
|
+
try {
|
|
6
|
+
execFileSync("/usr/bin/env", ["which", bin], { stdio: "ignore" });
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const macosBackend = {
|
|
14
|
+
name: "macos-keychain",
|
|
15
|
+
get(account) {
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync("security", ["find-generic-password", "-s", SERVICE, "-a", account, "-w"], {
|
|
18
|
+
encoding: "utf8",
|
|
19
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
20
|
+
}).replace(/\n$/, "");
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null; // not found → non-zero exit
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
set(account, secret) {
|
|
27
|
+
// -U updates if present. NOTE: the secret is in argv for the duration of
|
|
28
|
+
// this call (see the module comment); `security` has no stdin path.
|
|
29
|
+
execFileSync("security", ["add-generic-password", "-U", "-s", SERVICE, "-a", account, "-w", secret], {
|
|
30
|
+
stdio: "ignore",
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
delete(account) {
|
|
34
|
+
try {
|
|
35
|
+
execFileSync("security", ["delete-generic-password", "-s", SERVICE, "-a", account], { stdio: "ignore" });
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// already absent
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const secretToolBackend = {
|
|
43
|
+
name: "linux-secret-tool",
|
|
44
|
+
get(account) {
|
|
45
|
+
try {
|
|
46
|
+
return execFileSync("secret-tool", ["lookup", "service", SERVICE, "account", account], {
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
set(account, secret) {
|
|
56
|
+
// secret-tool reads the secret from STDIN — no argv exposure.
|
|
57
|
+
execFileSync("secret-tool", ["store", "--label", `${SERVICE} ${account}`, "service", SERVICE, "account", account], {
|
|
58
|
+
input: secret,
|
|
59
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
delete(account) {
|
|
63
|
+
try {
|
|
64
|
+
execFileSync("secret-tool", ["clear", "service", SERVICE, "account", account], { stdio: "ignore" });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// already absent
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
let override;
|
|
72
|
+
/** Test seam: force a backend (or null to force "none"). undefined = re-detect. */
|
|
73
|
+
export function setKeychainBackendForTests(backend) {
|
|
74
|
+
override = backend;
|
|
75
|
+
}
|
|
76
|
+
/** The active backend for this platform, or null if none is available. */
|
|
77
|
+
export function detectKeychainBackend() {
|
|
78
|
+
if (override !== undefined)
|
|
79
|
+
return override;
|
|
80
|
+
if (platform() === "darwin" && hasBinary("security"))
|
|
81
|
+
return macosBackend;
|
|
82
|
+
if (platform() === "linux" && hasBinary("secret-tool"))
|
|
83
|
+
return secretToolBackend;
|
|
84
|
+
return null;
|
|
85
|
+
}
|
package/dist/llm.js
CHANGED
|
@@ -70,8 +70,23 @@ export async function extractInsightsLlm(transcript, options) {
|
|
|
70
70
|
const text = truncateTranscript(transcript);
|
|
71
71
|
const prompt = `${EXTRACT_INSTRUCTIONS}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
|
|
72
72
|
const result = (await forcedToolCall(prompt, "extract_call_insights", EXTRACT_SCHEMA, model, options));
|
|
73
|
+
const normalizedTranscript = normalizeSpan(text);
|
|
73
74
|
const insights = (result.insights ?? [])
|
|
74
75
|
.filter((insight) => INSIGHT_TYPES.includes(insight.type))
|
|
76
|
+
// Mechanical verbatim gate (mirrors market classify): the prompt asks for a
|
|
77
|
+
// verbatim quote, but a prompt-injected or hallucinated transcript could
|
|
78
|
+
// fabricate a grounded-looking insight that drives a governed writeback.
|
|
79
|
+
// (1) The evidence quote must be a non-trivial verbatim span of the transcript.
|
|
80
|
+
.filter((insight) => {
|
|
81
|
+
const quote = normalizeSpan(insight.evidence ?? "");
|
|
82
|
+
return quote.length >= 12 && normalizedTranscript.includes(quote);
|
|
83
|
+
})
|
|
84
|
+
// (2) For next_step — the only insight type whose `text` is WRITTEN to the CRM
|
|
85
|
+
// (set_field nextStep / create_task body) — the written action must itself be
|
|
86
|
+
// grounded in the verified quote, not just accompanied by an innocuous one.
|
|
87
|
+
// This closes the decoupling attack: a prompt-injected transcript that emits a
|
|
88
|
+
// malicious `text` while quoting an unrelated real span no longer survives.
|
|
89
|
+
.filter((insight) => insight.type !== "next_step" || actionGroundedInEvidence(insight.text, insight.evidence ?? ""))
|
|
75
90
|
.map((insight) => ({
|
|
76
91
|
...insight,
|
|
77
92
|
title: insight.type.replace(/_/g, " "),
|
|
@@ -81,6 +96,39 @@ export async function extractInsightsLlm(transcript, options) {
|
|
|
81
96
|
.sort((a, b) => b.importance - a.importance || b.confidence - a.confidence);
|
|
82
97
|
return { insights, model };
|
|
83
98
|
}
|
|
99
|
+
/** Whitespace/punctuation-spacing-normalized match (same rule as market spans). */
|
|
100
|
+
function normalizeSpan(value) {
|
|
101
|
+
return value
|
|
102
|
+
.replace(/\s+([.,;:!?])/g, "$1")
|
|
103
|
+
.replace(/\s+/g, " ")
|
|
104
|
+
.trim()
|
|
105
|
+
.toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Is the written next-step action grounded in its (already transcript-verified)
|
|
109
|
+
* evidence quote? A legitimate next step paraphrases the quote, so it reuses the
|
|
110
|
+
* quote's salient terms; a prompt-injected action ("wire $50,000 to account
|
|
111
|
+
* 1234") quoting an unrelated innocuous span does not. Two checks: every
|
|
112
|
+
* number/amount in the action must appear in the evidence (defeats the
|
|
113
|
+
* financial-exfil class cleanly), and a meaningful share of the action's
|
|
114
|
+
* distinctive (≥4-char) words must appear in the evidence.
|
|
115
|
+
*/
|
|
116
|
+
function actionGroundedInEvidence(text, evidence) {
|
|
117
|
+
const action = normalizeSpan(text);
|
|
118
|
+
const quote = normalizeSpan(evidence);
|
|
119
|
+
if (!action)
|
|
120
|
+
return false;
|
|
121
|
+
const numbers = action.match(/\d[\d,.]*/g) ?? [];
|
|
122
|
+
for (const n of numbers) {
|
|
123
|
+
if (!quote.includes(n))
|
|
124
|
+
return false; // an ungrounded amount/account/id is a red flag
|
|
125
|
+
}
|
|
126
|
+
const distinctive = [...new Set(action.split(/[^a-z0-9$]+/).filter((token) => token.length >= 4))];
|
|
127
|
+
if (distinctive.length === 0)
|
|
128
|
+
return true; // nothing distinctive to ground (a short generic step)
|
|
129
|
+
const grounded = distinctive.filter((token) => quote.includes(token)).length;
|
|
130
|
+
return grounded / distinctive.length >= 0.4;
|
|
131
|
+
}
|
|
84
132
|
export const DEFAULT_RUBRIC = {
|
|
85
133
|
scale: 5,
|
|
86
134
|
dimensions: [
|
package/dist/mcp.js
CHANGED
|
@@ -23,8 +23,15 @@ async function importPeer(specifier) {
|
|
|
23
23
|
}
|
|
24
24
|
catch (error) {
|
|
25
25
|
try {
|
|
26
|
+
// Last-resort fallback to the invoking project's node_modules (the npx
|
|
27
|
+
// landmine: peers there, fullstackgtm in the npx cache). This loads code
|
|
28
|
+
// from the current working directory, so make it VISIBLE — running the
|
|
29
|
+
// MCP server in an untrusted directory could otherwise silently load a
|
|
30
|
+
// malicious `zod`/SDK from its node_modules.
|
|
26
31
|
const projectRequire = createRequire(join(process.cwd(), "package.json"));
|
|
27
|
-
|
|
32
|
+
const resolved = projectRequire.resolve(specifier);
|
|
33
|
+
console.error(`fullstackgtm-mcp: loading peer "${specifier}" from the current directory (${resolved}). Only run the MCP server in a directory you trust.`);
|
|
34
|
+
return (await import(__rewriteRelativeImportExtension(pathToFileURL(resolved).href)));
|
|
28
35
|
}
|
|
29
36
|
catch {
|
|
30
37
|
throw error; // the original error carries the missing-peer signal mcp-bin reports on
|
package/dist/types.d.ts
CHANGED
|
@@ -281,6 +281,12 @@ export type PatchPlan = {
|
|
|
281
281
|
filter?: {
|
|
282
282
|
objectType: "account" | "contact" | "deal";
|
|
283
283
|
where: string[];
|
|
284
|
+
/**
|
|
285
|
+
* The date the filter's comparison `today` literal resolves to (ISO
|
|
286
|
+
* yyyy-mm-dd). Stored so apply-time re-verification resolves `today`
|
|
287
|
+
* identically to plan time; absent on plans built before comparison ops.
|
|
288
|
+
*/
|
|
289
|
+
today?: string;
|
|
284
290
|
};
|
|
285
291
|
/**
|
|
286
292
|
* Plan-level guards re-evaluated against a FRESH snapshot at apply time.
|
package/docs/api.md
CHANGED
|
@@ -91,8 +91,11 @@ emits a standard dry-run `PatchPlan` for the normal approve → apply chain:
|
|
|
91
91
|
|
|
92
92
|
- `buildBulkUpdatePlan(snapshot, options: BulkUpdateOptions)` with
|
|
93
93
|
`parseWhere` (filter expressions: `=`, `!=`, `~`, `!~`, `:empty`,
|
|
94
|
-
`:notempty`,
|
|
95
|
-
`
|
|
94
|
+
`:notempty`, type-aware comparisons `<`, `>`, `<=`, `>=` — `today` resolves
|
|
95
|
+
to `options.today`/the policy date, date and numeric fields coerce by value
|
|
96
|
+
form, `|` any-of, relational pseudo-fields) and `isFilterableField`. Filters
|
|
97
|
+
are re-verified per record at apply time (the resolved `today` rides along on
|
|
98
|
+
`plan.filter.today` so re-verification agrees with plan time);
|
|
96
99
|
`from:<sourceField>` values derive per record from the snapshot.
|
|
97
100
|
- `buildDedupePlan(snapshot, options: DedupeOptions)` with `dedupeKey` —
|
|
98
101
|
duplicate groups by normalized identity key, one `merge_records` per group,
|
package/llms.txt
CHANGED
|
@@ -61,7 +61,13 @@ Storage is profile-scoped under `<home>/market/<category>`. MCP:
|
|
|
61
61
|
snapshot into a dry-run plan; the FULL filter is re-verified per record at
|
|
62
62
|
apply time (plus mid-apply rechecks); equality filters double as
|
|
63
63
|
preconditions, `--require`/`--guard` add explicit ones, `--max-operations`
|
|
64
|
-
caps blast radius.
|
|
64
|
+
caps blast radius. Filter operators: `=` `!=` `~` `!~` `:empty` `:notempty`,
|
|
65
|
+
plus type-aware comparisons `<` `>` `<=` `>=` (`today` resolves to the policy
|
|
66
|
+
date, e.g. `closeDate<today`; date fields compare as dates, numeric as
|
|
67
|
+
numbers, unset/non-parseable values do not match). For date/count hygiene
|
|
68
|
+
(past close dates, stale deals, missing accounts, duplicates) prefer the
|
|
69
|
+
rule-backed `fix --rule <id>` — it encodes the date/open-deal logic
|
|
70
|
+
deterministically; reach for bulk-update only when no rule covers the task. `--set f=from:<source>` derives per-record values (empty
|
|
65
71
|
source = skip + count, never guess). `--archive` refuses records sharing an
|
|
66
72
|
identity key — merge with `dedupe` instead. `dedupe <object> --key
|
|
67
73
|
<domain|email|name>` = one merge_records op per duplicate group,
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
|
-
"author": "Full Stack GTM",
|
|
6
|
+
"author": "Full Stack GTM <security@fullstackgtm.com> (https://fullstackgtm.com)",
|
|
7
7
|
"homepage": "https://github.com/fullstackgtm/core#readme",
|
|
8
8
|
"bugs": {
|
|
9
9
|
"url": "https://github.com/fullstackgtm/core/issues"
|
|
@@ -31,10 +31,13 @@
|
|
|
31
31
|
"INSTALL_FOR_AGENTS.md",
|
|
32
32
|
"llms.txt",
|
|
33
33
|
"skills",
|
|
34
|
-
"LICENSE"
|
|
34
|
+
"LICENSE",
|
|
35
|
+
"NOTICE",
|
|
36
|
+
"SECURITY.md",
|
|
37
|
+
"DATA-FLOWS.md"
|
|
35
38
|
],
|
|
36
39
|
"scripts": {
|
|
37
|
-
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
38
41
|
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
39
42
|
"prepublishOnly": "npm run build"
|
|
40
43
|
},
|
package/src/auditLog.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createHash, createHmac } from "node:crypto";
|
|
2
|
+
import { loadOrCreateSigningKey, loadSigningKey } from "./integrity.ts";
|
|
3
|
+
import type { PatchPlanRun } from "./types.ts";
|
|
4
|
+
import type { StoredPlan } from "./planStore.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Exportable, tamper-evident audit log.
|
|
8
|
+
*
|
|
9
|
+
* Every apply run is already recorded per-plan in the store, but a compliance /
|
|
10
|
+
* change-management process needs ONE portable artifact it can archive and
|
|
11
|
+
* later prove was not edited. `audit-log export` flattens every run across all
|
|
12
|
+
* plans into a hash-chained sequence: each entry carries the hash of the
|
|
13
|
+
* previous entry, so removing, reordering, or editing any entry breaks the
|
|
14
|
+
* chain at that point and `audit-log verify` reports exactly where. When a
|
|
15
|
+
* per-install signing key exists, the chain head is also HMAC-signed, so the
|
|
16
|
+
* export can be attributed to this installation, not just shown internally
|
|
17
|
+
* consistent.
|
|
18
|
+
*
|
|
19
|
+
* This is a point-in-time attestation of the stored run history; it is not a
|
|
20
|
+
* real-time append-only journal (that is future work). It answers "give me an
|
|
21
|
+
* auditable record of every change this tool applied, that my auditor can
|
|
22
|
+
* verify hasn't been doctored."
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type AuditLogEntry = {
|
|
26
|
+
seq: number;
|
|
27
|
+
planId: string;
|
|
28
|
+
planTitle: string;
|
|
29
|
+
provider: string;
|
|
30
|
+
startedAt: string;
|
|
31
|
+
finishedAt: string;
|
|
32
|
+
status: PatchPlanRun["status"];
|
|
33
|
+
trigger: string;
|
|
34
|
+
/** operationId → status, the per-operation outcome of this run */
|
|
35
|
+
operations: Array<{ operationId: string; status: string; detail?: string }>;
|
|
36
|
+
prevHash: string;
|
|
37
|
+
hash: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type AuditLogExport = {
|
|
41
|
+
version: 1;
|
|
42
|
+
generatedAt: string;
|
|
43
|
+
entryCount: number;
|
|
44
|
+
chainHead: string;
|
|
45
|
+
/** HMAC of chainHead with the per-install key, or null when no key exists. */
|
|
46
|
+
signature: string | null;
|
|
47
|
+
entries: AuditLogEntry[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const GENESIS = "0".repeat(64);
|
|
51
|
+
|
|
52
|
+
/** The content that the chain hash covers — everything but prevHash/hash. */
|
|
53
|
+
function entryContent(entry: Omit<AuditLogEntry, "prevHash" | "hash">): string {
|
|
54
|
+
return JSON.stringify([
|
|
55
|
+
entry.seq,
|
|
56
|
+
entry.planId,
|
|
57
|
+
entry.planTitle,
|
|
58
|
+
entry.provider,
|
|
59
|
+
entry.startedAt,
|
|
60
|
+
entry.finishedAt,
|
|
61
|
+
entry.status,
|
|
62
|
+
entry.trigger,
|
|
63
|
+
entry.operations,
|
|
64
|
+
]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function chainHash(prevHash: string, content: string): string {
|
|
68
|
+
return createHash("sha256").update(prevHash).update("\n").update(content).digest("hex");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Flatten all runs from the stored plans, oldest first, into chained entries. */
|
|
72
|
+
export function buildAuditLog(plans: StoredPlan[], generatedAt: string): AuditLogExport {
|
|
73
|
+
const runs: Array<{ stored: StoredPlan; run: PatchPlanRun }> = [];
|
|
74
|
+
for (const stored of plans) {
|
|
75
|
+
for (const run of stored.runs ?? []) runs.push({ stored, run });
|
|
76
|
+
}
|
|
77
|
+
runs.sort((a, b) => a.run.finishedAt.localeCompare(b.run.finishedAt));
|
|
78
|
+
|
|
79
|
+
const entries: AuditLogEntry[] = [];
|
|
80
|
+
let prevHash = GENESIS;
|
|
81
|
+
runs.forEach(({ stored, run }, index) => {
|
|
82
|
+
const base = {
|
|
83
|
+
seq: index,
|
|
84
|
+
planId: run.planId,
|
|
85
|
+
planTitle: stored.plan.title,
|
|
86
|
+
provider: run.provider,
|
|
87
|
+
startedAt: run.startedAt,
|
|
88
|
+
finishedAt: run.finishedAt,
|
|
89
|
+
status: run.status,
|
|
90
|
+
trigger: (run as { trigger?: string }).trigger ?? "manual",
|
|
91
|
+
operations: run.results.map((result) => ({
|
|
92
|
+
operationId: result.operationId,
|
|
93
|
+
status: result.status,
|
|
94
|
+
...(result.detail ? { detail: result.detail } : {}),
|
|
95
|
+
})),
|
|
96
|
+
};
|
|
97
|
+
const hash = chainHash(prevHash, entryContent(base));
|
|
98
|
+
entries.push({ ...base, prevHash, hash });
|
|
99
|
+
prevHash = hash;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Always sign — an unsigned export's keyless sha256 chain is self-recomputable
|
|
103
|
+
// (an attacker can edit entries and rebuild the chain from the public genesis),
|
|
104
|
+
// so the per-install HMAC is the only real tamper barrier. Bind the header
|
|
105
|
+
// fields into the signed material so metadata can't be altered either.
|
|
106
|
+
const key = loadOrCreateSigningKey();
|
|
107
|
+
const entryCount = entries.length;
|
|
108
|
+
return {
|
|
109
|
+
version: 1,
|
|
110
|
+
generatedAt,
|
|
111
|
+
entryCount,
|
|
112
|
+
chainHead: prevHash,
|
|
113
|
+
signature: signHead(key, 1, generatedAt, entryCount, prevHash),
|
|
114
|
+
entries,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function signHead(key: Buffer, version: number, generatedAt: string, entryCount: number, chainHead: string): string {
|
|
119
|
+
return createHmac("sha256", key).update(JSON.stringify([version, generatedAt, entryCount, chainHead])).digest("hex");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type AuditLogVerification = {
|
|
123
|
+
ok: boolean;
|
|
124
|
+
/** seq of the first entry whose hash does not verify, or null if the chain holds */
|
|
125
|
+
brokenAt: number | null;
|
|
126
|
+
signatureOk: boolean | null; // null = no signature present / no key to check
|
|
127
|
+
detail: string;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Recompute the chain (and the signature if a key is available). */
|
|
131
|
+
export function verifyAuditLog(log: AuditLogExport): AuditLogVerification {
|
|
132
|
+
let prevHash = GENESIS;
|
|
133
|
+
for (const entry of log.entries) {
|
|
134
|
+
if (entry.prevHash !== prevHash) {
|
|
135
|
+
return { ok: false, brokenAt: entry.seq, signatureOk: null, detail: `Chain breaks at entry ${entry.seq}: prevHash does not match the previous entry's hash (an entry was removed, reordered, or edited).` };
|
|
136
|
+
}
|
|
137
|
+
const expected = chainHash(prevHash, entryContent(entry));
|
|
138
|
+
if (expected !== entry.hash) {
|
|
139
|
+
return { ok: false, brokenAt: entry.seq, signatureOk: null, detail: `Chain breaks at entry ${entry.seq}: its content was edited after export (hash mismatch).` };
|
|
140
|
+
}
|
|
141
|
+
prevHash = entry.hash;
|
|
142
|
+
}
|
|
143
|
+
if (prevHash !== log.chainHead) {
|
|
144
|
+
return { ok: false, brokenAt: log.entries.length, signatureOk: null, detail: "The recorded chainHead does not match the recomputed chain." };
|
|
145
|
+
}
|
|
146
|
+
// The keyless chain alone is self-recomputable, so a missing/stripped signature
|
|
147
|
+
// means the export is forgeable — refuse it. (Current exports are always
|
|
148
|
+
// signed; a null signature is an old/unsigned or a downgraded export.)
|
|
149
|
+
if (!log.signature) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
brokenAt: null,
|
|
153
|
+
signatureOk: false,
|
|
154
|
+
detail: "Unsigned export: the hash chain alone is self-recomputable, so this log cannot be trusted (the signature is absent or was stripped). Re-export on the issuing install.",
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const key = loadSigningKey();
|
|
158
|
+
if (!key) {
|
|
159
|
+
// A third party without the issuing install's key cannot verify attribution.
|
|
160
|
+
// The chain is internally consistent, but that is not proof of authenticity.
|
|
161
|
+
return {
|
|
162
|
+
ok: false,
|
|
163
|
+
brokenAt: null,
|
|
164
|
+
signatureOk: null,
|
|
165
|
+
detail: "Chain is internally consistent, but this machine has no signing key to verify the signature — authenticity is unattributed. Verify on the issuing install.",
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
const signatureOk = signHead(key, log.version, log.generatedAt, log.entryCount, prevHash) === log.signature;
|
|
169
|
+
if (!signatureOk) {
|
|
170
|
+
return { ok: false, brokenAt: null, signatureOk: false, detail: "Signature does not match this installation's key — the log was exported elsewhere, or its entries/metadata were altered after signing." };
|
|
171
|
+
}
|
|
172
|
+
return { ok: true, brokenAt: null, signatureOk: true, detail: `Verified ${log.entries.length} entries; chain intact and signature valid.` };
|
|
173
|
+
}
|