opstruth 0.1.2 → 0.1.3
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 +17 -2
- package/package.json +1 -1
- package/src/cli.js +64 -39
- package/src/commands/local.js +7 -1
- package/src/commands/probes.js +33 -15
- package/src/commands/routes.js +4 -4
- package/src/commands/secrets.js +12 -4
- package/src/lib/config.js +41 -3
- package/src/lib/markdown.js +21 -0
- package/src/lib/probes.js +44 -2
- package/src/lib/scan.js +64 -3
- package/src/orchestrator.js +25 -12
package/README.md
CHANGED
|
@@ -6,13 +6,15 @@ Read-only operational truth checks for AI-assisted engineering workflows.
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Current published package:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm install -g opstruth
|
|
13
13
|
opstruth
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
The public npm package is `opstruth@0.1.2`.
|
|
17
|
+
|
|
16
18
|
One-off usage:
|
|
17
19
|
|
|
18
20
|
```bash
|
|
@@ -24,17 +26,20 @@ npx opstruth
|
|
|
24
26
|
```bash
|
|
25
27
|
opstruth
|
|
26
28
|
opstruth welcome
|
|
29
|
+
opstruth init --yes
|
|
27
30
|
opstruth probes
|
|
28
31
|
opstruth secrets
|
|
29
32
|
opstruth routes --base-url https://example.com
|
|
30
33
|
opstruth local --port 3000 --health /health
|
|
34
|
+
opstruth --json
|
|
35
|
+
opstruth --no-color
|
|
31
36
|
```
|
|
32
37
|
|
|
33
38
|
## Terminal Output
|
|
34
39
|
|
|
35
40
|
Human output uses a restrained colour theme for status, warnings, proof gaps, evidence, and next safe steps when the terminal supports ANSI colour.
|
|
36
41
|
|
|
37
|
-
Use `--no-color` or `NO_COLOR=1` to disable colour. Use `--color` to force colour for demos. JSON output remains machine-readable and does not include ANSI codes.
|
|
42
|
+
Use `--no-color` or `NO_COLOR=1` to disable colour. Use `--color` to force colour for demos. JSON output remains machine-readable and does not include ANSI codes. Evidence markdown output remains ANSI-free.
|
|
38
43
|
|
|
39
44
|
## Safety Model
|
|
40
45
|
|
|
@@ -42,6 +47,16 @@ opstruth is read-only by default. CLI checks do not deploy, mutate databases, tr
|
|
|
42
47
|
|
|
43
48
|
Skipped checks and not-verified areas are reported as proof gaps instead of being treated as safe.
|
|
44
49
|
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
`opstruth.config.json` can provide route paths, local ports/health paths, and secret allowlists. Generate a starter config:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
opstruth init --yes
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
CLI flags remain the clearest way to provide runtime inputs.
|
|
59
|
+
|
|
45
60
|
## Repository
|
|
46
61
|
|
|
47
62
|
Source, docs, and release evidence live at:
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -115,34 +115,43 @@ function helpText(command) {
|
|
|
115
115
|
' --no-color Disable colour for human terminal output',
|
|
116
116
|
' -h, --help Print help and exit 0'
|
|
117
117
|
];
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
'Usage: opstruth
|
|
121
|
-
'',
|
|
122
|
-
'
|
|
123
|
-
'',
|
|
124
|
-
'
|
|
125
|
-
'
|
|
126
|
-
'
|
|
127
|
-
'
|
|
128
|
-
'
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
118
|
+
const commandHelp = {
|
|
119
|
+
repo: ['Usage: opstruth repo [--json] [--strict]', 'Inspect cwd, git root, branch, latest commit, dirty files, and detected stack.'],
|
|
120
|
+
quality: ['Usage: opstruth quality [--script name] [--continue] [--json]', 'Run only existing safe package scripts; missing scripts and npm placeholder tests are skipped.'],
|
|
121
|
+
routes: ['Usage: opstruth routes --base-url <url> [--routes file] [--json]', 'Collect read-only URL, method, status, latency, redirect, and header evidence.'],
|
|
122
|
+
secrets: ['Usage: opstruth secrets [--json]', 'Scan source text for risky secret/auth references with redacted previews; .env contents are skipped.'],
|
|
123
|
+
supabase: ['Usage: opstruth supabase [--protected-table name] [--frontend-dir dir] [--migrations-dir dir]', 'Run a static Supabase migration/frontend exposure audit without credentials or database calls.'],
|
|
124
|
+
cloudflare: ['Usage: opstruth cloudflare [--url https://example.com] [--json]', 'Inspect Wrangler config, deploy scripts, and optional read-only route status; no deploy is run.'],
|
|
125
|
+
local: ['Usage: opstruth local --port 3000 [--health /health] [--process name] [--service name]', 'Check explicit local runtime inputs without starting, stopping, or killing services.'],
|
|
126
|
+
probes: ['Usage: opstruth probes [--json] [--only area|id] [--skip area|id]', 'Inspect probe catalogue metadata, eligible probes, skipped probes, required inputs, and proof gaps.'],
|
|
127
|
+
evidence: ['Usage: opstruth evidence [--title text] [--out evidence/opstruth.md] [--include file]', 'Write a markdown evidence pack with verified facts, proof gaps, boundaries, and next safe step.'],
|
|
128
|
+
init: ['Usage: opstruth init [--yes]', 'Create a safe starter opstruth.config.json after confirmation.']
|
|
129
|
+
};
|
|
130
|
+
if (commandHelp[command]) {
|
|
131
|
+
const [usage, description] = commandHelp[command];
|
|
132
|
+
return [
|
|
133
|
+
ASCII_HEADER,
|
|
134
|
+
usage,
|
|
135
|
+
'',
|
|
136
|
+
description,
|
|
137
|
+
'',
|
|
138
|
+
'Examples:',
|
|
139
|
+
' opstruth',
|
|
140
|
+
' opstruth --base-url https://example.com',
|
|
141
|
+
' opstruth routes --base-url https://example.com',
|
|
142
|
+
' opstruth local --port 3000 --health /health',
|
|
143
|
+
' opstruth --json',
|
|
144
|
+
' opstruth --no-color',
|
|
145
|
+
' opstruth --color',
|
|
146
|
+
'',
|
|
147
|
+
'Options:',
|
|
148
|
+
' --json Print JSON output',
|
|
149
|
+
' --strict Treat warnings/skips as failing confidence',
|
|
150
|
+
' --color Force colour for human terminal output',
|
|
151
|
+
' --no-color Disable colour for human terminal output',
|
|
152
|
+
' -h, --help Print help and exit 0'
|
|
153
|
+
].join('\n') + '\n';
|
|
154
|
+
}
|
|
146
155
|
return common.join('\n') + '\n';
|
|
147
156
|
}
|
|
148
157
|
|
|
@@ -165,8 +174,17 @@ function welcomeText() {
|
|
|
165
174
|
'- opstruth --strict',
|
|
166
175
|
'- opstruth routes --base-url https://example.com',
|
|
167
176
|
'- opstruth local --port 3000 --health /health',
|
|
177
|
+
'- opstruth --json',
|
|
178
|
+
'- opstruth --no-color',
|
|
179
|
+
'- opstruth --color',
|
|
168
180
|
'- opstruth evidence --title "Release proof"',
|
|
169
181
|
'',
|
|
182
|
+
'Improving confidence:',
|
|
183
|
+
'- provide --base-url or route config when route proof matters',
|
|
184
|
+
'- provide --port and --health when local runtime proof matters',
|
|
185
|
+
'- run inside a git repo for stronger change evidence',
|
|
186
|
+
'- attach evidence/opstruth-report.md to reviews or CI artifacts',
|
|
187
|
+
'',
|
|
170
188
|
'Safety philosophy:',
|
|
171
189
|
'opstruth prefers skipped or not verified over pretending something is safe. Dangerous actions require explicit approval and are not part of the default run.',
|
|
172
190
|
''
|
|
@@ -174,27 +192,34 @@ function welcomeText() {
|
|
|
174
192
|
}
|
|
175
193
|
|
|
176
194
|
const INIT_CONFIG = {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
'strict-transport-security',
|
|
183
|
-
'x-frame-options',
|
|
184
|
-
'referrer-policy'
|
|
185
|
-
]
|
|
186
|
-
},
|
|
195
|
+
projectName: 'example',
|
|
196
|
+
routes: [
|
|
197
|
+
{ path: '/', expectedStatus: 200 },
|
|
198
|
+
{ path: '/health', expectedStatus: 200 }
|
|
199
|
+
],
|
|
187
200
|
local: {
|
|
188
201
|
ports: [],
|
|
189
|
-
|
|
202
|
+
healthPaths: ['/health']
|
|
203
|
+
},
|
|
204
|
+
quality: {
|
|
205
|
+
skipScripts: [],
|
|
206
|
+
requiredScripts: []
|
|
207
|
+
},
|
|
208
|
+
secrets: {
|
|
209
|
+
allowlistPaths: [],
|
|
210
|
+
allowlistPatterns: []
|
|
190
211
|
},
|
|
191
212
|
supabase: {
|
|
213
|
+
enabled: true,
|
|
192
214
|
protectedTables: [
|
|
193
215
|
'agent_jobs',
|
|
194
216
|
'platform_credentials',
|
|
195
217
|
'worker_logs'
|
|
196
218
|
]
|
|
197
219
|
},
|
|
220
|
+
cloudflare: {
|
|
221
|
+
enabled: true
|
|
222
|
+
},
|
|
198
223
|
ignore: [
|
|
199
224
|
'.cache',
|
|
200
225
|
'.agents',
|
package/src/commands/local.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
|
|
2
2
|
import { runCommand } from '../lib/exec.js';
|
|
3
3
|
import { probeUrl } from '../lib/http.js';
|
|
4
|
+
import { loadOpstruthConfig } from '../lib/config.js';
|
|
4
5
|
|
|
5
6
|
export async function runLocal({ cwd = process.cwd(), port = [], health = '/', process: processName, service, strict = false } = {}) {
|
|
6
|
-
const
|
|
7
|
+
const loaded = await loadOpstruthConfig(cwd);
|
|
8
|
+
const configLocal = loaded.config?.local || {};
|
|
9
|
+
const ports = (Array.isArray(port) && port.length ? port : Array.isArray(configLocal.ports) ? configLocal.ports : [port].filter(Boolean));
|
|
10
|
+
const healthPaths = Array.isArray(configLocal.healthPaths) ? configLocal.healthPaths : [configLocal.healthPath].filter(Boolean);
|
|
11
|
+
if ((!health || health === '/') && healthPaths.length) health = healthPaths[0];
|
|
12
|
+
if (loaded.warning && !ports.length && !processName && !service) return createResult('local', 'warn', { warnings: [loaded.warning], notVerified: ['Local runtime liveness'], nextSafeStep: 'Fix opstruth.config.json or pass --port and --health explicitly.' });
|
|
7
13
|
if (!ports.length && !processName && !service) return createResult('local', 'skipped', { skipped: ['Local runtime checks skipped because no --port, --process, or --service was provided'], notVerified: ['Local runtime liveness'], nextSafeStep: 'Run opstruth local --port 3000 --health /health when the app is running.' });
|
|
8
14
|
const checks = [];
|
|
9
15
|
const warnings = [];
|
package/src/commands/probes.js
CHANGED
|
@@ -13,6 +13,29 @@ function summarizeCounts(label, counts) {
|
|
|
13
13
|
return `${label}: ${Object.entries(counts).map(([name, count]) => `${name}=${count}`).join(', ')}`;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function probeJson(probe) {
|
|
17
|
+
return {
|
|
18
|
+
id: probe.id,
|
|
19
|
+
name: probe.name,
|
|
20
|
+
area: probe.area,
|
|
21
|
+
stack: probe.stack,
|
|
22
|
+
mode: probe.mode,
|
|
23
|
+
safetyLevel: probe.safetyLevel,
|
|
24
|
+
defaultMode: probe.defaultMode,
|
|
25
|
+
mutability: probe.mutability,
|
|
26
|
+
inputsRequired: probe.inputsRequired,
|
|
27
|
+
evidenceCollected: probe.evidenceCollected,
|
|
28
|
+
evidenceExpectation: probe.evidenceExpectation,
|
|
29
|
+
proves: probe.proves,
|
|
30
|
+
doesNotProve: probe.doesNotProve,
|
|
31
|
+
proofLimitation: probe.proofLimitation,
|
|
32
|
+
skipReason: probe.skipReason,
|
|
33
|
+
nextSafeStep: probe.nextSafeStep,
|
|
34
|
+
supportedStacks: probe.supportedStacks,
|
|
35
|
+
notVerified: probe.notVerified
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
export async function runProbes({ cwd = process.cwd(), strict = false, skip = [], only = [] } = {}) {
|
|
17
40
|
const boundary = await resolveProjectBoundary(cwd);
|
|
18
41
|
const stack = await detectStack(boundary.root);
|
|
@@ -20,6 +43,7 @@ export async function runProbes({ cwd = process.cwd(), strict = false, skip = []
|
|
|
20
43
|
const byArea = countBy(PROBE_CATALOGUE, 'area');
|
|
21
44
|
const byMode = countBy(PROBE_CATALOGUE, 'defaultMode');
|
|
22
45
|
const bySafety = countBy(PROBE_CATALOGUE, 'safetyLevel');
|
|
46
|
+
const explicitInputProbes = PROBE_CATALOGUE.filter((probe) => probe.inputsRequired?.length);
|
|
23
47
|
const result = createResult('probes', 'pass', {
|
|
24
48
|
summary: 'Probe catalogue inspected for the current project without running mutating actions.',
|
|
25
49
|
verified: [
|
|
@@ -28,19 +52,20 @@ export async function runProbes({ cwd = process.cwd(), strict = false, skip = []
|
|
|
28
52
|
summarizeCounts('Probes by default mode', byMode),
|
|
29
53
|
summarizeCounts('Probes by safety level', bySafety),
|
|
30
54
|
'Detected safe automatic probes for this project: ' + selection.selected.length,
|
|
55
|
+
'Explicit input probes: ' + explicitInputProbes.map((probe) => `${probe.id} (${probe.inputsRequired.join(' + ')})`).join('; '),
|
|
31
56
|
'Project boundary: ' + boundary.root,
|
|
32
57
|
'Detected platforms: ' + (stack.platforms.length ? stack.platforms.join(', ') : 'none')
|
|
33
58
|
],
|
|
34
59
|
skipped: [
|
|
35
60
|
...(boundary.message ? [boundary.message] : []),
|
|
36
|
-
...selection.skipped.slice(0, 20).map((probe) => `${probe.id}: ${probe.reason}`)
|
|
61
|
+
...selection.skipped.slice(0, 20).map((probe) => `${probe.id}: ${probe.reason}; next: ${probe.nextSafeStep}`)
|
|
37
62
|
],
|
|
38
63
|
checks: selection.selected.map((probe) => ({
|
|
39
64
|
name: probe.id,
|
|
40
65
|
status: 'pass',
|
|
41
66
|
message: `${probe.area}/${probe.stack}: ${probe.name}`
|
|
42
67
|
})),
|
|
43
|
-
notVerified: ['Probe catalogue inspection does not run route, local runtime, deploy, or external service checks by itself.'],
|
|
68
|
+
notVerified: ['Probe catalogue inspection does not run route, local runtime, deploy, or external service checks by itself.', 'Skipped probes are proof gaps, not failures.'],
|
|
44
69
|
data: {
|
|
45
70
|
boundary,
|
|
46
71
|
stack,
|
|
@@ -48,19 +73,12 @@ export async function runProbes({ cwd = process.cwd(), strict = false, skip = []
|
|
|
48
73
|
byArea,
|
|
49
74
|
byMode,
|
|
50
75
|
bySafety,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
defaultMode: probe.defaultMode,
|
|
58
|
-
evidenceCollected: probe.evidenceCollected,
|
|
59
|
-
proves: probe.proves,
|
|
60
|
-
doesNotProve: probe.doesNotProve,
|
|
61
|
-
nextSafeStep: probe.nextSafeStep
|
|
62
|
-
})),
|
|
63
|
-
skipped: selection.skipped.map((probe) => ({ id: probe.id, area: probe.area, stack: probe.stack, reason: probe.reason }))
|
|
76
|
+
catalogue: PROBE_CATALOGUE.map(probeJson),
|
|
77
|
+
detected: selection.selected.map(probeJson),
|
|
78
|
+
skipped: selection.skipped.map((probe) => ({
|
|
79
|
+
...probeJson(probe),
|
|
80
|
+
reason: probe.reason
|
|
81
|
+
}))
|
|
64
82
|
},
|
|
65
83
|
nextSafeStep: 'Run opstruth for selected safe probes, or add explicit route/local inputs for stronger runtime evidence.'
|
|
66
84
|
});
|
package/src/commands/routes.js
CHANGED
|
@@ -8,12 +8,12 @@ export async function runRoutes({ cwd = process.cwd(), baseUrl, routesFile, stri
|
|
|
8
8
|
const boundary = await resolveProjectBoundary(cwd);
|
|
9
9
|
cwd = boundary.root;
|
|
10
10
|
let config = routesFile ? await loadRoutesConfig(cwd, routesFile) : null;
|
|
11
|
-
|
|
12
|
-
if (!config) config =
|
|
11
|
+
const defaultConfig = config ? null : await findDefaultRoutesConfig(cwd);
|
|
12
|
+
if (!config) config = defaultConfig?.config;
|
|
13
|
+
if (defaultConfig?.warning) return createResult('routes', 'warn', { warnings: [defaultConfig.warning], notVerified: ['Public route availability'], nextSafeStep: 'Fix opstruth.config.json or pass --routes with valid JSON.' });
|
|
13
14
|
const finalBase = baseUrl || config?.baseUrl;
|
|
14
15
|
if (!finalBase) return createResult('routes', 'skipped', { skipped: ['Route checks skipped because no --base-url or route config was provided'], notVerified: ['Public route availability'], nextSafeStep: 'Run opstruth routes --base-url https://example.com or provide --routes.' });
|
|
15
|
-
const
|
|
16
|
-
const routes = config?.routes?.length ? config.routes : paths.length ? paths : [{ path: '/', method: 'HEAD', expectStatus: [200, 301, 302] }];
|
|
16
|
+
const routes = config?.routes?.length ? config.routes : [{ path: '/', method: 'HEAD', expectStatus: [200, 301, 302] }];
|
|
17
17
|
const requiredHeaders = config?.requiredHeaders || DEFAULT_HEADERS;
|
|
18
18
|
const checks = [];
|
|
19
19
|
const warnings = [];
|
package/src/commands/secrets.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
|
|
2
2
|
import { scanRiskyReferences } from '../lib/scan.js';
|
|
3
3
|
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
4
|
+
import { loadOpstruthConfig } from '../lib/config.js';
|
|
4
5
|
|
|
5
6
|
export async function runSecrets({ cwd = process.cwd(), strict = false } = {}) {
|
|
6
7
|
const boundary = await resolveProjectBoundary(cwd);
|
|
7
|
-
const
|
|
8
|
+
const loaded = await loadOpstruthConfig(boundary.root);
|
|
9
|
+
const secretConfig = loaded.config?.secrets || {};
|
|
10
|
+
const findings = await scanRiskyReferences(boundary.root, {
|
|
11
|
+
allowlistPaths: secretConfig.allowlistPaths || [],
|
|
12
|
+
allowlistPatterns: secretConfig.allowlistPatterns || []
|
|
13
|
+
});
|
|
8
14
|
const findingObjects = findings.map((item) => createFinding({
|
|
9
15
|
status: 'warn',
|
|
10
16
|
area: 'secrets',
|
|
@@ -14,19 +20,21 @@ export async function runSecrets({ cwd = process.cwd(), strict = false } = {}) {
|
|
|
14
20
|
'file: ' + item.file,
|
|
15
21
|
'line: ' + item.line,
|
|
16
22
|
'pattern: ' + item.pattern,
|
|
23
|
+
'kind: ' + item.kind,
|
|
24
|
+
'context: ' + item.context,
|
|
17
25
|
'redacted preview: ' + item.preview
|
|
18
26
|
],
|
|
19
27
|
whyItMatters: 'Secret-like values and service-role references can create account, data, or infrastructure exposure if committed or exposed to browsers.',
|
|
20
28
|
nextSafeStep: 'Confirm whether this is a harmless reference. Move real secrets to secret storage and keep only names/placeholders in source.'
|
|
21
29
|
}));
|
|
22
|
-
const result = createResult('secrets', findings.length ? 'warn' : 'pass', {
|
|
30
|
+
const result = createResult('secrets', loaded.warning || findings.length ? 'warn' : 'pass', {
|
|
23
31
|
summary: 'Redacted risky secret/reference scan completed. .env file contents are skipped.',
|
|
24
32
|
verified: ['Project boundary scanned: ' + boundary.root, 'Source files scanned with redaction', '.env contents were not printed'],
|
|
25
|
-
warnings: findingObjects.map((finding) => finding.finding),
|
|
33
|
+
warnings: [...(loaded.warning ? [loaded.warning] : []), ...findingObjects.map((finding) => finding.finding)],
|
|
26
34
|
findings: findingObjects,
|
|
27
35
|
skipped: boundary.message ? [boundary.message] : [],
|
|
28
36
|
checks: [{ name: 'secret reference scan', status: findings.length ? 'warn' : 'pass', message: findings.length + ' finding(s)' }],
|
|
29
|
-
data: { boundary, findings },
|
|
37
|
+
data: { boundary, configFile: loaded.file, allowlistPaths: secretConfig.allowlistPaths || [], allowlistPatterns: secretConfig.allowlistPatterns || [], findings },
|
|
30
38
|
nextSafeStep: findings.length ? 'Review whether each reference is expected and move real secrets to safe storage.' : 'Keep secrets out of source and rerun before publishing.'
|
|
31
39
|
});
|
|
32
40
|
return finalizeStatus(result, { strict });
|
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/markdown.js
CHANGED
|
@@ -178,9 +178,24 @@ export function evidenceMarkdown({ title = 'opstruth Evidence Pack', status = 'n
|
|
|
178
178
|
'## Check Results',
|
|
179
179
|
list(checks, '- No checks attached', 25),
|
|
180
180
|
'',
|
|
181
|
+
'## Probe Results',
|
|
182
|
+
list(checks, '- No probe results attached', 25),
|
|
183
|
+
'',
|
|
181
184
|
'## Verified Facts',
|
|
182
185
|
list(liveVerification, '- No live verification evidence attached', 20),
|
|
183
186
|
'',
|
|
187
|
+
'## Warnings',
|
|
188
|
+
list(risks.filter((risk) => /warn|warning|review/i.test(risk)), '- None recorded', 20),
|
|
189
|
+
'',
|
|
190
|
+
'## Failures',
|
|
191
|
+
list(risks.filter((risk) => /fail|failure|blocked/i.test(risk)), '- None recorded', 20),
|
|
192
|
+
'',
|
|
193
|
+
'## Skipped / Not Configured',
|
|
194
|
+
'Skipped checks are proof gaps, not failures. See command output for skipped probe IDs and reasons.',
|
|
195
|
+
'',
|
|
196
|
+
'## Not Verified',
|
|
197
|
+
'Production, local runtime, database state, queues, publishing, and external AI usage are not verified unless explicit read-only inputs or external evidence are attached.',
|
|
198
|
+
'',
|
|
184
199
|
'## Risks And Gaps',
|
|
185
200
|
table(['Severity', 'Finding'], riskRows.slice(0, 25)),
|
|
186
201
|
'',
|
|
@@ -196,6 +211,12 @@ export function evidenceMarkdown({ title = 'opstruth Evidence Pack', status = 'n
|
|
|
196
211
|
'## Safety Boundaries',
|
|
197
212
|
list(safetyBoundaries, '- Read-only checks only'),
|
|
198
213
|
'',
|
|
214
|
+
'## Evidence Files / Paths',
|
|
215
|
+
list(scope.concat(filesChanged).filter(Boolean), '- No evidence paths attached', 30),
|
|
216
|
+
'',
|
|
217
|
+
'## Confidence',
|
|
218
|
+
confidenceFor({ status, failures: risks.filter((risk) => /fail|failure|blocked/i.test(risk)), warnings: risks, skipped: [], notVerified: [] }),
|
|
219
|
+
'',
|
|
199
220
|
'## Next Safe Step',
|
|
200
221
|
nextSafeStep || 'Run the narrowest missing read-only verification and attach the result.'
|
|
201
222
|
].join('\n') + '\n';
|
package/src/lib/probes.js
CHANGED
|
@@ -10,7 +10,7 @@ function nodeDependencyDetector(name) {
|
|
|
10
10
|
return async (_root, stack) => stack.dependencies?.includes(name);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
const RAW_PROBE_CATALOGUE = [
|
|
14
14
|
{
|
|
15
15
|
id: 'git.status',
|
|
16
16
|
name: 'Git status',
|
|
@@ -463,6 +463,48 @@ export const PROBE_CATALOGUE = [
|
|
|
463
463
|
}
|
|
464
464
|
];
|
|
465
465
|
|
|
466
|
+
function inputsRequiredFor(probe) {
|
|
467
|
+
if (probe.id.startsWith('routes.')) return ['--base-url or route config'];
|
|
468
|
+
if (probe.id === 'local.ports') return ['--port or local config'];
|
|
469
|
+
if (probe.id === 'local.health') return ['--port and --health or local config'];
|
|
470
|
+
if (probe.id === 'supabase.migrations') return ['supabase/migrations directory'];
|
|
471
|
+
if (probe.id === 'cloudflare.wrangler') return ['wrangler.toml, wrangler.json, or wrangler.jsonc'];
|
|
472
|
+
if (probe.id.startsWith('quality.')) return ['matching package.json script'];
|
|
473
|
+
if (probe.id.startsWith('node.')) return ['matching package metadata/config/source'];
|
|
474
|
+
if (probe.id.startsWith('git.')) return ['git repository'];
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function skipReasonFor(probe) {
|
|
479
|
+
if (probe.id.startsWith('routes.')) return 'Requires --base-url, --routes, or opstruth.config.json route entries';
|
|
480
|
+
if (probe.id === 'local.ports') return 'Requires --port or opstruth.config.json local ports';
|
|
481
|
+
if (probe.id === 'local.health') return 'Requires --port with --health or opstruth.config.json local health paths';
|
|
482
|
+
if (probe.id === 'supabase.migrations') return 'Requires a Supabase migrations directory';
|
|
483
|
+
if (probe.id === 'cloudflare.wrangler') return 'Requires Wrangler configuration';
|
|
484
|
+
if (probe.id.startsWith('quality.')) return 'Requires a matching non-placeholder package.json script';
|
|
485
|
+
if (probe.id.startsWith('git.')) return 'Requires a git repository';
|
|
486
|
+
return 'Not relevant to detected stack or missing configuration';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function normalizeProbe(probe) {
|
|
490
|
+
const inputsRequired = probe.inputsRequired || inputsRequiredFor(probe);
|
|
491
|
+
return {
|
|
492
|
+
...probe,
|
|
493
|
+
mode: probe.mode || probe.defaultMode,
|
|
494
|
+
mutability: probe.mutability || 'none',
|
|
495
|
+
inputsRequired,
|
|
496
|
+
evidenceExpectation: probe.evidenceExpectation || probe.evidenceCollected || [],
|
|
497
|
+
skipReason: probe.skipReason || skipReasonFor(probe),
|
|
498
|
+
proofLimitation: probe.proofLimitation || probe.doesNotProve,
|
|
499
|
+
supportedStacks: probe.supportedStacks || [probe.stack],
|
|
500
|
+
notVerified: probe.notVerified || [probe.doesNotProve],
|
|
501
|
+
falsePositiveRisk: probe.falsePositiveRisk || 'Low to medium; depends on project conventions and fixture/demo content.',
|
|
502
|
+
falseNegativeRisk: probe.falseNegativeRisk || 'Does not prove absence outside scanned files, configured inputs, or supported stack heuristics.'
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export const PROBE_CATALOGUE = RAW_PROBE_CATALOGUE.map(normalizeProbe);
|
|
507
|
+
|
|
466
508
|
export async function selectProbes({ root, stack, boundary, options = {} }) {
|
|
467
509
|
const only = new Set(options.only || []);
|
|
468
510
|
const skip = new Set(options.skip || []);
|
|
@@ -483,7 +525,7 @@ export async function selectProbes({ root, stack, boundary, options = {} }) {
|
|
|
483
525
|
}
|
|
484
526
|
const relevant = await probe.detector(root, stack, boundary, options);
|
|
485
527
|
if (relevant) selected.push(probe);
|
|
486
|
-
else skipped.push({ ...probe, reason: 'Not relevant to detected stack or missing configuration' });
|
|
528
|
+
else skipped.push({ ...probe, reason: probe.skipReason || 'Not relevant to detected stack or missing configuration' });
|
|
487
529
|
}
|
|
488
530
|
return { selected, skipped, catalogueSize: PROBE_CATALOGUE.length };
|
|
489
531
|
}
|
package/src/lib/scan.js
CHANGED
|
@@ -10,21 +10,79 @@ const OPSTRUTH_SCANNER_FILES = new Set([
|
|
|
10
10
|
'src/lib/scan.js',
|
|
11
11
|
'src/lib/probes.js',
|
|
12
12
|
'test/typescript-compatibility.test.js',
|
|
13
|
+
'cli/src/lib/redact.js',
|
|
14
|
+
'cli/src/lib/scan.js',
|
|
15
|
+
'cli/src/lib/probes.js',
|
|
16
|
+
'cli/test/typescript-compatibility.test.js',
|
|
13
17
|
'fixtures/risky-secret-app/src/config.js'
|
|
14
18
|
]);
|
|
19
|
+
const FIXTURE_PACKAGE_NAMES = new Set([
|
|
20
|
+
'plain-node-app',
|
|
21
|
+
'vite-react-app',
|
|
22
|
+
'next-app',
|
|
23
|
+
'tanstack-app',
|
|
24
|
+
'cloudflare-worker-app',
|
|
25
|
+
'supabase-app',
|
|
26
|
+
'default-npm-placeholder-test',
|
|
27
|
+
'failing-real-test-script',
|
|
28
|
+
'risky-secret-app',
|
|
29
|
+
'missing-build-script',
|
|
30
|
+
'route-config-app'
|
|
31
|
+
]);
|
|
15
32
|
|
|
16
33
|
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
34
|
|
|
35
|
+
function matchesAllowlist(file, line, { allowlistPaths = [], allowlistPatterns = [] } = {}) {
|
|
36
|
+
if (allowlistPaths.some((item) => file === item || file.startsWith(item.replace(/\/$/, '') + '/'))) return true;
|
|
37
|
+
return allowlistPatterns.some((pattern) => {
|
|
38
|
+
try { return new RegExp(pattern).test(line) || new RegExp(pattern).test(file); } catch { return false; }
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function classifySecretLine(line) {
|
|
43
|
+
if (/[=:]\s*["']?[^"'\s;]+/.test(line)) return 'secret-like value';
|
|
44
|
+
return 'secret reference';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function classifySourceContext(file, rootContext = 'source file') {
|
|
48
|
+
if (file.startsWith('fixtures/') || file.startsWith('cli/fixtures/')) return 'fixture/demo file';
|
|
49
|
+
if (file.startsWith('docs/') || file.startsWith('cli/docs/')) return 'documentation reference';
|
|
50
|
+
return rootContext;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function classifyRootContext(root) {
|
|
54
|
+
const packageFile = path.join(root, 'package.json');
|
|
55
|
+
if (!(await pathExists(packageFile))) return 'source file';
|
|
56
|
+
try {
|
|
57
|
+
const name = (await readJson(packageFile)).name;
|
|
58
|
+
return FIXTURE_PACKAGE_NAMES.has(name) ? 'fixture/demo file' : 'source file';
|
|
59
|
+
} catch {
|
|
60
|
+
return 'source file';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
18
64
|
async function isOpstruthRoot(root) {
|
|
19
65
|
const packageFile = path.join(root, 'package.json');
|
|
20
|
-
|
|
21
|
-
try {
|
|
66
|
+
const cliPackageFile = path.join(root, 'cli/package.json');
|
|
67
|
+
try {
|
|
68
|
+
if (await pathExists(packageFile)) {
|
|
69
|
+
const name = (await readJson(packageFile)).name;
|
|
70
|
+
if (name === 'opstruth' || name === 'opstruth-monorepo') return true;
|
|
71
|
+
}
|
|
72
|
+
if (await pathExists(cliPackageFile)) {
|
|
73
|
+
return (await readJson(cliPackageFile)).name === 'opstruth';
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
22
79
|
}
|
|
23
80
|
|
|
24
|
-
export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS } = {}) {
|
|
81
|
+
export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS, allowlistPaths = [], allowlistPatterns = [] } = {}) {
|
|
25
82
|
const files = await walkFiles(root, { skipDirs: mergeIgnores(skipDirs) });
|
|
26
83
|
const findings = [];
|
|
27
84
|
const suppressInternalScannerDefinitions = await isOpstruthRoot(root);
|
|
85
|
+
const rootContext = await classifyRootContext(root);
|
|
28
86
|
for (const file of files) {
|
|
29
87
|
if (suppressInternalScannerDefinitions && OPSTRUTH_SCANNER_FILES.has(file.rel)) continue;
|
|
30
88
|
if (!isLikelyText(file.rel)) continue;
|
|
@@ -33,6 +91,7 @@ export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS }
|
|
|
33
91
|
try { text = await readText(file.full); } catch { continue; }
|
|
34
92
|
const lines = text.split(/\r?\n/);
|
|
35
93
|
lines.forEach((line, index) => {
|
|
94
|
+
if (matchesAllowlist(file.rel, line, { allowlistPaths, allowlistPatterns })) return;
|
|
36
95
|
for (const pattern of RISK_PATTERNS) {
|
|
37
96
|
if (pattern.test(line)) {
|
|
38
97
|
findings.push({
|
|
@@ -40,6 +99,8 @@ export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS }
|
|
|
40
99
|
line: index + 1,
|
|
41
100
|
pattern: pattern.source.replaceAll('\\', ''),
|
|
42
101
|
match: pattern.source.replaceAll('\\', ''),
|
|
102
|
+
kind: classifySecretLine(line),
|
|
103
|
+
context: classifySourceContext(file.rel, rootContext),
|
|
43
104
|
preview: redact(line.trim()).slice(0, 160),
|
|
44
105
|
excerpt: redact(line.trim()).slice(0, 160),
|
|
45
106
|
severity: 'review'
|
package/src/orchestrator.js
CHANGED
|
@@ -25,6 +25,29 @@ function nextStepFor(aggregate) {
|
|
|
25
25
|
return 'Attach the evidence pack to the change or handoff.';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function probeJson(probe) {
|
|
29
|
+
return {
|
|
30
|
+
id: probe.id,
|
|
31
|
+
name: probe.name,
|
|
32
|
+
area: probe.area,
|
|
33
|
+
stack: probe.stack,
|
|
34
|
+
mode: probe.mode,
|
|
35
|
+
safetyLevel: probe.safetyLevel,
|
|
36
|
+
defaultMode: probe.defaultMode,
|
|
37
|
+
mutability: probe.mutability,
|
|
38
|
+
inputsRequired: probe.inputsRequired,
|
|
39
|
+
evidenceCollected: probe.evidenceCollected,
|
|
40
|
+
evidenceExpectation: probe.evidenceExpectation,
|
|
41
|
+
proves: probe.proves,
|
|
42
|
+
doesNotProve: probe.doesNotProve,
|
|
43
|
+
proofLimitation: probe.proofLimitation,
|
|
44
|
+
skipReason: probe.skipReason,
|
|
45
|
+
nextSafeStep: probe.nextSafeStep,
|
|
46
|
+
supportedStacks: probe.supportedStacks,
|
|
47
|
+
notVerified: probe.notVerified
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
export async function runOrchestrator(options = {}) {
|
|
29
52
|
const startCwd = options.cwd || process.cwd();
|
|
30
53
|
const boundary = await resolveProjectBoundary(startCwd);
|
|
@@ -75,18 +98,8 @@ export async function runOrchestrator(options = {}) {
|
|
|
75
98
|
stack,
|
|
76
99
|
probes: {
|
|
77
100
|
catalogueSize: probeSelection.catalogueSize,
|
|
78
|
-
selected: probeSelection.selected.map(
|
|
79
|
-
|
|
80
|
-
name: probe.name,
|
|
81
|
-
area: probe.area,
|
|
82
|
-
stack: probe.stack,
|
|
83
|
-
safetyLevel: probe.safetyLevel,
|
|
84
|
-
defaultMode: probe.defaultMode,
|
|
85
|
-
proves: probe.proves,
|
|
86
|
-
doesNotProve: probe.doesNotProve,
|
|
87
|
-
evidenceCollected: probe.evidenceCollected
|
|
88
|
-
})),
|
|
89
|
-
skipped: probeSelection.skipped.map((probe) => ({ id: probe.id, area: probe.area, stack: probe.stack, reason: probe.reason }))
|
|
101
|
+
selected: probeSelection.selected.map(probeJson),
|
|
102
|
+
skipped: probeSelection.skipped.map((probe) => ({ ...probeJson(probe), reason: probe.reason }))
|
|
90
103
|
},
|
|
91
104
|
childResults
|
|
92
105
|
},
|