opstruth 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -2
- package/examples/supabase-live-redacted-evidence.json +78 -0
- package/package.json +1 -1
- package/src/cli.js +77 -40
- package/src/commands/github-ci.js +338 -0
- package/src/commands/local.js +7 -1
- package/src/commands/probes.js +33 -15
- package/src/commands/quality.js +212 -20
- package/src/commands/routes.js +60 -9
- package/src/commands/secrets.js +37 -12
- package/src/commands/supabase-live.js +564 -0
- package/src/lib/config.js +41 -3
- package/src/lib/exec.js +21 -3
- package/src/lib/git.js +5 -3
- package/src/lib/markdown.js +21 -0
- package/src/lib/probes.js +44 -2
- package/src/lib/redact.js +1 -0
- package/src/lib/scan.js +241 -14
- package/src/orchestrator.js +39 -17
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
|
|
4
|
+
|
|
5
|
+
export const SUPABASE_LIVE_SCHEMA_VERSION = 'opstruth.supabase-live.v1';
|
|
6
|
+
|
|
7
|
+
export const SUPABASE_LIVE_SIGNALS = [
|
|
8
|
+
'function_deployed',
|
|
9
|
+
'secret_name_configured',
|
|
10
|
+
'missing_credential_denial',
|
|
11
|
+
'incorrect_credential_denial',
|
|
12
|
+
'authorised_noop',
|
|
13
|
+
'scheduler_configured',
|
|
14
|
+
'scheduler_autonomous_execution',
|
|
15
|
+
'telemetry_count_only',
|
|
16
|
+
'non_admin_authorization',
|
|
17
|
+
'admin_authorization',
|
|
18
|
+
'rate_limit',
|
|
19
|
+
'database_effects'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const SUPABASE_LIVE_STATES = [
|
|
23
|
+
'verified',
|
|
24
|
+
'failed',
|
|
25
|
+
'not_verified',
|
|
26
|
+
'not_configured',
|
|
27
|
+
'not_observed',
|
|
28
|
+
'unsafe_to_test',
|
|
29
|
+
'authentication_unavailable',
|
|
30
|
+
'metadata_unavailable',
|
|
31
|
+
'external_evidence'
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const RISKY_KEY_NAMES = new Set([
|
|
35
|
+
'authorization',
|
|
36
|
+
'authorizationheader',
|
|
37
|
+
'authorizationheaders',
|
|
38
|
+
'headers',
|
|
39
|
+
'requestheaders',
|
|
40
|
+
'schedulerpayload',
|
|
41
|
+
'requestpayload',
|
|
42
|
+
'rawpayload',
|
|
43
|
+
'rawrequest',
|
|
44
|
+
'rawresponse',
|
|
45
|
+
'rawlogs',
|
|
46
|
+
'rawlog',
|
|
47
|
+
'rawcommand',
|
|
48
|
+
'commandtext',
|
|
49
|
+
'projectref',
|
|
50
|
+
'projectreference',
|
|
51
|
+
'supabaseprojectref',
|
|
52
|
+
'supabaseprojectreference',
|
|
53
|
+
'supabaseurl',
|
|
54
|
+
'databaseurl',
|
|
55
|
+
'dburl',
|
|
56
|
+
'service_role',
|
|
57
|
+
'servicerole'
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const RISKY_VALUE_PATTERNS = [
|
|
61
|
+
{ label: 'JWT-like value', pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/ },
|
|
62
|
+
{ label: 'bearer token', pattern: /\bBearer\s+[A-Za-z0-9._~+/-]+=*/i },
|
|
63
|
+
{ label: 'authorization header', pattern: /\bauthorization\s*[:=]/i },
|
|
64
|
+
{ label: 'secret assignment', pattern: /\b(?:SUPABASE_ACCESS_TOKEN|SUPABASE_PROJECT_REF|IMPORT_REDDIT_TIPS_SECRET|SUPABASE_SERVICE_ROLE_KEY|GH_TOKEN|GITHUB_TOKEN|NPM_TOKEN)\s*=/i },
|
|
65
|
+
{ label: 'service-role assignment', pattern: /\bservice[_-]?role\s*=/i },
|
|
66
|
+
{ label: 'OpenAI-style secret', pattern: /\bsk-[A-Za-z0-9]/ },
|
|
67
|
+
{ label: 'Supabase project URL', pattern: /https:\/\/[a-z0-9]{15,}\.supabase\.co/i },
|
|
68
|
+
{ label: 'long token-like value', pattern: /\b[A-Za-z0-9_-]{40,}\b/ }
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
const TELEMETRY_COUNT_FIELDS = [
|
|
72
|
+
'candidates',
|
|
73
|
+
'candidateCount',
|
|
74
|
+
'candidate_count',
|
|
75
|
+
'fresh',
|
|
76
|
+
'freshCount',
|
|
77
|
+
'fresh_count',
|
|
78
|
+
'inserted',
|
|
79
|
+
'insertedCount',
|
|
80
|
+
'inserted_count',
|
|
81
|
+
'skipped',
|
|
82
|
+
'skippedCount',
|
|
83
|
+
'skipped_count',
|
|
84
|
+
'accepted',
|
|
85
|
+
'acceptedCount',
|
|
86
|
+
'accepted_count',
|
|
87
|
+
'rejected',
|
|
88
|
+
'rejectedCount',
|
|
89
|
+
'rejected_count'
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const TELEMETRY_ALLOWED_STATUSES = new Set([
|
|
93
|
+
'ok',
|
|
94
|
+
'success',
|
|
95
|
+
'failed',
|
|
96
|
+
'error',
|
|
97
|
+
'denied',
|
|
98
|
+
'unauthorized',
|
|
99
|
+
'forbidden',
|
|
100
|
+
'rate_limited',
|
|
101
|
+
'noop',
|
|
102
|
+
'scheduled_success',
|
|
103
|
+
'scheduled_failure'
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const TELEMETRY_ALLOWED_TRIGGERS = new Set([
|
|
107
|
+
'scheduled',
|
|
108
|
+
'manual',
|
|
109
|
+
'missing_credential',
|
|
110
|
+
'incorrect_secret',
|
|
111
|
+
'admin',
|
|
112
|
+
'non_admin',
|
|
113
|
+
'rate_limit',
|
|
114
|
+
'unknown'
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
function normalizeKey(key = '') {
|
|
118
|
+
return String(key).replace(/[^a-z0-9_]/gi, '').toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function scanForSensitiveMaterial(value, pathParts = []) {
|
|
122
|
+
const findings = [];
|
|
123
|
+
if (Array.isArray(value)) {
|
|
124
|
+
value.forEach((item, index) => findings.push(...scanForSensitiveMaterial(item, pathParts.concat(String(index)))));
|
|
125
|
+
return findings;
|
|
126
|
+
}
|
|
127
|
+
if (value && typeof value === 'object') {
|
|
128
|
+
for (const [key, item] of Object.entries(value)) {
|
|
129
|
+
const normalized = normalizeKey(key);
|
|
130
|
+
const pathLabel = pathParts.concat(key).join('.');
|
|
131
|
+
if (RISKY_KEY_NAMES.has(normalized)) findings.push({ path: pathLabel, reason: 'risky field name' });
|
|
132
|
+
findings.push(...scanForSensitiveMaterial(item, pathParts.concat(key)));
|
|
133
|
+
}
|
|
134
|
+
return findings;
|
|
135
|
+
}
|
|
136
|
+
if (typeof value === 'string') {
|
|
137
|
+
for (const { label, pattern } of RISKY_VALUE_PATTERNS) {
|
|
138
|
+
if (pattern.test(value)) findings.push({ path: pathParts.join('.') || '(root)', reason: label });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return findings;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function cloneJson(value) {
|
|
145
|
+
return JSON.parse(JSON.stringify(value));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function coerceArrayFromProviderOutput(raw) {
|
|
149
|
+
if (Array.isArray(raw)) return raw;
|
|
150
|
+
if (!raw || typeof raw !== 'object') return [];
|
|
151
|
+
for (const key of ['events', 'logs', 'result', 'results', 'data', 'rows']) {
|
|
152
|
+
if (Array.isArray(raw[key])) return raw[key];
|
|
153
|
+
}
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function lookupValue(record, keys) {
|
|
158
|
+
if (!record || typeof record !== 'object') return undefined;
|
|
159
|
+
for (const key of keys) {
|
|
160
|
+
if (Object.hasOwn(record, key)) return record[key];
|
|
161
|
+
}
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function normalizeTelemetryStatus(value) {
|
|
166
|
+
const status = String(value || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
167
|
+
return TELEMETRY_ALLOWED_STATUSES.has(status) ? status : null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalizeTelemetryTrigger(value) {
|
|
171
|
+
const trigger = String(value || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
172
|
+
return TELEMETRY_ALLOWED_TRIGGERS.has(trigger) ? trigger : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function normalizeTelemetryEventName(record) {
|
|
176
|
+
const raw = lookupValue(record, ['eventName', 'event_name', 'event', 'message']);
|
|
177
|
+
const text = String(raw || '').trim();
|
|
178
|
+
if (!text) return null;
|
|
179
|
+
if (/IMPORT_REDDIT_TIPS_PIPELINE_TELEMETRY/i.test(text)) return 'import_reddit_tips_pipeline_telemetry';
|
|
180
|
+
if (/authorization.*den/i.test(text)) return 'authorization_denial';
|
|
181
|
+
if (/scheduled.*trigger/i.test(text)) return 'scheduled_trigger';
|
|
182
|
+
if (/rate.*limit/i.test(text)) return 'rate_limit';
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function normalizeSafeCorrelationId(value) {
|
|
187
|
+
const id = String(value || '').trim();
|
|
188
|
+
if (!id) return null;
|
|
189
|
+
if (/^[A-Za-z0-9_-]{1,36}$/.test(id)) return id;
|
|
190
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) return id;
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function normalizeCount(value) {
|
|
195
|
+
if (value === null || value === undefined || value === '') return null;
|
|
196
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0) return value;
|
|
197
|
+
if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value);
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function collectTelemetryCounts(record) {
|
|
202
|
+
const counts = {};
|
|
203
|
+
const nested = record && typeof record.counts === 'object' && !Array.isArray(record.counts)
|
|
204
|
+
? record.counts
|
|
205
|
+
: {};
|
|
206
|
+
for (const key of TELEMETRY_COUNT_FIELDS) {
|
|
207
|
+
const value = normalizeCount(record?.[key] ?? nested[key]);
|
|
208
|
+
if (value === null) continue;
|
|
209
|
+
const normalizedKey = key
|
|
210
|
+
.replace(/Count$/, '')
|
|
211
|
+
.replace(/_count$/, '')
|
|
212
|
+
.toLowerCase();
|
|
213
|
+
counts[normalizedKey] = value;
|
|
214
|
+
}
|
|
215
|
+
return counts;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeTelemetryEvent(record) {
|
|
219
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) return null;
|
|
220
|
+
const counts = collectTelemetryCounts(record);
|
|
221
|
+
const eventName = normalizeTelemetryEventName(record);
|
|
222
|
+
const trigger = normalizeTelemetryTrigger(lookupValue(record, ['trigger', 'caller', 'source']));
|
|
223
|
+
const status = normalizeTelemetryStatus(lookupValue(record, ['status', 'statusClassification', 'status_classification', 'result']));
|
|
224
|
+
const timestamp = lookupValue(record, ['timestamp', 'time', 'created_at']);
|
|
225
|
+
const correlationId = normalizeSafeCorrelationId(lookupValue(record, ['requestId', 'request_id', 'correlationId', 'correlation_id']));
|
|
226
|
+
if (!Object.keys(counts).length && !eventName && !trigger && !status) return null;
|
|
227
|
+
const event = {};
|
|
228
|
+
if (typeof timestamp === 'string' && timestamp.length <= 40) event.timestamp = timestamp;
|
|
229
|
+
if (eventName) event.eventName = eventName;
|
|
230
|
+
if (trigger) event.trigger = trigger;
|
|
231
|
+
if (status) event.status = status;
|
|
232
|
+
if (correlationId) event.correlationId = correlationId;
|
|
233
|
+
if (Object.keys(counts).length) event.counts = counts;
|
|
234
|
+
return Object.keys(event).length ? event : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function summarizeSupabaseTelemetryOutput(raw) {
|
|
238
|
+
const errors = [];
|
|
239
|
+
const sensitive = scanForSensitiveMaterial(raw);
|
|
240
|
+
for (const item of sensitive) errors.push(`Sensitive material rejected at ${item.path}: ${item.reason}`);
|
|
241
|
+
if (errors.length) return { ok: false, errors };
|
|
242
|
+
|
|
243
|
+
const records = coerceArrayFromProviderOutput(raw);
|
|
244
|
+
if (!records.length) {
|
|
245
|
+
return {
|
|
246
|
+
ok: true,
|
|
247
|
+
signal: {
|
|
248
|
+
state: 'not_observed',
|
|
249
|
+
summary: 'No allowlisted telemetry records were present in the local provider output.'
|
|
250
|
+
},
|
|
251
|
+
telemetry: {
|
|
252
|
+
source: 'local telemetry file',
|
|
253
|
+
eventCount: 0,
|
|
254
|
+
events: [],
|
|
255
|
+
discardedUnknownFields: true,
|
|
256
|
+
rawOutputPrinted: false
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const events = records
|
|
262
|
+
.map((record) => normalizeTelemetryEvent(record))
|
|
263
|
+
.filter(Boolean);
|
|
264
|
+
if (!events.length) {
|
|
265
|
+
return {
|
|
266
|
+
ok: true,
|
|
267
|
+
signal: {
|
|
268
|
+
state: 'not_observed',
|
|
269
|
+
summary: 'Provider output was readable, but no count-only allowlisted telemetry event was found.'
|
|
270
|
+
},
|
|
271
|
+
telemetry: {
|
|
272
|
+
source: 'local telemetry file',
|
|
273
|
+
eventCount: 0,
|
|
274
|
+
events: [],
|
|
275
|
+
discardedUnknownFields: true,
|
|
276
|
+
rawOutputPrinted: false
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const aggregateCounts = {};
|
|
282
|
+
for (const event of events) {
|
|
283
|
+
for (const [key, value] of Object.entries(event.counts || {})) {
|
|
284
|
+
aggregateCounts[key] = (aggregateCounts[key] || 0) + value;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
ok: true,
|
|
290
|
+
signal: {
|
|
291
|
+
state: 'verified',
|
|
292
|
+
summary: `${events.length} count-only telemetry event${events.length === 1 ? '' : 's'} parsed from local provider output.`,
|
|
293
|
+
evidence: [
|
|
294
|
+
'raw provider output was read locally',
|
|
295
|
+
'unknown fields were discarded',
|
|
296
|
+
'only allowlisted count/status fields were emitted'
|
|
297
|
+
],
|
|
298
|
+
source: 'telemetry-file'
|
|
299
|
+
},
|
|
300
|
+
telemetry: {
|
|
301
|
+
source: 'local telemetry file',
|
|
302
|
+
eventCount: events.length,
|
|
303
|
+
aggregateCounts,
|
|
304
|
+
events,
|
|
305
|
+
discardedUnknownFields: true,
|
|
306
|
+
rawOutputPrinted: false
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function normalizeSignal(raw, key) {
|
|
312
|
+
if (typeof raw === 'string') return { state: raw, summary: key };
|
|
313
|
+
if (raw && typeof raw === 'object') {
|
|
314
|
+
return {
|
|
315
|
+
state: raw.state,
|
|
316
|
+
summary: raw.summary || raw.reason || key,
|
|
317
|
+
evidence: Array.isArray(raw.evidence) ? raw.evidence : [],
|
|
318
|
+
source: raw.source || null
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
return { state: 'not_verified', summary: key };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function validateSupabaseLiveEvidence(evidence) {
|
|
325
|
+
const errors = [];
|
|
326
|
+
if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) {
|
|
327
|
+
return { ok: false, errors: ['Evidence must be a JSON object'] };
|
|
328
|
+
}
|
|
329
|
+
if (evidence.schemaVersion !== SUPABASE_LIVE_SCHEMA_VERSION) {
|
|
330
|
+
errors.push(`Unsupported schemaVersion: ${evidence.schemaVersion || 'missing'}`);
|
|
331
|
+
}
|
|
332
|
+
if (!evidence.signals || typeof evidence.signals !== 'object' || Array.isArray(evidence.signals)) {
|
|
333
|
+
errors.push('signals must be an object');
|
|
334
|
+
}
|
|
335
|
+
const sensitive = scanForSensitiveMaterial(evidence);
|
|
336
|
+
for (const item of sensitive) errors.push(`Sensitive material rejected at ${item.path}: ${item.reason}`);
|
|
337
|
+
if (evidence.signals && typeof evidence.signals === 'object' && !Array.isArray(evidence.signals)) {
|
|
338
|
+
for (const [key, raw] of Object.entries(evidence.signals)) {
|
|
339
|
+
if (!SUPABASE_LIVE_SIGNALS.includes(key)) errors.push(`Unsupported signal: ${key}`);
|
|
340
|
+
const signal = normalizeSignal(raw, key);
|
|
341
|
+
if (!SUPABASE_LIVE_STATES.includes(signal.state)) errors.push(`Unsupported state for ${key}: ${signal.state || 'missing'}`);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return { ok: errors.length === 0, errors };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function applyTelemetrySummary(evidence, telemetrySummary) {
|
|
348
|
+
const next = cloneJson(evidence);
|
|
349
|
+
next.signals = { ...(next.signals || {}) };
|
|
350
|
+
next.signals.telemetry_count_only = telemetrySummary.signal;
|
|
351
|
+
next.telemetry = telemetrySummary.telemetry;
|
|
352
|
+
const redactions = new Set(next.redactionsApplied || []);
|
|
353
|
+
redactions.add('raw telemetry provider output omitted');
|
|
354
|
+
redactions.add('unknown telemetry fields discarded');
|
|
355
|
+
next.redactionsApplied = Array.from(redactions);
|
|
356
|
+
return next;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function telemetryOnlyEvidence(telemetrySummary) {
|
|
360
|
+
return applyTelemetrySummary({
|
|
361
|
+
schemaVersion: SUPABASE_LIVE_SCHEMA_VERSION,
|
|
362
|
+
collectedAt: null,
|
|
363
|
+
repositoryCommit: null,
|
|
364
|
+
functionName: null,
|
|
365
|
+
schedulerJob: null,
|
|
366
|
+
evidenceSource: 'local telemetry provider output',
|
|
367
|
+
manualOrAutonomous: 'not_specified',
|
|
368
|
+
databaseScope: null,
|
|
369
|
+
signals: Object.fromEntries(SUPABASE_LIVE_SIGNALS.map((key) => [key, { state: 'not_verified', summary: `${key} not supplied` }])),
|
|
370
|
+
redactionsApplied: [],
|
|
371
|
+
notVerified: SUPABASE_LIVE_SIGNALS.filter((key) => key !== 'telemetry_count_only')
|
|
372
|
+
}, telemetrySummary);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export function summarizeSupabaseLiveEvidence(evidence) {
|
|
376
|
+
const signals = Object.fromEntries(
|
|
377
|
+
SUPABASE_LIVE_SIGNALS.map((key) => [key, normalizeSignal(evidence.signals?.[key] ?? 'not_verified', key)])
|
|
378
|
+
);
|
|
379
|
+
const verified = [];
|
|
380
|
+
const failures = [];
|
|
381
|
+
const warnings = [];
|
|
382
|
+
const notVerified = [];
|
|
383
|
+
const skipped = [];
|
|
384
|
+
const checks = [];
|
|
385
|
+
const findings = [];
|
|
386
|
+
|
|
387
|
+
for (const key of SUPABASE_LIVE_SIGNALS) {
|
|
388
|
+
const signal = signals[key];
|
|
389
|
+
const label = key.replaceAll('_', ' ');
|
|
390
|
+
const checkStatus = signal.state === 'verified' || signal.state === 'external_evidence'
|
|
391
|
+
? 'pass'
|
|
392
|
+
: signal.state === 'failed'
|
|
393
|
+
? 'fail'
|
|
394
|
+
: ['not_configured', 'not_observed'].includes(signal.state)
|
|
395
|
+
? 'skipped'
|
|
396
|
+
: 'not_verified';
|
|
397
|
+
checks.push({ name: label, status: checkStatus, message: signal.state });
|
|
398
|
+
if (signal.state === 'verified') verified.push(`${label}: verified`);
|
|
399
|
+
else if (signal.state === 'external_evidence') verified.push(`${label}: external evidence attached`);
|
|
400
|
+
else if (signal.state === 'failed') {
|
|
401
|
+
const message = `${label}: failed`;
|
|
402
|
+
failures.push(message);
|
|
403
|
+
findings.push(createFinding({
|
|
404
|
+
status: 'fail',
|
|
405
|
+
area: 'supabase-live',
|
|
406
|
+
title: `${label} failed`,
|
|
407
|
+
finding: signal.summary || message,
|
|
408
|
+
evidence: signal.evidence || [],
|
|
409
|
+
whyItMatters: 'A failed production proof signal is a blocker until reviewed.',
|
|
410
|
+
nextSafeStep: 'Inspect the redacted source evidence and rerun the narrowest safe production check.'
|
|
411
|
+
}));
|
|
412
|
+
} else if (['not_configured', 'not_observed'].includes(signal.state)) {
|
|
413
|
+
skipped.push(`${label}: ${signal.state}`);
|
|
414
|
+
notVerified.push(`${label}: ${signal.summary || signal.state}`);
|
|
415
|
+
} else {
|
|
416
|
+
notVerified.push(`${label}: ${signal.summary || signal.state}`);
|
|
417
|
+
if (['unsafe_to_test', 'authentication_unavailable', 'metadata_unavailable'].includes(signal.state)) {
|
|
418
|
+
warnings.push(`${label}: ${signal.state}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const manualOrAutonomous = evidence.manualOrAutonomous || 'not_specified';
|
|
424
|
+
const schedulerSignal = signals.scheduler_autonomous_execution;
|
|
425
|
+
if (manualOrAutonomous === 'manual' && schedulerSignal.state === 'verified') {
|
|
426
|
+
warnings.push('scheduler autonomous execution is marked verified but evidence classification is manual');
|
|
427
|
+
notVerified.push('Autonomous scheduler execution needs autonomous pg_cron evidence, not a manual invocation');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
signals,
|
|
432
|
+
verified,
|
|
433
|
+
failures,
|
|
434
|
+
warnings,
|
|
435
|
+
skipped,
|
|
436
|
+
notVerified,
|
|
437
|
+
checks,
|
|
438
|
+
findings,
|
|
439
|
+
data: {
|
|
440
|
+
schemaVersion: evidence.schemaVersion,
|
|
441
|
+
collectedAt: evidence.collectedAt || null,
|
|
442
|
+
repositoryCommit: evidence.repositoryCommit || null,
|
|
443
|
+
functionName: evidence.functionName || null,
|
|
444
|
+
schedulerJob: evidence.schedulerJob || null,
|
|
445
|
+
evidenceSource: evidence.evidenceSource || null,
|
|
446
|
+
manualOrAutonomous,
|
|
447
|
+
databaseScope: evidence.databaseScope || null,
|
|
448
|
+
redactionsApplied: evidence.redactionsApplied || [],
|
|
449
|
+
notVerified: evidence.notVerified || [],
|
|
450
|
+
telemetry: evidence.telemetry || null,
|
|
451
|
+
signals
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async function loadTelemetrySummary({ cwd, telemetryFile }) {
|
|
457
|
+
if (!telemetryFile) return null;
|
|
458
|
+
const filePath = path.isAbsolute(telemetryFile) ? telemetryFile : path.join(cwd, telemetryFile);
|
|
459
|
+
let parsed;
|
|
460
|
+
try {
|
|
461
|
+
parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
462
|
+
} catch (error) {
|
|
463
|
+
return {
|
|
464
|
+
ok: false,
|
|
465
|
+
errors: [`Telemetry file error: ${error.code === 'ENOENT' ? 'missing file' : 'malformed JSON or unreadable file'}`]
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return summarizeSupabaseTelemetryOutput(parsed);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export async function runSupabaseLive({ cwd = process.cwd(), evidenceFile, telemetryFile, strict = false } = {}) {
|
|
472
|
+
if (!evidenceFile && !telemetryFile) {
|
|
473
|
+
return createResult('supabase-live', 'skipped', {
|
|
474
|
+
summary: 'Supabase live proof is explicit opt-in and requires a local redacted evidence or telemetry file. No Supabase network request was made.',
|
|
475
|
+
skipped: ['No --evidence-file or --telemetry-file was provided'],
|
|
476
|
+
notVerified: SUPABASE_LIVE_SIGNALS.map((signal) => signal.replaceAll('_', ' ')),
|
|
477
|
+
checks: [{ name: 'local evidence input provided', status: 'skipped', message: 'missing --evidence-file or --telemetry-file' }],
|
|
478
|
+
data: { boundary: 'local evidence file only', networkRequests: 0, mutations: 0 },
|
|
479
|
+
nextSafeStep: 'Collect redacted production evidence separately, then run opstruth supabase-live --evidence-file <file> or --telemetry-file <file>.'
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const telemetrySummary = await loadTelemetrySummary({ cwd, telemetryFile });
|
|
484
|
+
if (telemetrySummary && !telemetrySummary.ok) {
|
|
485
|
+
return finalizeStatus(createResult('supabase-live', 'fail', {
|
|
486
|
+
summary: 'Supabase telemetry provider output was rejected before rendering. No Supabase network request was made.',
|
|
487
|
+
failures: telemetrySummary.errors,
|
|
488
|
+
checks: [{ name: 'telemetry schema and redaction validation', status: 'fail', message: telemetrySummary.errors[0] }],
|
|
489
|
+
data: { boundary: 'local evidence file only', networkRequests: 0, mutations: 0, rejected: true },
|
|
490
|
+
nextSafeStep: 'Store raw provider output under /tmp, remove sensitive material, and rerun with an allowlisted telemetry file.'
|
|
491
|
+
}), { strict });
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (!evidenceFile && telemetrySummary) {
|
|
495
|
+
const evidence = telemetryOnlyEvidence(telemetrySummary);
|
|
496
|
+
const validation = validateSupabaseLiveEvidence(evidence);
|
|
497
|
+
if (!validation.ok) {
|
|
498
|
+
return finalizeStatus(createResult('supabase-live', 'fail', {
|
|
499
|
+
summary: 'Supabase telemetry evidence was rejected before rendering. No Supabase network request was made.',
|
|
500
|
+
failures: validation.errors,
|
|
501
|
+
checks: [{ name: 'telemetry evidence validation', status: 'fail', message: validation.errors[0] }],
|
|
502
|
+
data: { boundary: 'local evidence file only', networkRequests: 0, mutations: 0, rejected: true },
|
|
503
|
+
nextSafeStep: 'Review the telemetry allowlist and remove unsupported fields.'
|
|
504
|
+
}), { strict });
|
|
505
|
+
}
|
|
506
|
+
const summary = summarizeSupabaseLiveEvidence(evidence);
|
|
507
|
+
return finalizeStatus(createResult('supabase-live', summary.failures.length ? 'fail' : summary.warnings.length ? 'warn' : 'pass', {
|
|
508
|
+
summary: 'Supabase telemetry proof loaded from a local provider-output file. No Supabase network request was made.',
|
|
509
|
+
verified: summary.verified,
|
|
510
|
+
failures: summary.failures,
|
|
511
|
+
warnings: summary.warnings,
|
|
512
|
+
skipped: summary.skipped,
|
|
513
|
+
notVerified: summary.notVerified,
|
|
514
|
+
checks: summary.checks,
|
|
515
|
+
findings: summary.findings,
|
|
516
|
+
data: { ...summary.data, boundary: 'local evidence file only', networkRequests: 0, mutations: 0 },
|
|
517
|
+
nextSafeStep: 'Merge the telemetry signal into a full redacted evidence file when production context is ready.'
|
|
518
|
+
}), { strict });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
const filePath = path.isAbsolute(evidenceFile) ? evidenceFile : path.join(cwd, evidenceFile);
|
|
522
|
+
let parsed;
|
|
523
|
+
try {
|
|
524
|
+
parsed = JSON.parse(await readFile(filePath, 'utf8'));
|
|
525
|
+
} catch (error) {
|
|
526
|
+
return finalizeStatus(createResult('supabase-live', 'fail', {
|
|
527
|
+
summary: 'Supabase live proof evidence file could not be loaded or parsed. No Supabase network request was made.',
|
|
528
|
+
failures: [`Evidence file error: ${error.code === 'ENOENT' ? 'missing file' : 'malformed JSON or unreadable file'}`],
|
|
529
|
+
checks: [{ name: 'evidence file parse', status: 'fail', message: error.code === 'ENOENT' ? 'missing file' : 'parse/read failure' }],
|
|
530
|
+
data: { boundary: 'local evidence file only', networkRequests: 0, mutations: 0 },
|
|
531
|
+
nextSafeStep: 'Provide a valid redacted JSON evidence file.'
|
|
532
|
+
}), { strict });
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const evidence = telemetrySummary ? applyTelemetrySummary(parsed, telemetrySummary) : parsed;
|
|
536
|
+
const validation = validateSupabaseLiveEvidence(evidence);
|
|
537
|
+
if (!validation.ok) {
|
|
538
|
+
return finalizeStatus(createResult('supabase-live', 'fail', {
|
|
539
|
+
summary: 'Supabase live proof evidence was rejected before rendering. No Supabase network request was made.',
|
|
540
|
+
failures: validation.errors,
|
|
541
|
+
checks: [{ name: 'evidence schema and redaction validation', status: 'fail', message: validation.errors[0] }],
|
|
542
|
+
data: { boundary: 'local evidence file only', networkRequests: 0, mutations: 0, rejected: true },
|
|
543
|
+
nextSafeStep: 'Remove raw identifiers, credentials, headers, payloads, logs, and unsupported fields; rerun with redacted evidence.'
|
|
544
|
+
}), { strict });
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const summary = summarizeSupabaseLiveEvidence(evidence);
|
|
548
|
+
return finalizeStatus(createResult('supabase-live', summary.failures.length ? 'fail' : summary.warnings.length ? 'warn' : 'pass', {
|
|
549
|
+
summary: 'Supabase live proof loaded from a local redacted evidence file. No Supabase network request was made.',
|
|
550
|
+
verified: summary.verified,
|
|
551
|
+
failures: summary.failures,
|
|
552
|
+
warnings: summary.warnings,
|
|
553
|
+
skipped: summary.skipped,
|
|
554
|
+
notVerified: summary.notVerified,
|
|
555
|
+
checks: summary.checks,
|
|
556
|
+
findings: summary.findings,
|
|
557
|
+
data: { ...summary.data, boundary: 'local evidence file only', networkRequests: 0, mutations: 0 },
|
|
558
|
+
nextSafeStep: summary.failures.length
|
|
559
|
+
? 'Review failed production signals before trusting the Supabase state.'
|
|
560
|
+
: summary.notVerified.length
|
|
561
|
+
? 'Treat not-verified Supabase properties as proof gaps and collect the narrowest safe evidence.'
|
|
562
|
+
: 'Attach the redacted evidence file to the review record.'
|
|
563
|
+
}), { strict });
|
|
564
|
+
}
|
package/src/lib/config.js
CHANGED
|
@@ -1,18 +1,56 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { pathExists, readJson, readText } from './fs.js';
|
|
3
3
|
|
|
4
|
+
export async function loadOpstruthConfig(root) {
|
|
5
|
+
const full = path.join(root, 'opstruth.config.json');
|
|
6
|
+
if (!(await pathExists(full))) return { config: null, warning: null, file: null };
|
|
7
|
+
try {
|
|
8
|
+
return { config: await readJson(full), warning: null, file: 'opstruth.config.json' };
|
|
9
|
+
} catch (error) {
|
|
10
|
+
return { config: null, warning: 'Invalid opstruth.config.json: ' + error.message, file: 'opstruth.config.json' };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeRoutesConfig(config) {
|
|
15
|
+
if (!config) return null;
|
|
16
|
+
const routesConfig = config.routes?.baseUrl !== undefined || config.routes?.paths || Array.isArray(config.routes)
|
|
17
|
+
? config.routes
|
|
18
|
+
: config;
|
|
19
|
+
const normalizeRoute = (route) => ({
|
|
20
|
+
...route,
|
|
21
|
+
expectStatus: route.expectStatus || (Number.isFinite(route.expectedStatus) ? [route.expectedStatus] : route.expectedStatus)
|
|
22
|
+
});
|
|
23
|
+
if (Array.isArray(routesConfig)) return { routes: routesConfig.map(normalizeRoute) };
|
|
24
|
+
if (Array.isArray(routesConfig?.routes)) return { ...routesConfig, routes: routesConfig.routes.map(normalizeRoute) };
|
|
25
|
+
if (Array.isArray(routesConfig?.paths)) {
|
|
26
|
+
return {
|
|
27
|
+
...routesConfig,
|
|
28
|
+
routes: routesConfig.paths.map((routePath) => ({
|
|
29
|
+
path: routePath,
|
|
30
|
+
method: String(routePath).includes('health') ? 'GET' : 'HEAD',
|
|
31
|
+
expectStatus: [200, 301, 302]
|
|
32
|
+
}))
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return routesConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
4
38
|
export async function loadRoutesConfig(root, file) {
|
|
5
39
|
if (!file) return null;
|
|
6
40
|
const full = path.isAbsolute(file) ? file : path.join(root, file);
|
|
7
41
|
if (!(await pathExists(full))) return null;
|
|
8
|
-
return readJson(full);
|
|
42
|
+
return normalizeRoutesConfig(await readJson(full));
|
|
9
43
|
}
|
|
10
44
|
export async function findDefaultRoutesConfig(root) {
|
|
11
45
|
for (const file of ['opstruth.config.json', 'opstruth.routes.json', 'routes.json']) {
|
|
12
46
|
const full = path.join(root, file);
|
|
13
47
|
if (await pathExists(full)) {
|
|
14
|
-
|
|
15
|
-
|
|
48
|
+
try {
|
|
49
|
+
const config = await readJson(full);
|
|
50
|
+
return { file, config: normalizeRoutesConfig(config) };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return { file, config: null, warning: 'Invalid route config ' + file + ': ' + error.message };
|
|
53
|
+
}
|
|
16
54
|
}
|
|
17
55
|
}
|
|
18
56
|
return null;
|
package/src/lib/exec.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { redact } from './redact.js';
|
|
3
3
|
|
|
4
|
-
export function runCommand(command, args = [], {
|
|
4
|
+
export function runCommand(command, args = [], {
|
|
5
|
+
cwd = process.cwd(),
|
|
6
|
+
timeoutMs = 120000,
|
|
7
|
+
redactStdout = true,
|
|
8
|
+
redactStderr = true
|
|
9
|
+
} = {}) {
|
|
5
10
|
const started = Date.now();
|
|
6
11
|
return new Promise((resolve) => {
|
|
7
12
|
const child = spawn(command, args, { cwd, shell: false, windowsHide: true });
|
|
@@ -12,11 +17,24 @@ export function runCommand(command, args = [], { cwd = process.cwd(), timeoutMs
|
|
|
12
17
|
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
13
18
|
child.on('error', (error) => {
|
|
14
19
|
clearTimeout(timer);
|
|
15
|
-
resolve({
|
|
20
|
+
resolve({
|
|
21
|
+
command: [command, ...args].join(' '),
|
|
22
|
+
exitCode: 127,
|
|
23
|
+
durationMs: Date.now() - started,
|
|
24
|
+
stdout: '',
|
|
25
|
+
stderr: redactStderr ? redact(error.message) : error.message
|
|
26
|
+
});
|
|
16
27
|
});
|
|
17
28
|
child.on('close', (code, signal) => {
|
|
18
29
|
clearTimeout(timer);
|
|
19
|
-
resolve({
|
|
30
|
+
resolve({
|
|
31
|
+
command: [command, ...args].join(' '),
|
|
32
|
+
exitCode: code ?? (signal ? 124 : 1),
|
|
33
|
+
signal,
|
|
34
|
+
durationMs: Date.now() - started,
|
|
35
|
+
stdout: redactStdout ? redact(stdout) : stdout,
|
|
36
|
+
stderr: redactStderr ? redact(stderr) : stderr
|
|
37
|
+
});
|
|
20
38
|
});
|
|
21
39
|
});
|
|
22
40
|
}
|
package/src/lib/git.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { runCommand } from './exec.js';
|
|
2
2
|
|
|
3
|
-
export async function git(args, cwd
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
export async function git(args, cwd, options = {}) {
|
|
4
|
+
return runCommand('git', args, { cwd, timeoutMs: options.timeoutMs ?? 30000, redactStdout: options.redactStdout ?? true });
|
|
5
|
+
}
|
|
6
|
+
export async function gitText(args, cwd, options = {}) {
|
|
7
|
+
const result = await git(args, cwd, options);
|
|
6
8
|
return result.exitCode === 0 ? result.stdout.trim() : '';
|
|
7
9
|
}
|
|
8
10
|
export async function getGitInfo(cwd) {
|