@tekyzinc/gsd-t 4.19.13 → 4.20.10
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 +27 -0
- package/README.md +3 -1
- package/bin/gsd-t-audit-distill.cjs +171 -0
- package/bin/gsd-t-logging-envelope-check.cjs +566 -0
- package/bin/gsd-t-logging-scaffolder.cjs +281 -0
- package/bin/gsd-t-migrate-logging.cjs +203 -0
- package/bin/gsd-t-trace-distill.cjs +196 -0
- package/bin/gsd-t-verify-gate.cjs +2 -0
- package/bin/gsd-t.js +67 -7
- package/commands/gsd-t-help.md +8 -0
- package/commands/gsd-t-init.md +11 -0
- package/commands/gsd-t-migrate-logging.md +46 -0
- package/commands/gsd-t-stories.md +27 -12
- package/commands/gsd-t-verify.md +2 -1
- package/package.json +1 -1
- package/templates/CLAUDE-global.md +10 -0
- package/templates/logging/audit-module.template.ts +469 -0
- package/templates/logging/trace-module.template.ts +190 -0
- package/templates/playbooks/tekyz-user-stories-format.md +31 -12
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GSD-T Logging Envelope Check (M100 D3)
|
|
6
|
+
*
|
|
7
|
+
* Standalone STRUCTURAL predicate enforcing BOTH the trace envelope and the
|
|
8
|
+
* audit envelope over PER-PROJECT-VARYING schemas — never hardcoding a
|
|
9
|
+
* category/action value.
|
|
10
|
+
*
|
|
11
|
+
* Contract: .gsd-t/contracts/logging-verify-gate-contract.md v1.0.0 DRAFT.
|
|
12
|
+
* Consumed contracts: trace-logging-contract.md, audit-logging-contract.md.
|
|
13
|
+
*
|
|
14
|
+
* Exports (per contract §"The predicate: what d3 produces"):
|
|
15
|
+
* checkEnvelope(record, { stream: "trace" | "audit" }) -> { ok, failures }
|
|
16
|
+
* checkLoggingEnvelopes({ projectDir }) -> { ok, failures }
|
|
17
|
+
*
|
|
18
|
+
* Hard rules:
|
|
19
|
+
* - STRUCTURAL ONLY: parse shape as shape, never text.includes(category/action).
|
|
20
|
+
* A novel category/action value MUST PASS.
|
|
21
|
+
* - Presence vs. null are TWO SEPARATE checks — never truthiness. A required
|
|
22
|
+
* field may be PRESENT with value `null` (legal) vs. the key ABSENT (fail).
|
|
23
|
+
* - No-collapse keys on the TOP-LEVEL marker set of the record, NEVER a
|
|
24
|
+
* key-name scan of nested `context`/`data` payloads.
|
|
25
|
+
* - FAIL-CLOSED: any failure blocks. Never warn-and-proceed.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
// ── Field definitions (structural — no hardcoded values) ───────────────────
|
|
32
|
+
|
|
33
|
+
const TRACE_REQUIRED_FIELDS = ['ts', 'category', 'decision', 'detail'];
|
|
34
|
+
const AUDIT_REQUIRED_FIELDS = ['ts', 'actor', 'action', 'target', 'before', 'after', 'context'];
|
|
35
|
+
|
|
36
|
+
// Fields whose TYPE check accepts `null` as a legal value (presence still required).
|
|
37
|
+
const NULLABLE_FIELDS = new Set(['decision', 'before', 'after']);
|
|
38
|
+
|
|
39
|
+
// Top-level marker sets used by the no-collapse boundary (per contract §No-collapse detector).
|
|
40
|
+
const TRACE_MARKERS = ['category', 'decision', 'detail'];
|
|
41
|
+
const AUDIT_MARKERS = ['before', 'after', 'actor', 'action'];
|
|
42
|
+
|
|
43
|
+
// ── Type checking ────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
function _hasKey(record, key) {
|
|
46
|
+
return record != null && typeof record === 'object' && Object.prototype.hasOwnProperty.call(record, key);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function _typeOk(field, value) {
|
|
50
|
+
switch (field) {
|
|
51
|
+
case 'ts':
|
|
52
|
+
case 'category':
|
|
53
|
+
case 'detail':
|
|
54
|
+
case 'actor':
|
|
55
|
+
case 'action':
|
|
56
|
+
case 'target':
|
|
57
|
+
return typeof value === 'string';
|
|
58
|
+
case 'decision':
|
|
59
|
+
// boolean | null — presence checked separately.
|
|
60
|
+
return value === null || typeof value === 'boolean';
|
|
61
|
+
case 'before':
|
|
62
|
+
case 'after':
|
|
63
|
+
// Record<string, unknown> | null — presence checked separately.
|
|
64
|
+
return value === null || (typeof value === 'object' && !Array.isArray(value));
|
|
65
|
+
case 'context':
|
|
66
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
67
|
+
// Optional trace fields:
|
|
68
|
+
case 'key':
|
|
69
|
+
return typeof value === 'string';
|
|
70
|
+
case 'status':
|
|
71
|
+
return typeof value === 'number';
|
|
72
|
+
case 'data':
|
|
73
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
74
|
+
default:
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── PII matcher — recurses into nested structures, no false positives ──────
|
|
80
|
+
|
|
81
|
+
// Real-TLD-shaped email, matched as a whole-token substring (word-boundary-safe):
|
|
82
|
+
// local@domain.tld (tld letters only, 2+ chars). Uses lookaround so it also matches
|
|
83
|
+
// a bare full-string value (whole-string IS a substring of itself). The trailing
|
|
84
|
+
// lookahead allows a closing quote/bracket/angle-bracket/brace immediately after
|
|
85
|
+
// the TLD (JSON string bodies, `Name <email>` headers, array literals) — without
|
|
86
|
+
// these, an email immediately followed by `"`, `>`, `]`, or `}` was NOT matched.
|
|
87
|
+
const EMAIL_RE = /(?<![^\s(])[^\s@()]+@[^\s@()]+\.[a-zA-Z]{2,}(?![^\s).,;:!?"'>\]}])/;
|
|
88
|
+
// Phone: grouped digit run with separators/parens — NOT a bare long id, and NOT
|
|
89
|
+
// an ISO-8601 date/timestamp shape (YYYY-MM-DD[THH:MM:SS...]) which otherwise
|
|
90
|
+
// false-positives against generic digit-dash grouping.
|
|
91
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T[\d:.Z+-]*)?$/;
|
|
92
|
+
// CONSERVATIVE phone matcher (fail-closed gate — a false positive BLOCKS a legit build,
|
|
93
|
+
// so we only flag CLEAR phone signals, accepting we miss exotic formats):
|
|
94
|
+
// +CC prefix, OR a parenthesized area code, OR the classic NNN-NNN-NNNN US shape.
|
|
95
|
+
// A bare undelimited digit run is NOT a phone (contract: a bare long id isn't a phone).
|
|
96
|
+
// Grouped internal ids / ranges (12-34-56, 100-200-300, 1234-5678-90) are NOT matched.
|
|
97
|
+
const PHONE_RE = /(?<!\d)(?:\+\d{1,3}[\s.-]?)?(?:\(\d{3}\)[\s.-]?\d{3}[\s.-]?\d{4}|\d{3}[.-]\d{3}[.-]\d{4})(?!\d)/;
|
|
98
|
+
|
|
99
|
+
function _isPhoneShaped(value) {
|
|
100
|
+
const matches = value.match(new RegExp(PHONE_RE.source, 'g')) || [];
|
|
101
|
+
return matches.some((m) => !ISO_DATE_RE.test(m) && !ISO_DATE_RE.test(value.trim()));
|
|
102
|
+
}
|
|
103
|
+
// Postal address: leading street number + street-name + common suffix, or a ZIP-shaped tail.
|
|
104
|
+
const ADDRESS_RE = /\b\d{1,6}\s+[A-Za-z0-9.'-]+(?:\s+[A-Za-z0-9.'-]+){0,4}\s+(Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd|Lane|Ln|Drive|Dr|Court|Ct|Way|Place|Pl)\b/i;
|
|
105
|
+
const ZIP_RE = /\b\d{5}(-\d{4})?\b/;
|
|
106
|
+
|
|
107
|
+
function _isPiiString(value) {
|
|
108
|
+
if (typeof value !== 'string') return false;
|
|
109
|
+
if (ISO_DATE_RE.test(value.trim())) return false; // structural timestamp, not PII
|
|
110
|
+
if (EMAIL_RE.test(value)) return true;
|
|
111
|
+
if (_isPhoneShaped(value)) return true;
|
|
112
|
+
if (ADDRESS_RE.test(value)) return true;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function _scanForPii(value, pathParts, hits, depth) {
|
|
117
|
+
if (depth > 12) return; // defensive recursion cap, not a real-world limit
|
|
118
|
+
if (value == null) return;
|
|
119
|
+
if (typeof value === 'string') {
|
|
120
|
+
if (_isPiiString(value)) hits.push(pathParts.join('.'));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
value.forEach((v, i) => _scanForPii(v, pathParts.concat(String(i)), hits, depth + 1));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (typeof value === 'object') {
|
|
128
|
+
for (const k of Object.keys(value)) {
|
|
129
|
+
_scanForPii(value[k], pathParts.concat(k), hits, depth + 1);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── checkEnvelope(record, { stream }) ───────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
function checkEnvelope(record, opts) {
|
|
137
|
+
opts = opts || {};
|
|
138
|
+
const stream = opts.stream;
|
|
139
|
+
const failures = [];
|
|
140
|
+
|
|
141
|
+
if (record == null || typeof record !== 'object' || Array.isArray(record)) {
|
|
142
|
+
return { ok: false, failures: [{ rule: (stream === 'audit' ? 'audit-envelope-structural' : 'trace-envelope-structural'), stream: stream || null, detail: 'record is not an object' }] };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (stream !== 'trace' && stream !== 'audit') {
|
|
146
|
+
return { ok: false, failures: [{ rule: 'envelope-structural', stream: stream || null, detail: 'unknown stream: ' + String(stream) }] };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const requiredFields = stream === 'trace' ? TRACE_REQUIRED_FIELDS : AUDIT_REQUIRED_FIELDS;
|
|
150
|
+
const structuralRule = stream === 'trace' ? 'trace-envelope-structural' : 'audit-envelope-structural';
|
|
151
|
+
|
|
152
|
+
for (const field of requiredFields) {
|
|
153
|
+
if (!_hasKey(record, field)) {
|
|
154
|
+
failures.push({ rule: structuralRule, stream, detail: 'missing required field: ' + field });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const value = record[field];
|
|
158
|
+
if (!_typeOk(field, value)) {
|
|
159
|
+
failures.push({ rule: structuralRule, stream, detail: 'wrong type for field: ' + field });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Optional-field type checks (only when present).
|
|
164
|
+
if (stream === 'trace') {
|
|
165
|
+
for (const optField of ['key', 'status', 'data']) {
|
|
166
|
+
if (_hasKey(record, optField) && record[optField] != null && !_typeOk(optField, record[optField])) {
|
|
167
|
+
failures.push({ rule: structuralRule, stream, detail: 'wrong type for optional field: ' + optField });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// PII bar — trace only, recurse into every field including `data`.
|
|
173
|
+
// `ts` is excluded: it is a structural ISO-8601 timestamp (type-checked above),
|
|
174
|
+
// not free-form user content, and its digit-dash grouping otherwise false-positives
|
|
175
|
+
// against the phone matcher.
|
|
176
|
+
if (stream === 'trace') {
|
|
177
|
+
const hits = [];
|
|
178
|
+
for (const field of Object.keys(record)) {
|
|
179
|
+
if (field === 'ts') continue;
|
|
180
|
+
_scanForPii(record[field], [field], hits, 0);
|
|
181
|
+
}
|
|
182
|
+
if (hits.length > 0) {
|
|
183
|
+
failures.push({ rule: 'trace-pii-barred', stream, detail: 'PII-shaped value at: ' + hits.join(', ') });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// No-collapse — TOP-LEVEL marker set only, never a nested key-name scan.
|
|
188
|
+
const hasTraceMarker = TRACE_MARKERS.some((k) => _hasKey(record, k));
|
|
189
|
+
const hasAuditMarker = AUDIT_MARKERS.some((k) => _hasKey(record, k));
|
|
190
|
+
if (hasTraceMarker && hasAuditMarker) {
|
|
191
|
+
failures.push({ rule: 'no-collapse', stream, detail: 'record carries top-level markers from BOTH trace and audit streams' });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { ok: failures.length === 0, failures };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Durability + default-rule checks (M100-D3-T2) ───────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Checks an audit module's declared surface for append-only/immutability.
|
|
201
|
+
* moduleSurface: { exportsUpdate?: boolean, exportsDelete?: boolean, declaresAppendOnly?: boolean }
|
|
202
|
+
*/
|
|
203
|
+
function _checkAppendOnlyImmutable(moduleSurface) {
|
|
204
|
+
const failures = [];
|
|
205
|
+
if (!moduleSurface || typeof moduleSurface !== 'object') {
|
|
206
|
+
failures.push({ rule: 'audit-append-only-immutable', stream: 'audit', detail: 'no audit module surface discovered' });
|
|
207
|
+
return failures;
|
|
208
|
+
}
|
|
209
|
+
if (moduleSurface.exportsUpdate === true || moduleSurface.exportsDelete === true) {
|
|
210
|
+
failures.push({ rule: 'audit-append-only-immutable', stream: 'audit', detail: 'audit module exposes an update/delete path — not append-only' });
|
|
211
|
+
}
|
|
212
|
+
if (moduleSurface.declaresAppendOnly !== true) {
|
|
213
|
+
failures.push({ rule: 'audit-append-only-immutable', stream: 'audit', detail: 'audit module does not declare append-only/immutability' });
|
|
214
|
+
}
|
|
215
|
+
return failures;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Checks retention configurability.
|
|
220
|
+
* retentionConfig: { hardcoded?: boolean, configurable?: boolean }
|
|
221
|
+
*/
|
|
222
|
+
function _checkRetentionConfigurable(retentionConfig) {
|
|
223
|
+
const failures = [];
|
|
224
|
+
if (!retentionConfig || typeof retentionConfig !== 'object' || retentionConfig.configurable !== true || retentionConfig.hardcoded === true) {
|
|
225
|
+
failures.push({ rule: 'audit-retention-configurable', stream: 'audit', detail: 'retention window is hardcoded or not configurable' });
|
|
226
|
+
}
|
|
227
|
+
return failures;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Validates the opt-out record shape per audit-logging-contract.md §opt-out-record.
|
|
232
|
+
*/
|
|
233
|
+
function _isValidOptOut(optOutRecord) {
|
|
234
|
+
if (!optOutRecord || typeof optOutRecord !== 'object') return false;
|
|
235
|
+
if (optOutRecord.auditOptOut !== true) return false;
|
|
236
|
+
if (typeof optOutRecord.reason !== 'string' || optOutRecord.reason.trim().length === 0) return false;
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function _checkDefaultExceptOptOut({ hasAuditStore, optOutRecord }) {
|
|
241
|
+
const failures = [];
|
|
242
|
+
if (hasAuditStore) return failures;
|
|
243
|
+
if (_isValidOptOut(optOutRecord)) return failures;
|
|
244
|
+
failures.push({ rule: 'audit-default-except-optout', stream: 'audit', detail: 'no audit store and no valid opt-out record' });
|
|
245
|
+
return failures;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Validates the TRACE opt-out record shape per trace-logging-contract.md §opt-out-record
|
|
250
|
+
* (M100 correction: the "trace has NO opt-out" rule was too absolute — a stateless CLI /
|
|
251
|
+
* library has no runtime data-flow to trace, so a symmetric opt-out exists for that class).
|
|
252
|
+
*/
|
|
253
|
+
function _isValidTraceOptOut(rec) {
|
|
254
|
+
if (!rec || typeof rec !== 'object') return false;
|
|
255
|
+
if (rec.traceOptOut !== true) return false;
|
|
256
|
+
if (typeof rec.reason !== 'string' || rec.reason.trim().length === 0) return false;
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── §discovery — checkLoggingEnvelopes({ projectDir }) ──────────────────────
|
|
261
|
+
//
|
|
262
|
+
// Real enumeration per logging-verify-gate-contract.md §discovery. Walks the
|
|
263
|
+
// project to locate (i) the trace module/store + records, (ii) the audit
|
|
264
|
+
// module/store + records, (iii) .gsd-t/audit-optout.json — then runs every
|
|
265
|
+
// discovered record through checkEnvelope. Never returns ok:true vacuously.
|
|
266
|
+
|
|
267
|
+
const TRACE_MODULE_CANDIDATES = [
|
|
268
|
+
'src/logging/trace.ts',
|
|
269
|
+
'src/logging/trace.js',
|
|
270
|
+
'src/trace/index.ts',
|
|
271
|
+
'src/trace/index.js',
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
const TRACE_STORE_CANDIDATES = [
|
|
275
|
+
'.gsd-t/trace-records.json',
|
|
276
|
+
'.gsd-t/logging/trace-records.json',
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
// SQLite store candidates — the contracts EXPLICITLY sanction "storage is
|
|
280
|
+
// whatever fits the stack", so a DB-backed store is first-class, not an
|
|
281
|
+
// unlisted edge case. When one is discovered we open it read-only and run
|
|
282
|
+
// checkEnvelope over its rows (better-sqlite3 is already a dependency). A
|
|
283
|
+
// discovered-but-UNINSPECTABLE store (unknown shape, unopenable, no known
|
|
284
|
+
// table) FAILS-CLOSED rather than silently passing with zero records inspected.
|
|
285
|
+
const TRACE_DB_CANDIDATES = [
|
|
286
|
+
'.gsd-t/trace.db',
|
|
287
|
+
'.gsd-t/trace.sqlite',
|
|
288
|
+
'.gsd-t/logging/trace.db',
|
|
289
|
+
'.gsd-t/logging/trace.sqlite',
|
|
290
|
+
];
|
|
291
|
+
const AUDIT_DB_CANDIDATES = [
|
|
292
|
+
'.gsd-t/audit.db',
|
|
293
|
+
'.gsd-t/audit.sqlite',
|
|
294
|
+
'.gsd-t/logging/audit.db',
|
|
295
|
+
'.gsd-t/logging/audit.sqlite',
|
|
296
|
+
];
|
|
297
|
+
// Known table names per stream (structural — the audit module template ships
|
|
298
|
+
// `audit_log`; trace's DB-backed convention is `trace_records`/`trace_log`).
|
|
299
|
+
const TRACE_DB_TABLES = ['trace_records', 'trace_log', 'trace'];
|
|
300
|
+
const AUDIT_DB_TABLES = ['audit_log', 'audit_records', 'audit'];
|
|
301
|
+
|
|
302
|
+
const AUDIT_MODULE_CANDIDATES = [
|
|
303
|
+
'src/logging/audit-module.ts',
|
|
304
|
+
'src/logging/audit.ts',
|
|
305
|
+
'src/logging/audit.js',
|
|
306
|
+
'src/audit/index.ts',
|
|
307
|
+
'src/audit/index.js',
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
const AUDIT_STORE_CANDIDATES = [
|
|
311
|
+
'.gsd-t/audit-records.json',
|
|
312
|
+
'.gsd-t/logging/audit-records.json',
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
function _readJsonArrayIfExists(absPath) {
|
|
316
|
+
try {
|
|
317
|
+
if (!fs.existsSync(absPath)) return null;
|
|
318
|
+
const raw = fs.readFileSync(absPath, 'utf8');
|
|
319
|
+
const parsed = JSON.parse(raw);
|
|
320
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
321
|
+
} catch (_err) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function _firstExisting(projectDir, candidates) {
|
|
327
|
+
for (const rel of candidates) {
|
|
328
|
+
const abs = path.join(projectDir, rel);
|
|
329
|
+
try {
|
|
330
|
+
if (fs.existsSync(abs)) return abs;
|
|
331
|
+
} catch (_err) { /* ignore */ }
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Stack-adaptive SQLite store inspection ──────────────────────────────────
|
|
337
|
+
//
|
|
338
|
+
// Opens a discovered .db/.sqlite store read-only and rehydrates rows into the
|
|
339
|
+
// canonical envelope shape so checkEnvelope can validate them exactly like JSON
|
|
340
|
+
// records. Return shapes are DISTINCT so the caller can fail-closed:
|
|
341
|
+
// { records: [...] } → inspectable, here are the records (maybe [])
|
|
342
|
+
// { uninspectable: 'why' } → discovered but CANNOT inspect → caller FAILS.
|
|
343
|
+
|
|
344
|
+
function _loadBetterSqlite() {
|
|
345
|
+
try {
|
|
346
|
+
return require('better-sqlite3');
|
|
347
|
+
} catch (_err) {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Column set → canonical trace/audit record. Reads JSON-text columns back into objects. */
|
|
353
|
+
function _rowToRecord(row) {
|
|
354
|
+
const rec = {};
|
|
355
|
+
for (const k of Object.keys(row)) {
|
|
356
|
+
if (k === 'id') continue; // store-assigned key, not part of the envelope
|
|
357
|
+
let v = row[k];
|
|
358
|
+
// JSON-serialized object/array columns (before/after/context/data) come
|
|
359
|
+
// back as TEXT — rehydrate so checkEnvelope sees the real shape/type.
|
|
360
|
+
if (typeof v === 'string' && v.length > 0 && (v[0] === '{' || v[0] === '[')) {
|
|
361
|
+
try { v = JSON.parse(v); } catch (_e) { /* leave as string */ }
|
|
362
|
+
}
|
|
363
|
+
rec[k] = v;
|
|
364
|
+
}
|
|
365
|
+
return rec;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Inspect a SQLite store for a given stream. Returns { records } when a known
|
|
370
|
+
* table is found and read, else { uninspectable: reason } — NEVER a silent
|
|
371
|
+
* empty pass.
|
|
372
|
+
*/
|
|
373
|
+
function _inspectSqliteStore(absDbPath, knownTables) {
|
|
374
|
+
const Database = _loadBetterSqlite();
|
|
375
|
+
if (!Database) {
|
|
376
|
+
return { uninspectable: 'a SQLite store was discovered but better-sqlite3 is unavailable to inspect it' };
|
|
377
|
+
}
|
|
378
|
+
let db = null;
|
|
379
|
+
try {
|
|
380
|
+
db = new Database(absDbPath, { readonly: true, fileMustExist: true });
|
|
381
|
+
const present = db
|
|
382
|
+
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
|
|
383
|
+
.all()
|
|
384
|
+
.map((r) => r.name);
|
|
385
|
+
const table = knownTables.find((t) => present.includes(t));
|
|
386
|
+
if (!table) {
|
|
387
|
+
return { uninspectable: 'a SQLite store was discovered but no recognized records table was found (saw: ' + present.join(', ') + ')' };
|
|
388
|
+
}
|
|
389
|
+
const rows = db.prepare('SELECT * FROM "' + table + '"').all();
|
|
390
|
+
return { records: rows.map(_rowToRecord) };
|
|
391
|
+
} catch (err) {
|
|
392
|
+
return { uninspectable: 'a SQLite store was discovered but could not be opened/read: ' + (err && err.message ? err.message : String(err)) };
|
|
393
|
+
} finally {
|
|
394
|
+
if (db) { try { db.close(); } catch (_e) { /* ignore */ } }
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Reads a module's declared surface out of its source text — best-effort,
|
|
400
|
+
* structural signal only (looks for documented export/declaration markers),
|
|
401
|
+
* never a substring scan of business content.
|
|
402
|
+
*/
|
|
403
|
+
function _readModuleSurface(absModulePath) {
|
|
404
|
+
let text = '';
|
|
405
|
+
try {
|
|
406
|
+
text = fs.readFileSync(absModulePath, 'utf8');
|
|
407
|
+
} catch (_err) {
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
return {
|
|
411
|
+
declaresAppendOnly: /append-?only/i.test(text) || /immutable/i.test(text),
|
|
412
|
+
exportsUpdate: /export\s+(async\s+)?function\s+update\w*Entry|export\s+const\s+update\w*Entry/i.test(text),
|
|
413
|
+
exportsDelete: /export\s+(async\s+)?function\s+delete\w*Entry|export\s+const\s+delete\w*Entry/i.test(text),
|
|
414
|
+
retentionConfigurable: /retention/i.test(text) && (/process\.env/.test(text) || /config/i.test(text)),
|
|
415
|
+
retentionHardcoded: /retention/i.test(text) && !(/process\.env/.test(text) || /config/i.test(text)),
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function checkLoggingEnvelopes(opts) {
|
|
420
|
+
opts = opts || {};
|
|
421
|
+
const projectDir = opts.projectDir || '.';
|
|
422
|
+
const failures = [];
|
|
423
|
+
|
|
424
|
+
// (i) Trace discovery — default for every project EXCEPT explicit opt-out
|
|
425
|
+
// (M100 correction: stateless CLI/library class has no runtime data-flow to trace,
|
|
426
|
+
// so a symmetric .gsd-t/trace-optout.json opt-out exists, mirroring audit's).
|
|
427
|
+
const traceModulePath = _firstExisting(projectDir, TRACE_MODULE_CANDIDATES);
|
|
428
|
+
const traceStorePath = _firstExisting(projectDir, TRACE_STORE_CANDIDATES);
|
|
429
|
+
const traceDbPath = _firstExisting(projectDir, TRACE_DB_CANDIDATES);
|
|
430
|
+
const traceRecords = traceStorePath ? _readJsonArrayIfExists(traceStorePath) : null;
|
|
431
|
+
let traceOptOutRecord = null;
|
|
432
|
+
const traceOptOutPath = path.join(projectDir, '.gsd-t', 'trace-optout.json');
|
|
433
|
+
try {
|
|
434
|
+
if (fs.existsSync(traceOptOutPath)) traceOptOutRecord = JSON.parse(fs.readFileSync(traceOptOutPath, 'utf8'));
|
|
435
|
+
} catch (_e) { traceOptOutRecord = null; }
|
|
436
|
+
|
|
437
|
+
if (!traceModulePath && !traceStorePath && !traceDbPath) {
|
|
438
|
+
if (!_isValidTraceOptOut(traceOptOutRecord)) {
|
|
439
|
+
failures.push({ rule: 'trace-default-except-optout', stream: 'trace', detail: 'no trace module or store discoverable and no valid trace opt-out record' });
|
|
440
|
+
}
|
|
441
|
+
} else if (Array.isArray(traceRecords)) {
|
|
442
|
+
for (const rec of traceRecords) {
|
|
443
|
+
const result = checkEnvelope(rec, { stream: 'trace' });
|
|
444
|
+
if (!result.ok) failures.push(...result.failures);
|
|
445
|
+
}
|
|
446
|
+
} else if (traceDbPath) {
|
|
447
|
+
// Stack-adaptive: a DB-backed trace store — inspect it, don't wave it through.
|
|
448
|
+
const inspected = _inspectSqliteStore(traceDbPath, TRACE_DB_TABLES);
|
|
449
|
+
if (inspected.uninspectable) {
|
|
450
|
+
failures.push({ rule: 'trace-store-uninspectable', stream: 'trace', detail: inspected.uninspectable + ' — the PII bar + envelope check cannot be enforced, failing closed' });
|
|
451
|
+
} else {
|
|
452
|
+
for (const rec of inspected.records) {
|
|
453
|
+
const result = checkEnvelope(rec, { stream: 'trace' });
|
|
454
|
+
if (!result.ok) failures.push(...result.failures);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
} else if (traceStorePath && traceRecords === null) {
|
|
458
|
+
// A recognized JSON store path EXISTS but did NOT parse as a JSON array —
|
|
459
|
+
// it is an uninspectable shape (malformed JSON, or a non-array). The PII
|
|
460
|
+
// bar + envelope check cannot run over it, so FAIL-CLOSED rather than pass.
|
|
461
|
+
failures.push({ rule: 'trace-store-uninspectable', stream: 'trace', detail: 'trace store at ' + traceStorePath + ' is present but is not a parseable JSON array — cannot enforce the envelope/PII checks, failing closed' });
|
|
462
|
+
}
|
|
463
|
+
// else: trace MODULE present but NO store discovered at all (traceStorePath &&
|
|
464
|
+
// traceDbPath both absent) — a fresh project that scaffolded trace but has not
|
|
465
|
+
// yet emitted a record. This is the ONLY genuinely-legal no-records case: the
|
|
466
|
+
// module's presence satisfies trace-default-except-optout above, and there is
|
|
467
|
+
// legitimately nothing to validate yet.
|
|
468
|
+
|
|
469
|
+
// (ii) Audit discovery.
|
|
470
|
+
const auditModulePath = _firstExisting(projectDir, AUDIT_MODULE_CANDIDATES);
|
|
471
|
+
const auditStorePath = _firstExisting(projectDir, AUDIT_STORE_CANDIDATES);
|
|
472
|
+
const auditDbPath = _firstExisting(projectDir, AUDIT_DB_CANDIDATES);
|
|
473
|
+
const auditRecords = auditStorePath ? _readJsonArrayIfExists(auditStorePath) : null;
|
|
474
|
+
const hasAuditStore = !!(auditModulePath || auditStorePath || auditDbPath);
|
|
475
|
+
|
|
476
|
+
// (iii) Opt-out file.
|
|
477
|
+
let optOutRecord = null;
|
|
478
|
+
const optOutPath = path.join(projectDir, '.gsd-t', 'audit-optout.json');
|
|
479
|
+
try {
|
|
480
|
+
if (fs.existsSync(optOutPath)) {
|
|
481
|
+
optOutRecord = JSON.parse(fs.readFileSync(optOutPath, 'utf8'));
|
|
482
|
+
}
|
|
483
|
+
} catch (_err) {
|
|
484
|
+
optOutRecord = null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const defaultOptOutFailures = _checkDefaultExceptOptOut({ hasAuditStore, optOutRecord });
|
|
488
|
+
failures.push(...defaultOptOutFailures);
|
|
489
|
+
|
|
490
|
+
if (hasAuditStore) {
|
|
491
|
+
if (Array.isArray(auditRecords)) {
|
|
492
|
+
for (const rec of auditRecords) {
|
|
493
|
+
const result = checkEnvelope(rec, { stream: 'audit' });
|
|
494
|
+
if (!result.ok) failures.push(...result.failures);
|
|
495
|
+
}
|
|
496
|
+
} else if (auditDbPath) {
|
|
497
|
+
// Stack-adaptive: a DB-backed audit store — inspect it, don't wave it through.
|
|
498
|
+
const inspected = _inspectSqliteStore(auditDbPath, AUDIT_DB_TABLES);
|
|
499
|
+
if (inspected.uninspectable) {
|
|
500
|
+
failures.push({ rule: 'audit-store-uninspectable', stream: 'audit', detail: inspected.uninspectable + ' — the envelope check cannot be enforced, failing closed' });
|
|
501
|
+
} else {
|
|
502
|
+
for (const rec of inspected.records) {
|
|
503
|
+
const result = checkEnvelope(rec, { stream: 'audit' });
|
|
504
|
+
if (!result.ok) failures.push(...result.failures);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
} else if (auditStorePath && auditRecords === null) {
|
|
508
|
+
// A recognized JSON audit store exists but is not a parseable JSON array —
|
|
509
|
+
// an uninspectable shape. FAIL-CLOSED rather than silently pass.
|
|
510
|
+
failures.push({ rule: 'audit-store-uninspectable', stream: 'audit', detail: 'audit store at ' + auditStorePath + ' is present but is not a parseable JSON array — cannot enforce the envelope check, failing closed' });
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (auditModulePath) {
|
|
514
|
+
const surface = _readModuleSurface(auditModulePath);
|
|
515
|
+
failures.push(..._checkAppendOnlyImmutable({
|
|
516
|
+
exportsUpdate: surface ? surface.exportsUpdate : false,
|
|
517
|
+
exportsDelete: surface ? surface.exportsDelete : false,
|
|
518
|
+
declaresAppendOnly: surface ? surface.declaresAppendOnly : false,
|
|
519
|
+
}));
|
|
520
|
+
failures.push(..._checkRetentionConfigurable({
|
|
521
|
+
hardcoded: surface ? surface.retentionHardcoded : true,
|
|
522
|
+
configurable: surface ? surface.retentionConfigurable : false,
|
|
523
|
+
}));
|
|
524
|
+
} else {
|
|
525
|
+
// Audit store present but no module surface to inspect declared durability rules.
|
|
526
|
+
failures.push({ rule: 'audit-append-only-immutable', stream: 'audit', detail: 'no audit module surface discoverable to verify append-only declaration' });
|
|
527
|
+
failures.push({ rule: 'audit-retention-configurable', stream: 'audit', detail: 'no audit module surface discoverable to verify retention configurability' });
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return { ok: failures.length === 0, failures };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
module.exports = {
|
|
535
|
+
checkEnvelope,
|
|
536
|
+
checkLoggingEnvelopes,
|
|
537
|
+
// Test surface (not part of the public contract):
|
|
538
|
+
_hasKey,
|
|
539
|
+
_typeOk,
|
|
540
|
+
_isPiiString,
|
|
541
|
+
_scanForPii,
|
|
542
|
+
_checkAppendOnlyImmutable,
|
|
543
|
+
_checkRetentionConfigurable,
|
|
544
|
+
_isValidOptOut,
|
|
545
|
+
_checkDefaultExceptOptOut,
|
|
546
|
+
_readModuleSurface,
|
|
547
|
+
_inspectSqliteStore,
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
// ── CLI (invoked by the verify gate as a Track 2 worker) ────────────────────
|
|
551
|
+
|
|
552
|
+
function _parseArgv(argv) {
|
|
553
|
+
const out = { projectDir: '.' };
|
|
554
|
+
for (let i = 0; i < argv.length; i++) {
|
|
555
|
+
const a = argv[i];
|
|
556
|
+
if (a === '--project') out.projectDir = argv[++i] || '.';
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
if (require.main === module) {
|
|
562
|
+
const args = _parseArgv(process.argv.slice(2));
|
|
563
|
+
const result = checkLoggingEnvelopes({ projectDir: args.projectDir });
|
|
564
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
565
|
+
process.exit(result.ok ? 0 : 1);
|
|
566
|
+
}
|