opstruth 0.1.1

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/bin/opstruth.js +7 -0
  4. package/examples/routes.json +12 -0
  5. package/fixtures/next-app/app/page.tsx +3 -0
  6. package/fixtures/next-app/next.config.ts +5 -0
  7. package/fixtures/next-app/package.json +19 -0
  8. package/fixtures/next-app/tsconfig.json +6 -0
  9. package/fixtures/non-git-folder/README.md +3 -0
  10. package/fixtures/non-git-folder/notes.txt +1 -0
  11. package/fixtures/plain-node-app/package.json +8 -0
  12. package/fixtures/plain-node-app/src/index.js +3 -0
  13. package/fixtures/risky-secret-app/package.json +8 -0
  14. package/fixtures/risky-secret-app/src/config.js +3 -0
  15. package/fixtures/supabase-cloudflare-app/package.json +16 -0
  16. package/fixtures/supabase-cloudflare-app/src/supabaseClient.ts +7 -0
  17. package/fixtures/supabase-cloudflare-app/src/worker.ts +5 -0
  18. package/fixtures/supabase-cloudflare-app/supabase/migrations/001_init.sql +11 -0
  19. package/fixtures/supabase-cloudflare-app/wrangler.toml +6 -0
  20. package/fixtures/vite-react-app/package.json +20 -0
  21. package/fixtures/vite-react-app/src/App.tsx +3 -0
  22. package/fixtures/vite-react-app/tsconfig.json +6 -0
  23. package/fixtures/vite-react-app/vite.config.ts +6 -0
  24. package/package.json +53 -0
  25. package/scripts/demo-fixtures.sh +35 -0
  26. package/scripts/demo-run.sh +32 -0
  27. package/src/cli.js +254 -0
  28. package/src/commands/cloudflare.js +51 -0
  29. package/src/commands/evidence.js +38 -0
  30. package/src/commands/local.js +43 -0
  31. package/src/commands/probes.js +68 -0
  32. package/src/commands/quality.js +66 -0
  33. package/src/commands/repo.js +30 -0
  34. package/src/commands/routes.js +49 -0
  35. package/src/commands/secrets.js +33 -0
  36. package/src/commands/supabase.js +39 -0
  37. package/src/lib/boundary.js +74 -0
  38. package/src/lib/config.js +31 -0
  39. package/src/lib/detect.js +111 -0
  40. package/src/lib/exec.js +28 -0
  41. package/src/lib/fs.js +36 -0
  42. package/src/lib/git.js +27 -0
  43. package/src/lib/http.js +14 -0
  44. package/src/lib/markdown.js +202 -0
  45. package/src/lib/probes.js +489 -0
  46. package/src/lib/redact.js +27 -0
  47. package/src/lib/result.js +63 -0
  48. package/src/lib/scan.js +53 -0
  49. package/src/orchestrator.js +106 -0
@@ -0,0 +1,489 @@
1
+ import path from 'node:path';
2
+ import { pathExists } from './fs.js';
3
+ import { isDefaultPlaceholderTestScript } from '../commands/quality.js';
4
+
5
+ function fileDetector(file) {
6
+ return async (root) => pathExists(path.join(root, file));
7
+ }
8
+
9
+ function nodeDependencyDetector(name) {
10
+ return async (_root, stack) => stack.dependencies?.includes(name);
11
+ }
12
+
13
+ export const PROBE_CATALOGUE = [
14
+ {
15
+ id: 'git.status',
16
+ name: 'Git status',
17
+ area: 'repo',
18
+ stack: 'git',
19
+ description: 'Records the git branch, latest commit, dirty file count, and changed-file list from the resolved project boundary.',
20
+ detector: async (_root, stack, boundary) => boundary.isGitRepo,
21
+ safetyLevel: 'safe_readonly',
22
+ defaultMode: 'automatic',
23
+ command: 'git status --short',
24
+ evidenceCollected: ['cwd', 'git root', 'branch', 'latest commit', 'dirty file count'],
25
+ proves: 'The working tree state visible to git.',
26
+ doesNotProve: 'Whether changes are deployed or correct.',
27
+ nextSafeStep: 'Review changed files before trusting the result.'
28
+ },
29
+ {
30
+ id: 'git.diff_check',
31
+ name: 'Git diff whitespace check',
32
+ area: 'quality',
33
+ stack: 'git',
34
+ description: 'Runs `git diff --check` to catch whitespace errors and unresolved conflict markers in staged and unstaged changes.',
35
+ detector: async (_root, _stack, boundary) => boundary.isGitRepo,
36
+ safetyLevel: 'safe_readonly',
37
+ defaultMode: 'automatic',
38
+ command: 'git diff --check',
39
+ evidenceCollected: ['command', 'exit code', 'duration', 'excerpt'],
40
+ proves: 'Git did not report diff-check errors.',
41
+ doesNotProve: 'Application correctness.',
42
+ nextSafeStep: 'Fix reported diff issues and rerun.'
43
+ },
44
+ {
45
+ id: 'git.diff_stat',
46
+ name: 'Git diff stat',
47
+ area: 'repo',
48
+ stack: 'git',
49
+ description: 'Records a compact `git diff --stat` summary so reviewers can see which files moved without reading the full diff.',
50
+ detector: async (_root, _stack, boundary) => boundary.isGitRepo,
51
+ safetyLevel: 'safe_readonly',
52
+ defaultMode: 'automatic',
53
+ command: 'git diff --stat',
54
+ evidenceCollected: ['diff stat'],
55
+ proves: 'A high-level changed-file summary.',
56
+ doesNotProve: 'Semantic impact.',
57
+ nextSafeStep: 'Inspect the files with the largest movement.'
58
+ },
59
+ {
60
+ id: 'git.log',
61
+ name: 'Git log',
62
+ area: 'repo',
63
+ stack: 'git',
64
+ description: 'Captures the latest commit and recent commit context.',
65
+ detector: async (_root, _stack, boundary) => boundary.isGitRepo,
66
+ safetyLevel: 'safe_readonly',
67
+ defaultMode: 'automatic',
68
+ command: 'git log --oneline -5',
69
+ evidenceCollected: ['latest commit', 'recent commits'],
70
+ proves: 'Recent repository commit context.',
71
+ doesNotProve: 'Remote or CI state.',
72
+ nextSafeStep: 'Compare with PR or CI evidence when needed.'
73
+ },
74
+ {
75
+ id: 'git.merge_conflicts',
76
+ name: 'Merge conflict marker scan',
77
+ area: 'repo',
78
+ stack: 'git',
79
+ description: 'Scans text files inside the project boundary for unresolved merge conflict markers without reading ignored cache/build folders.',
80
+ detector: async () => true,
81
+ safetyLevel: 'safe_readonly',
82
+ defaultMode: 'automatic',
83
+ staticCheck: true,
84
+ evidenceCollected: ['file', 'line', 'marker preview'],
85
+ proves: 'No obvious conflict markers were found in scanned text files.',
86
+ doesNotProve: 'All generated/binary content is clean.',
87
+ nextSafeStep: 'Resolve markers and rerun.'
88
+ },
89
+ {
90
+ id: 'node.package_manager',
91
+ name: 'Package manager detection',
92
+ area: 'node',
93
+ stack: 'node',
94
+ description: 'Detects npm, pnpm, yarn, or bun from lockfiles/package.json.',
95
+ detector: fileDetector('package.json'),
96
+ safetyLevel: 'safe_readonly',
97
+ defaultMode: 'automatic',
98
+ staticCheck: true,
99
+ evidenceCollected: ['package manager', 'lockfiles'],
100
+ proves: 'Which package command opstruth should use for read-only quality scripts.',
101
+ doesNotProve: 'Dependency health or install reproducibility.',
102
+ nextSafeStep: 'Keep lockfiles consistent with the chosen package manager.'
103
+ },
104
+ {
105
+ id: 'node.tsconfig',
106
+ name: 'TypeScript config detection',
107
+ area: 'node',
108
+ stack: 'typescript',
109
+ description: 'Detects TypeScript through tsconfig, dependencies, source, and config files.',
110
+ detector: async (_root, stack) => stack.isTypeScript,
111
+ safetyLevel: 'safe_readonly',
112
+ defaultMode: 'automatic',
113
+ staticCheck: true,
114
+ evidenceCollected: ['tsconfig', 'TypeScript dependency', 'TS source presence'],
115
+ proves: 'The repo has TypeScript indicators inside the project boundary.',
116
+ doesNotProve: 'Types are valid unless typecheck runs.',
117
+ nextSafeStep: 'Run the typecheck script when available.'
118
+ },
119
+ {
120
+ id: 'node.vite',
121
+ name: 'Vite detection',
122
+ area: 'node',
123
+ stack: 'vite',
124
+ description: 'Detects Vite configuration or dependency.',
125
+ detector: async (_root, stack) => stack.platforms?.includes('Vite'),
126
+ safetyLevel: 'safe_readonly',
127
+ defaultMode: 'automatic',
128
+ staticCheck: true,
129
+ evidenceCollected: ['vite config', 'vite dependency'],
130
+ proves: 'Vite appears relevant to this project.',
131
+ doesNotProve: 'The Vite app builds or serves.',
132
+ nextSafeStep: 'Run build/test scripts when present.'
133
+ },
134
+ {
135
+ id: 'node.next',
136
+ name: 'Next.js detection',
137
+ area: 'node',
138
+ stack: 'next',
139
+ description: 'Detects Next.js configuration or dependency.',
140
+ detector: async (_root, stack) => stack.platforms?.includes('Next.js'),
141
+ safetyLevel: 'safe_readonly',
142
+ defaultMode: 'automatic',
143
+ staticCheck: true,
144
+ evidenceCollected: ['next config', 'next dependency'],
145
+ proves: 'Next.js appears relevant to this project.',
146
+ doesNotProve: 'Runtime routes or deployment status.',
147
+ nextSafeStep: 'Run build and configured route probes when available.'
148
+ },
149
+ {
150
+ id: 'node.react',
151
+ name: 'React detection',
152
+ area: 'node',
153
+ stack: 'react',
154
+ description: 'Detects React dependency.',
155
+ detector: nodeDependencyDetector('react'),
156
+ safetyLevel: 'safe_readonly',
157
+ defaultMode: 'automatic',
158
+ staticCheck: true,
159
+ evidenceCollected: ['React dependency'],
160
+ proves: 'React appears in project dependencies.',
161
+ doesNotProve: 'Rendered UI behavior.',
162
+ nextSafeStep: 'Run component or browser tests if available.'
163
+ },
164
+ {
165
+ id: 'node.eslint',
166
+ name: 'ESLint config detection',
167
+ area: 'quality',
168
+ stack: 'eslint',
169
+ description: 'Detects ESLint config and lint script availability.',
170
+ detector: async (_root, stack) => Boolean(stack.config?.eslint?.length || stack.scripts?.lint),
171
+ safetyLevel: 'safe_readonly',
172
+ defaultMode: 'automatic',
173
+ staticCheck: true,
174
+ evidenceCollected: ['ESLint config', 'lint script'],
175
+ proves: 'Lint checks may be available.',
176
+ doesNotProve: 'Lint is passing unless the script runs.',
177
+ nextSafeStep: 'Run npm/pnpm/yarn/bun lint through opstruth quality.'
178
+ },
179
+ {
180
+ id: 'node.vitest',
181
+ name: 'Vitest config detection',
182
+ area: 'quality',
183
+ stack: 'vitest',
184
+ description: 'Detects Vitest config or dependency.',
185
+ detector: async (_root, stack) => Boolean(stack.config?.vitest?.length || stack.dependencies?.includes('vitest')),
186
+ safetyLevel: 'safe_readonly',
187
+ defaultMode: 'automatic',
188
+ staticCheck: true,
189
+ evidenceCollected: ['Vitest config', 'vitest dependency'],
190
+ proves: 'Vitest may be available.',
191
+ doesNotProve: 'Tests are passing unless the script runs.',
192
+ nextSafeStep: 'Run the test script through opstruth quality.'
193
+ },
194
+ {
195
+ id: 'node.playwright',
196
+ name: 'Playwright config detection',
197
+ area: 'quality',
198
+ stack: 'playwright',
199
+ description: 'Detects Playwright config or dependency.',
200
+ detector: async (_root, stack) => Boolean(stack.config?.playwright?.length || stack.dependencies?.includes('@playwright/test')),
201
+ safetyLevel: 'safe_readonly',
202
+ defaultMode: 'optional',
203
+ staticCheck: true,
204
+ evidenceCollected: ['Playwright config', 'Playwright dependency'],
205
+ proves: 'Browser tests may be available.',
206
+ doesNotProve: 'Browser behavior unless tests are run explicitly.',
207
+ nextSafeStep: 'Run browser tests separately when they are safe for the environment.'
208
+ },
209
+ {
210
+ id: 'quality.typecheck',
211
+ name: 'Typecheck script',
212
+ area: 'quality',
213
+ stack: 'node',
214
+ description: 'Runs the existing package `typecheck` script only when package.json exposes it; missing scripts are skipped, not failed.',
215
+ detector: async (_root, stack) => Boolean(stack.scripts?.typecheck),
216
+ safetyLevel: 'safe_readonly',
217
+ defaultMode: 'automatic',
218
+ command: '<package-manager> run typecheck',
219
+ evidenceCollected: ['command', 'exit code', 'duration', 'short excerpt'],
220
+ proves: 'Configured typecheck command exits successfully.',
221
+ doesNotProve: 'Runtime correctness.',
222
+ nextSafeStep: 'Fix type errors and rerun.'
223
+ },
224
+ {
225
+ id: 'quality.lint',
226
+ name: 'Lint script',
227
+ area: 'quality',
228
+ stack: 'node',
229
+ description: 'Runs the existing package `lint` script only when package.json exposes it; opstruth does not invent lint commands.',
230
+ detector: async (_root, stack) => Boolean(stack.scripts?.lint),
231
+ safetyLevel: 'safe_readonly',
232
+ defaultMode: 'automatic',
233
+ command: '<package-manager> run lint',
234
+ evidenceCollected: ['command', 'exit code', 'duration', 'short excerpt'],
235
+ proves: 'Configured lint command exits successfully.',
236
+ doesNotProve: 'Production behavior.',
237
+ nextSafeStep: 'Fix lint failures and rerun.'
238
+ },
239
+ {
240
+ id: 'quality.test',
241
+ name: 'Test script',
242
+ area: 'quality',
243
+ stack: 'node',
244
+ description: 'Runs the existing package `test` script only when package.json exposes a real test command; npm placeholder scripts are skipped, not failed.',
245
+ detector: async (_root, stack) => Boolean(stack.scripts?.test && !isDefaultPlaceholderTestScript(stack.scripts.test)),
246
+ safetyLevel: 'safe_readonly',
247
+ defaultMode: 'automatic',
248
+ command: '<package-manager> run test',
249
+ evidenceCollected: ['command', 'exit code', 'duration', 'short excerpt'],
250
+ proves: 'Configured test command exits successfully.',
251
+ doesNotProve: 'Untested behavior or production state.',
252
+ nextSafeStep: 'Fix failing tests and rerun.'
253
+ },
254
+ {
255
+ id: 'quality.build',
256
+ name: 'Build script',
257
+ area: 'quality',
258
+ stack: 'node',
259
+ description: 'Runs the existing package `build` script only when package.json exposes it; this proves local build command success, not deployment.',
260
+ detector: async (_root, stack) => Boolean(stack.scripts?.build),
261
+ safetyLevel: 'safe_readonly',
262
+ defaultMode: 'automatic',
263
+ command: '<package-manager> run build',
264
+ evidenceCollected: ['command', 'exit code', 'duration', 'short excerpt'],
265
+ proves: 'Configured build command exits successfully.',
266
+ doesNotProve: 'Deployment success.',
267
+ nextSafeStep: 'Fix build failures and rerun.'
268
+ },
269
+ {
270
+ id: 'quality.ci',
271
+ name: 'CI script',
272
+ area: 'quality',
273
+ stack: 'node',
274
+ description: 'Runs the existing package `ci` script only when package.json exposes it; this is optional because local CI scripts may be slower.',
275
+ detector: async (_root, stack) => Boolean(stack.scripts?.ci),
276
+ safetyLevel: 'safe_readonly',
277
+ defaultMode: 'optional',
278
+ command: '<package-manager> run ci',
279
+ evidenceCollected: ['command', 'exit code', 'duration', 'short excerpt'],
280
+ proves: 'Configured local CI command exits successfully.',
281
+ doesNotProve: 'Remote CI status.',
282
+ nextSafeStep: 'Compare with hosted CI when shipping.'
283
+ },
284
+ {
285
+ id: 'routes.head_root',
286
+ name: 'HEAD /',
287
+ area: 'routes',
288
+ stack: 'http',
289
+ description: 'Sends a read-only HEAD request to the configured base URL root and records status, latency, redirects, and required security headers.',
290
+ detector: async (_root, _stack, _boundary, options) => Boolean(options.baseUrl || options.routesFile),
291
+ safetyLevel: 'safe_readonly',
292
+ defaultMode: 'automatic',
293
+ command: 'HEAD /',
294
+ evidenceCollected: ['URL', 'method', 'status', 'latency', 'headers', 'redirect'],
295
+ proves: 'The configured route responded at probe time.',
296
+ doesNotProve: 'All routes or authenticated flows.',
297
+ nextSafeStep: 'Add important paths to opstruth.config.json.'
298
+ },
299
+ {
300
+ id: 'routes.health',
301
+ name: 'GET /health',
302
+ area: 'routes',
303
+ stack: 'http',
304
+ description: 'Sends a read-only GET request to configured health-style paths and records status and latency without authenticating or mutating state.',
305
+ detector: async (_root, _stack, _boundary, options) => Boolean(options.baseUrl || options.routesFile),
306
+ safetyLevel: 'safe_readonly',
307
+ defaultMode: 'automatic',
308
+ command: 'GET /health',
309
+ evidenceCollected: ['URL', 'method', 'status', 'latency'],
310
+ proves: 'The configured health endpoint responded at probe time.',
311
+ doesNotProve: 'Deep dependency health unless the app reports it.',
312
+ nextSafeStep: 'Expose a read-only health endpoint if useful.'
313
+ },
314
+ {
315
+ id: 'local.ports',
316
+ name: 'Listening port checks',
317
+ area: 'local',
318
+ stack: 'runtime',
319
+ description: 'Checks only explicitly provided local ports with listening-port inspection; it does not start, restart, or kill processes.',
320
+ detector: async (_root, _stack, _boundary, options) => Boolean(options.port?.length),
321
+ safetyLevel: 'safe_readonly',
322
+ defaultMode: 'automatic',
323
+ command: 'ss -ltnp',
324
+ evidenceCollected: ['port', 'probe type', 'result'],
325
+ proves: 'A configured local port appears to be listening.',
326
+ doesNotProve: 'Application correctness.',
327
+ nextSafeStep: 'Add a health path for stronger local runtime evidence.'
328
+ },
329
+ {
330
+ id: 'local.health',
331
+ name: 'Local health endpoint checks',
332
+ area: 'local',
333
+ stack: 'runtime',
334
+ description: 'Checks only explicitly provided local health URLs on 127.0.0.1 and records HTTP status, latency, and probe result.',
335
+ detector: async (_root, _stack, _boundary, options) => Boolean(options.port?.length && options.health),
336
+ safetyLevel: 'safe_readonly',
337
+ defaultMode: 'automatic',
338
+ command: 'GET http://127.0.0.1:<port>/<health>',
339
+ evidenceCollected: ['port', 'health URL', 'status', 'latency'],
340
+ proves: 'The configured local health endpoint responded.',
341
+ doesNotProve: 'Production health.',
342
+ nextSafeStep: 'Run against the runtime that matters.'
343
+ },
344
+ {
345
+ id: 'secrets.patterns',
346
+ name: 'Secret pattern scan',
347
+ area: 'secrets',
348
+ stack: 'all',
349
+ description: 'Scans project text files for risky secret, token, bearer, service-role, and authorization references with redacted previews.',
350
+ detector: async () => true,
351
+ safetyLevel: 'safe_readonly',
352
+ defaultMode: 'automatic',
353
+ staticCheck: true,
354
+ evidenceCollected: ['file', 'line', 'pattern', 'redacted preview'],
355
+ proves: 'No configured risky patterns appeared in scanned text files.',
356
+ doesNotProve: 'Absence of all secrets.',
357
+ nextSafeStep: 'Review findings and move real secrets to secret storage.'
358
+ },
359
+ {
360
+ id: 'supabase.migrations',
361
+ name: 'Supabase migrations detection',
362
+ area: 'supabase',
363
+ stack: 'supabase',
364
+ description: 'Detects Supabase migration files and records static RLS, policy, protected-table, and security-definer heuristics without connecting to a database.',
365
+ detector: fileDetector('supabase/migrations'),
366
+ safetyLevel: 'safe_readonly',
367
+ defaultMode: 'automatic',
368
+ staticCheck: true,
369
+ evidenceCollected: ['migration files', 'RLS/policy/security-definer heuristics'],
370
+ proves: 'Supabase migrations exist and static heuristics were inspected.',
371
+ doesNotProve: 'Database runtime policy state.',
372
+ nextSafeStep: 'Review migrations and verify database state separately.'
373
+ },
374
+ {
375
+ id: 'cloudflare.wrangler',
376
+ name: 'Wrangler config detection',
377
+ area: 'cloudflare',
378
+ stack: 'cloudflare',
379
+ description: 'Detects Wrangler configuration files and records declared Worker entry, compatibility date, routes, and deploy-script references. Does not prove the Worker is currently deployed unless route checks are configured.',
380
+ detector: async (root) => (await fileDetector('wrangler.toml')(root)) || (await fileDetector('wrangler.json')(root)) || (await fileDetector('wrangler.jsonc')(root)),
381
+ safetyLevel: 'safe_readonly',
382
+ defaultMode: 'automatic',
383
+ staticCheck: true,
384
+ evidenceCollected: ['wrangler config', 'compatibility date', 'route declarations', 'secret names only'],
385
+ proves: 'Cloudflare configuration is present.',
386
+ doesNotProve: 'Deployed Worker/Pages state.',
387
+ nextSafeStep: 'Verify deployed routes separately.'
388
+ },
389
+ {
390
+ id: 'docker.compose',
391
+ name: 'Docker Compose detection',
392
+ area: 'docker',
393
+ stack: 'docker',
394
+ description: 'Detects docker-compose files and service names without starting containers.',
395
+ detector: async (root) => (await fileDetector('docker-compose.yml')(root)) || (await fileDetector('docker-compose.yaml')(root)),
396
+ safetyLevel: 'safe_readonly',
397
+ defaultMode: 'automatic',
398
+ staticCheck: true,
399
+ evidenceCollected: ['compose file', 'service names'],
400
+ proves: 'Compose configuration exists.',
401
+ doesNotProve: 'Containers are running or healthy.',
402
+ nextSafeStep: 'Run explicit local runtime probes for running containers.'
403
+ },
404
+ {
405
+ id: 'docker.dockerfile',
406
+ name: 'Dockerfile detection',
407
+ area: 'docker',
408
+ stack: 'docker',
409
+ description: 'Detects Dockerfile presence.',
410
+ detector: fileDetector('Dockerfile'),
411
+ safetyLevel: 'safe_readonly',
412
+ defaultMode: 'automatic',
413
+ staticCheck: true,
414
+ evidenceCollected: ['Dockerfile path'],
415
+ proves: 'A Docker build recipe exists.',
416
+ doesNotProve: 'Image builds or runs.',
417
+ nextSafeStep: 'Run build checks outside opstruth when appropriate.'
418
+ },
419
+ {
420
+ id: 'github.workflows',
421
+ name: 'GitHub Actions workflow detection',
422
+ area: 'github',
423
+ stack: 'github-actions',
424
+ description: 'Detects GitHub Actions workflow files and records CI/deploy workflow heuristics plus secret-reference patterns without calling GitHub.',
425
+ detector: fileDetector('.github/workflows'),
426
+ safetyLevel: 'safe_readonly',
427
+ defaultMode: 'automatic',
428
+ staticCheck: true,
429
+ evidenceCollected: ['workflow files', 'deploy heuristic', 'secret references'],
430
+ proves: 'Workflow files exist and static heuristics were inspected.',
431
+ doesNotProve: 'Remote CI status.',
432
+ nextSafeStep: 'Check hosted CI for authoritative status.'
433
+ },
434
+ {
435
+ id: 'hosting.vercel',
436
+ name: 'Vercel config detection',
437
+ area: 'hosting',
438
+ stack: 'vercel',
439
+ description: 'Detects `vercel.json` hosting configuration and records that Vercel may be relevant; it does not call Vercel or prove deployment state.',
440
+ detector: fileDetector('vercel.json'),
441
+ safetyLevel: 'safe_readonly',
442
+ defaultMode: 'automatic',
443
+ staticCheck: true,
444
+ evidenceCollected: ['vercel.json'],
445
+ proves: 'Vercel configuration exists.',
446
+ doesNotProve: 'Deployment status.',
447
+ nextSafeStep: 'Verify the deployed URL separately.'
448
+ },
449
+ {
450
+ id: 'hosting.netlify',
451
+ name: 'Netlify config detection',
452
+ area: 'hosting',
453
+ stack: 'netlify',
454
+ description: 'Detects `netlify.toml` hosting configuration and records that Netlify may be relevant; it does not call Netlify or prove deployment state.',
455
+ detector: fileDetector('netlify.toml'),
456
+ safetyLevel: 'safe_readonly',
457
+ defaultMode: 'automatic',
458
+ staticCheck: true,
459
+ evidenceCollected: ['netlify.toml'],
460
+ proves: 'Netlify configuration exists.',
461
+ doesNotProve: 'Deployment status.',
462
+ nextSafeStep: 'Verify the deployed URL separately.'
463
+ }
464
+ ];
465
+
466
+ export async function selectProbes({ root, stack, boundary, options = {} }) {
467
+ const only = new Set(options.only || []);
468
+ const skip = new Set(options.skip || []);
469
+ const selected = [];
470
+ const skipped = [];
471
+ for (const probe of PROBE_CATALOGUE) {
472
+ if (only.size && !only.has(probe.id) && !only.has(probe.area) && !only.has(probe.stack)) {
473
+ skipped.push({ ...probe, reason: 'Skipped by --only filter' });
474
+ continue;
475
+ }
476
+ if (skip.has(probe.id) || skip.has(probe.area) || skip.has(probe.stack)) {
477
+ skipped.push({ ...probe, reason: 'Skipped by user request' });
478
+ continue;
479
+ }
480
+ if (probe.safetyLevel !== 'safe_readonly' || probe.defaultMode === 'manual_only') {
481
+ skipped.push({ ...probe, reason: 'Requires explicit approval or manual execution' });
482
+ continue;
483
+ }
484
+ const relevant = await probe.detector(root, stack, boundary, options);
485
+ if (relevant) selected.push(probe);
486
+ else skipped.push({ ...probe, reason: 'Not relevant to detected stack or missing configuration' });
487
+ }
488
+ return { selected, skipped, catalogueSize: PROBE_CATALOGUE.length };
489
+ }
@@ -0,0 +1,27 @@
1
+ const VALUE_PATTERNS = [
2
+ /(OPENAI_API_KEY\s*[=:]\s*["']?)([^"'\s;]+)/gi,
3
+ /(SUPABASE_SERVICE_ROLE_KEY\s*[=:]\s*["']?)([^"'\s;]+)/gi,
4
+ /(service_role\s*[=:]\s*["']?)([^"'\s;]+)/gi,
5
+ /(access_token\s*[=:]\s*["']?)([^"'\s;]+)/gi,
6
+ /(refresh_token\s*[=:]\s*["']?)([^"'\s;]+)/gi,
7
+ /(client_secret\s*[=:]\s*["']?)([^"'\s;]+)/gi,
8
+ /(private_key\s*[=:]\s*["']?)([^"'\s;]+)/gi,
9
+ /(webhook_secret\s*[=:]\s*["']?)([^"'\s;]+)/gi,
10
+ /(api_key\s*[=:]\s*["']?)([^"'\s;]+)/gi,
11
+ /(authorization\s*[:=]\s*)([^\n]+)/gi,
12
+ /(bearer\s+)([a-z0-9._~+\/-]+=*)/gi
13
+ ];
14
+
15
+ export function redact(value = '') {
16
+ let output = String(value);
17
+ for (const pattern of VALUE_PATTERNS) output = output.replace(pattern, '$1[REDACTED]');
18
+ output = output.replace(/([A-Za-z0-9_]{12,}\.[A-Za-z0-9_\-]{12,}\.[A-Za-z0-9_\-]{12,})/g, '[REDACTED_TOKEN]');
19
+ return output;
20
+ }
21
+
22
+ export function redactObject(value) {
23
+ if (Array.isArray(value)) return value.map(redactObject);
24
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redactObject(item)]));
25
+ if (typeof value === 'string') return redact(value);
26
+ return value;
27
+ }
@@ -0,0 +1,63 @@
1
+ export const STATUS_ORDER = ['pass', 'not_verified', 'skipped', 'warn', 'fail'];
2
+
3
+ export function worstStatus(statuses = []) {
4
+ let worst = 'pass';
5
+ for (const status of statuses.filter(Boolean)) {
6
+ if (STATUS_ORDER.indexOf(status) > STATUS_ORDER.indexOf(worst)) worst = status;
7
+ }
8
+ return worst;
9
+ }
10
+
11
+ export function createResult(command, status = 'pass', partial = {}) {
12
+ return {
13
+ command,
14
+ status,
15
+ summary: partial.summary || '',
16
+ verified: partial.verified || [],
17
+ warnings: partial.warnings || [],
18
+ failures: partial.failures || [],
19
+ findings: partial.findings || [],
20
+ skipped: partial.skipped || [],
21
+ notVerified: partial.notVerified || [],
22
+ checks: partial.checks || [],
23
+ data: partial.data || {},
24
+ nextSafeStep: partial.nextSafeStep || '',
25
+ startedAt: partial.startedAt || new Date().toISOString(),
26
+ finishedAt: partial.finishedAt || new Date().toISOString()
27
+ };
28
+ }
29
+
30
+ export function createFinding({ status = 'warn', area = 'general', title, finding, evidence = [], whyItMatters = '', nextSafeStep = '' } = {}) {
31
+ return {
32
+ status,
33
+ area,
34
+ title: title || finding || 'Finding',
35
+ finding: finding || title || 'Finding recorded',
36
+ evidence,
37
+ whyItMatters,
38
+ nextSafeStep
39
+ };
40
+ }
41
+
42
+ export function finalizeStatus(result, { strict = false } = {}) {
43
+ if (result.findings?.some((finding) => finding.status === 'fail') && !result.failures?.length) {
44
+ result.failures = result.findings.filter((finding) => finding.status === 'fail').map((finding) => finding.finding);
45
+ }
46
+ if (result.findings?.some((finding) => finding.status === 'warn') && !result.warnings?.length) {
47
+ result.warnings = result.findings.filter((finding) => finding.status === 'warn').map((finding) => finding.finding);
48
+ }
49
+ if (result.failures?.length) result.status = 'fail';
50
+ else if (result.warnings?.length) result.status = strict ? 'fail' : 'warn';
51
+ else if (result.verified?.length || result.checks?.some((check) => check.status === 'pass')) result.status = 'pass';
52
+ else if (result.skipped?.length) result.status = 'skipped';
53
+ else result.status = result.status || 'not_verified';
54
+ result.finishedAt = new Date().toISOString();
55
+ return result;
56
+ }
57
+
58
+ export function exitCodeFor(result, { strict = false } = {}) {
59
+ if (!result) return 1;
60
+ if (result.status === 'fail') return 1;
61
+ if (strict && result.status === 'warn') return 1;
62
+ return 0;
63
+ }
@@ -0,0 +1,53 @@
1
+ import path from 'node:path';
2
+ import { walkFiles, readText, readJson, pathExists } from './fs.js';
3
+ import { redact } from './redact.js';
4
+ import { mergeIgnores } from './boundary.js';
5
+
6
+ export const DEFAULT_SKIP_DIRS = mergeIgnores();
7
+ export const RISK_PATTERNS = [/OPENAI_API_KEY/i, /SUPABASE_SERVICE_ROLE_KEY/i, /service_role/i, /access_token/i, /refresh_token/i, /client_secret/i, /private_key/i, /webhook_secret/i, /api_key/i, /bearer/i, /authorization/i];
8
+ const OPSTRUTH_SCANNER_FILES = new Set([
9
+ 'src/lib/redact.js',
10
+ 'src/lib/scan.js',
11
+ 'src/lib/probes.js',
12
+ 'test/typescript-compatibility.test.js',
13
+ 'fixtures/risky-secret-app/src/config.js'
14
+ ]);
15
+
16
+ export function isLikelyText(file) { return /\.(js|mjs|cjs|ts|tsx|jsx|json|jsonc|toml|yml|yaml|md|txt|env|sql|html|css)$/i.test(file) || !path.extname(file); }
17
+
18
+ async function isOpstruthRoot(root) {
19
+ const packageFile = path.join(root, 'package.json');
20
+ if (!(await pathExists(packageFile))) return false;
21
+ try { return (await readJson(packageFile)).name === 'opstruth'; } catch { return false; }
22
+ }
23
+
24
+ export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS } = {}) {
25
+ const files = await walkFiles(root, { skipDirs: mergeIgnores(skipDirs) });
26
+ const findings = [];
27
+ const suppressInternalScannerDefinitions = await isOpstruthRoot(root);
28
+ for (const file of files) {
29
+ if (suppressInternalScannerDefinitions && OPSTRUTH_SCANNER_FILES.has(file.rel)) continue;
30
+ if (!isLikelyText(file.rel)) continue;
31
+ if (path.basename(file.rel).startsWith('.env')) continue;
32
+ let text = '';
33
+ try { text = await readText(file.full); } catch { continue; }
34
+ const lines = text.split(/\r?\n/);
35
+ lines.forEach((line, index) => {
36
+ for (const pattern of RISK_PATTERNS) {
37
+ if (pattern.test(line)) {
38
+ findings.push({
39
+ file: file.rel,
40
+ line: index + 1,
41
+ pattern: pattern.source.replaceAll('\\', ''),
42
+ match: pattern.source.replaceAll('\\', ''),
43
+ preview: redact(line.trim()).slice(0, 160),
44
+ excerpt: redact(line.trim()).slice(0, 160),
45
+ severity: 'review'
46
+ });
47
+ break;
48
+ }
49
+ }
50
+ });
51
+ }
52
+ return findings;
53
+ }