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
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/quality.js
CHANGED
|
@@ -4,6 +4,16 @@ import { detectPackageManager, detectPackageScripts } from '../lib/detect.js';
|
|
|
4
4
|
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
5
5
|
|
|
6
6
|
const DEFAULT_SCRIPTS = ['typecheck', 'lint', 'test', 'build', 'ci'];
|
|
7
|
+
const INDIVIDUAL_SCRIPTS = ['lint', 'typecheck', 'test', 'build'];
|
|
8
|
+
const MUTATION_LIKE_SCRIPT = /\b(supabase\s+(?:secrets\s+set|db\s+push|functions\s+deploy)|wrangler\s+deploy|npm\s+publish|pnpm\s+publish|yarn\s+npm\s+publish|bun\s+publish|pg_cron|psql\s|curl\s+.*production)\b/i;
|
|
9
|
+
|
|
10
|
+
export const QUALITY_SIGNALS = [
|
|
11
|
+
{ key: 'lint', script: 'lint', label: 'Lint' },
|
|
12
|
+
{ key: 'typecheck', script: 'typecheck', label: 'Typecheck' },
|
|
13
|
+
{ key: 'tests', script: 'test', label: 'Tests' },
|
|
14
|
+
{ key: 'build', script: 'build', label: 'Build' },
|
|
15
|
+
{ key: 'ci', script: 'ci', label: 'CI' }
|
|
16
|
+
];
|
|
7
17
|
|
|
8
18
|
export function isDefaultPlaceholderTestScript(script = '') {
|
|
9
19
|
return /^echo\s+["']?Error:\s*no test specified["']?\s*(?:&&|;)\s*exit\s+1$/i.test(script.trim());
|
|
@@ -13,29 +23,140 @@ function isRunnableQualityScript(name, script) {
|
|
|
13
23
|
return !(name === 'test' && isDefaultPlaceholderTestScript(script));
|
|
14
24
|
}
|
|
15
25
|
|
|
16
|
-
function
|
|
26
|
+
export function isMutationLikeQualityScript(script = '') {
|
|
27
|
+
return MUTATION_LIKE_SCRIPT.test(script);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function qualityRunnerFor(manager) {
|
|
17
31
|
if (manager === 'pnpm') return ['pnpm', ['run']];
|
|
18
32
|
if (manager === 'yarn') return ['yarn', []];
|
|
19
33
|
if (manager === 'bun') return ['bun', ['run']];
|
|
20
34
|
return ['npm', ['run']];
|
|
21
35
|
}
|
|
22
|
-
|
|
36
|
+
|
|
37
|
+
function signalKeyForScript(script) {
|
|
38
|
+
return script === 'test' ? 'tests' : script;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function emptySignals(availableScripts) {
|
|
42
|
+
return Object.fromEntries(QUALITY_SIGNALS.map(({ key, script, label }) => {
|
|
43
|
+
const rawScript = availableScripts[script];
|
|
44
|
+
const configured = Object.prototype.hasOwnProperty.call(availableScripts, script);
|
|
45
|
+
const runnable = configured && isRunnableQualityScript(script, rawScript);
|
|
46
|
+
const requiresReview = runnable && isMutationLikeQualityScript(rawScript);
|
|
47
|
+
let status = 'not_configured';
|
|
48
|
+
let reason = 'package script not found';
|
|
49
|
+
if (configured && !runnable) {
|
|
50
|
+
status = 'skipped';
|
|
51
|
+
reason = 'default npm placeholder test script';
|
|
52
|
+
} else if (requiresReview) {
|
|
53
|
+
status = 'skipped';
|
|
54
|
+
reason = 'script contains mutation-like command and requires human review';
|
|
55
|
+
} else if (configured) {
|
|
56
|
+
status = 'not_verified';
|
|
57
|
+
reason = 'configured but not executed yet';
|
|
58
|
+
}
|
|
59
|
+
return [key, {
|
|
60
|
+
key,
|
|
61
|
+
label,
|
|
62
|
+
script,
|
|
63
|
+
configured,
|
|
64
|
+
status,
|
|
65
|
+
reason,
|
|
66
|
+
command: rawScript || null,
|
|
67
|
+
proofRoute: null,
|
|
68
|
+
exitCode: null,
|
|
69
|
+
durationMs: null,
|
|
70
|
+
logExcerpt: ''
|
|
71
|
+
}];
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ciCoversScript(ciScript = '', script) {
|
|
76
|
+
const escaped = script.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
77
|
+
if (script === 'test' && /\bnpm\s+test\b|\bpnpm\s+test\b|\bbun\s+test\b|\byarn\s+test\b/.test(ciScript)) return true;
|
|
78
|
+
return new RegExp(`\\b(?:npm|pnpm|bun)\\s+run\\s+${escaped}\\b|\\byarn\\s+${escaped}\\b`).test(ciScript);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function checkStatusForSignal(status) {
|
|
82
|
+
if (status === 'passed') return 'pass';
|
|
83
|
+
if (status === 'failed' || status === 'timed_out') return 'fail';
|
|
84
|
+
if (status === 'skipped' || status === 'not_configured') return 'skipped';
|
|
85
|
+
return 'not_verified';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function signalMessage(signal) {
|
|
89
|
+
const bits = [signal.status];
|
|
90
|
+
if (signal.proofRoute) bits.push('via ' + signal.proofRoute);
|
|
91
|
+
if (signal.reason) bits.push(signal.reason);
|
|
92
|
+
return bits.join('; ');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function statusFromRun(run) {
|
|
96
|
+
if (run.exitCode === 0) return 'passed';
|
|
97
|
+
if (run.exitCode === 124 || run.signal) return 'timed_out';
|
|
98
|
+
return 'failed';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function failureText(script, signal) {
|
|
102
|
+
if (signal.status === 'timed_out') return script + ' timed out';
|
|
103
|
+
return script + ' failed';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function executeScript({ script, signal, runner, baseArgs, cwd, timeoutMs, checks }) {
|
|
107
|
+
const run = await runCommand(runner, [...baseArgs, script], { cwd, timeoutMs });
|
|
108
|
+
const status = statusFromRun(run);
|
|
109
|
+
signal.status = status;
|
|
110
|
+
signal.reason = status === 'passed' ? 'configured script exited 0' : `configured script exited ${run.exitCode}`;
|
|
111
|
+
signal.proofRoute = 'direct';
|
|
112
|
+
signal.exitCode = run.exitCode;
|
|
113
|
+
signal.durationMs = run.durationMs;
|
|
114
|
+
signal.logExcerpt = excerpt((run.stdout || '') + '\n' + (run.stderr || ''));
|
|
115
|
+
checks.push({
|
|
116
|
+
name: 'package script ' + script,
|
|
117
|
+
command: run.command,
|
|
118
|
+
exitCode: run.exitCode,
|
|
119
|
+
durationMs: run.durationMs,
|
|
120
|
+
status: run.exitCode === 0 ? 'pass' : 'fail',
|
|
121
|
+
logExcerpt: signal.logExcerpt
|
|
122
|
+
});
|
|
123
|
+
return run;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function addSignalChecks(checks, signals) {
|
|
127
|
+
for (const signal of Object.values(signals)) {
|
|
128
|
+
checks.push({
|
|
129
|
+
name: 'quality signal ' + signal.key,
|
|
130
|
+
command: signal.proofRoute === 'direct' ? signal.command : null,
|
|
131
|
+
exitCode: signal.exitCode,
|
|
132
|
+
durationMs: signal.durationMs,
|
|
133
|
+
status: checkStatusForSignal(signal.status),
|
|
134
|
+
message: signalMessage(signal),
|
|
135
|
+
logExcerpt: signal.logExcerpt
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function signalSummary(signals) {
|
|
141
|
+
return QUALITY_SIGNALS.map(({ key, label }) => `${label}: ${signals[key].status}${signals[key].proofRoute ? ' via ' + signals[key].proofRoute : ''}`).join('; ');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function runQuality({ cwd = process.cwd(), continueOnFailure = false, strict = false, scripts: wantedScripts, timeoutMs = 180000 } = {}) {
|
|
23
145
|
const boundary = await resolveProjectBoundary(cwd);
|
|
24
146
|
cwd = boundary.root;
|
|
25
147
|
const packageManager = await detectPackageManager(cwd);
|
|
26
148
|
const availableScripts = await detectPackageScripts(cwd);
|
|
27
149
|
const requested = wantedScripts?.length ? wantedScripts : DEFAULT_SCRIPTS;
|
|
28
|
-
const
|
|
29
|
-
const
|
|
150
|
+
const explicitScripts = Boolean(wantedScripts?.length);
|
|
151
|
+
const signals = emptySignals(availableScripts);
|
|
30
152
|
const checks = [];
|
|
31
153
|
const warnings = [];
|
|
32
154
|
const failures = [];
|
|
33
|
-
const skipped =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
});
|
|
155
|
+
const skipped = [];
|
|
156
|
+
const notVerified = [];
|
|
157
|
+
const selected = [];
|
|
158
|
+
const [runner, baseArgs] = qualityRunnerFor(packageManager);
|
|
159
|
+
|
|
39
160
|
if (boundary.message) skipped.push(boundary.message);
|
|
40
161
|
if (boundary.isGitRepo) {
|
|
41
162
|
const diff = await runCommand('git', ['diff', '--check'], { cwd, timeoutMs: 60000 });
|
|
@@ -44,23 +165,94 @@ export async function runQuality({ cwd = process.cwd(), continueOnFailure = fals
|
|
|
44
165
|
} else {
|
|
45
166
|
skipped.push('git diff --check skipped because no git repository was detected');
|
|
46
167
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
168
|
+
|
|
169
|
+
const requestedRunnable = requested.filter((name) => Object.prototype.hasOwnProperty.call(availableScripts, name) && isRunnableQualityScript(name, availableScripts[name]));
|
|
170
|
+
const requestedUnsafe = requestedRunnable.filter((name) => isMutationLikeQualityScript(availableScripts[name]));
|
|
171
|
+
for (const name of requestedUnsafe) warnings.push(`package script ${name} requires review before execution`);
|
|
172
|
+
|
|
173
|
+
let executionStrategy = 'individual';
|
|
174
|
+
const ciScript = availableScripts.ci || '';
|
|
175
|
+
const ciAvailable = requested.includes('ci') && Object.prototype.hasOwnProperty.call(availableScripts, 'ci') && isRunnableQualityScript('ci', ciScript);
|
|
176
|
+
const ciSafe = ciAvailable && !isMutationLikeQualityScript(ciScript);
|
|
177
|
+
if (!explicitScripts && ciSafe) executionStrategy = 'ci';
|
|
178
|
+
if (!explicitScripts && ciAvailable && !ciSafe) executionStrategy = 'individual';
|
|
179
|
+
|
|
180
|
+
if (executionStrategy === 'ci') {
|
|
181
|
+
selected.push('ci');
|
|
182
|
+
const run = await executeScript({ script: 'ci', signal: signals.ci, runner, baseArgs, cwd, timeoutMs, checks });
|
|
183
|
+
for (const script of INDIVIDUAL_SCRIPTS) {
|
|
184
|
+
const signal = signals[signalKeyForScript(script)];
|
|
185
|
+
if (!signal.configured || signal.status === 'skipped') continue;
|
|
186
|
+
if (ciCoversScript(ciScript, script)) {
|
|
187
|
+
signal.status = run.exitCode === 0 ? 'passed' : 'not_verified';
|
|
188
|
+
signal.reason = run.exitCode === 0 ? 'covered by configured ci script' : 'ci did not pass, so individual result is not verified';
|
|
189
|
+
signal.proofRoute = 'ci';
|
|
190
|
+
signal.exitCode = run.exitCode === 0 ? 0 : null;
|
|
191
|
+
signal.durationMs = run.exitCode === 0 ? run.durationMs : null;
|
|
192
|
+
} else {
|
|
193
|
+
signal.status = 'not_verified';
|
|
194
|
+
signal.reason = 'configured script was not run because ci strategy did not explicitly cover it';
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (run.exitCode !== 0) failures.push(failureText('ci', signals.ci));
|
|
198
|
+
} else {
|
|
199
|
+
const scriptsToRun = requested.filter((name) => name !== 'ci' || explicitScripts).filter((name) => {
|
|
200
|
+
const raw = availableScripts[name];
|
|
201
|
+
if (!Object.prototype.hasOwnProperty.call(availableScripts, name)) return false;
|
|
202
|
+
if (!isRunnableQualityScript(name, raw)) return false;
|
|
203
|
+
if (isMutationLikeQualityScript(raw)) return false;
|
|
204
|
+
return QUALITY_SIGNALS.some((signal) => signal.script === name);
|
|
205
|
+
});
|
|
206
|
+
for (const script of scriptsToRun) {
|
|
207
|
+
if (failures.length && !continueOnFailure) break;
|
|
208
|
+
selected.push(script);
|
|
209
|
+
const signal = signals[signalKeyForScript(script)];
|
|
210
|
+
await executeScript({ script, signal, runner, baseArgs, cwd, timeoutMs, checks });
|
|
211
|
+
if (signal.status === 'failed' || signal.status === 'timed_out') failures.push(failureText(script, signal));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
for (const signal of Object.values(signals)) {
|
|
216
|
+
if (signal.status === 'not_configured') skipped.push(`package script not found, skipped: ${signal.script}`);
|
|
217
|
+
if (signal.status === 'skipped') skipped.push(`${signal.script} skipped: ${signal.reason}`);
|
|
218
|
+
if (signal.status === 'not_verified' && signal.configured) notVerified.push(`${signal.script} configured but not verified: ${signal.reason}`);
|
|
53
219
|
}
|
|
54
220
|
if (!Object.keys(availableScripts).length) skipped.push('No package.json scripts detected');
|
|
221
|
+
|
|
222
|
+
addSignalChecks(checks, signals);
|
|
223
|
+
|
|
224
|
+
const skippedScripts = Object.values(signals)
|
|
225
|
+
.filter((signal) => ['not_configured', 'skipped'].includes(signal.status))
|
|
226
|
+
.map((signal) => signal.script);
|
|
227
|
+
|
|
55
228
|
const result = createResult('quality', failures.length ? 'fail' : warnings.length ? 'warn' : 'pass', {
|
|
56
|
-
summary:
|
|
57
|
-
verified: [
|
|
229
|
+
summary: `Safe local quality gates reported distinct proof signals. Execution strategy: ${executionStrategy}.`,
|
|
230
|
+
verified: [
|
|
231
|
+
boundary.isGitRepo ? 'git diff --check executed' : 'git diff --check skipped outside git repository',
|
|
232
|
+
'package.json scripts inspected',
|
|
233
|
+
'Quality proof signals: ' + signalSummary(signals),
|
|
234
|
+
'Existing quality scripts selected: ' + (selected.length ? selected.join(', ') : 'none')
|
|
235
|
+
],
|
|
58
236
|
warnings,
|
|
59
237
|
failures,
|
|
60
238
|
skipped,
|
|
239
|
+
notVerified,
|
|
61
240
|
checks,
|
|
62
|
-
data: {
|
|
63
|
-
|
|
241
|
+
data: {
|
|
242
|
+
boundary,
|
|
243
|
+
packageManager,
|
|
244
|
+
availableScripts,
|
|
245
|
+
requestedScripts: requested,
|
|
246
|
+
selectedScripts: selected,
|
|
247
|
+
skippedScripts,
|
|
248
|
+
quality: {
|
|
249
|
+
executionStrategy,
|
|
250
|
+
runner,
|
|
251
|
+
baseArgs,
|
|
252
|
+
signals
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
nextSafeStep: failures.length ? 'Fix the failing quality signal and rerun opstruth quality.' : 'Attach quality signal output to the evidence pack.'
|
|
64
256
|
});
|
|
65
257
|
return finalizeStatus(result, { strict });
|
|
66
258
|
}
|
package/src/commands/routes.js
CHANGED
|
@@ -4,21 +4,54 @@ import { loadRoutesConfig, findDefaultRoutesConfig } from '../lib/config.js';
|
|
|
4
4
|
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
5
5
|
|
|
6
6
|
const DEFAULT_HEADERS = ['content-security-policy', 'strict-transport-security', 'x-frame-options', 'referrer-policy'];
|
|
7
|
+
|
|
8
|
+
export function isLocalRouteUrl(value) {
|
|
9
|
+
try {
|
|
10
|
+
const hostname = new URL(value).hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
11
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildMissingHeaderFinding({ url, routePath, missingHeaders = [], evidence = [] } = {}) {
|
|
18
|
+
if (!missingHeaders.length) return null;
|
|
19
|
+
const localPreview = isLocalRouteUrl(url);
|
|
20
|
+
const finding = localPreview
|
|
21
|
+
? `${routePath} local preview missing headers: ${missingHeaders.join(', ')}`
|
|
22
|
+
: `${routePath} missing headers: ${missingHeaders.join(', ')}`;
|
|
23
|
+
|
|
24
|
+
return createFinding({
|
|
25
|
+
status: 'warn',
|
|
26
|
+
area: 'routes',
|
|
27
|
+
title: localPreview ? 'Local preview security headers missing' : 'Route security headers missing',
|
|
28
|
+
finding,
|
|
29
|
+
evidence,
|
|
30
|
+
whyItMatters: localPreview
|
|
31
|
+
? 'The local preview response does not include the expected security headers. Local development and preview servers may differ from the deployed hosting layer. Production headers remain Not Verified until a production URL is checked.'
|
|
32
|
+
: 'Missing browser security headers can weaken runtime protection even when the checked route is available.',
|
|
33
|
+
nextSafeStep: localPreview
|
|
34
|
+
? 'Check an explicitly supplied production URL before drawing production conclusions, and configure local preview headers only when they are useful for parity.'
|
|
35
|
+
: 'Add the missing headers in the app or hosting layer and rerun route probes.'
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
7
39
|
export async function runRoutes({ cwd = process.cwd(), baseUrl, routesFile, strict = false } = {}) {
|
|
8
40
|
const boundary = await resolveProjectBoundary(cwd);
|
|
9
41
|
cwd = boundary.root;
|
|
10
42
|
let config = routesFile ? await loadRoutesConfig(cwd, routesFile) : null;
|
|
11
|
-
|
|
12
|
-
if (!config) config =
|
|
43
|
+
const defaultConfig = config ? null : await findDefaultRoutesConfig(cwd);
|
|
44
|
+
if (!config) config = defaultConfig?.config;
|
|
45
|
+
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
46
|
const finalBase = baseUrl || config?.baseUrl;
|
|
14
47
|
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] }];
|
|
48
|
+
const routes = config?.routes?.length ? config.routes : [{ path: '/', method: 'HEAD', expectStatus: [200, 301, 302] }];
|
|
17
49
|
const requiredHeaders = config?.requiredHeaders || DEFAULT_HEADERS;
|
|
18
50
|
const checks = [];
|
|
19
51
|
const warnings = [];
|
|
20
52
|
const failures = [];
|
|
21
53
|
const findings = [];
|
|
54
|
+
const localPreview = isLocalRouteUrl(finalBase);
|
|
22
55
|
for (const route of routes) {
|
|
23
56
|
const url = new URL(route.path, finalBase).toString();
|
|
24
57
|
const probe = await probeUrl(url, { method: route.method || 'HEAD' });
|
|
@@ -38,12 +71,30 @@ export async function runRoutes({ cwd = process.cwd(), baseUrl, routesFile, stri
|
|
|
38
71
|
failures.push(message);
|
|
39
72
|
findings.push(createFinding({ status: 'fail', area: 'routes', title: 'Route status did not match expectation', finding: message, evidence, whyItMatters: 'A route that fails a read-only smoke check may block users or downstream health checks.', nextSafeStep: 'Inspect the route and rerun without deploying from opstruth.' }));
|
|
40
73
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
warnings.push(
|
|
44
|
-
findings.push(
|
|
74
|
+
const headerFinding = buildMissingHeaderFinding({ url, routePath: route.path, missingHeaders, evidence });
|
|
75
|
+
if (headerFinding) {
|
|
76
|
+
warnings.push(headerFinding.finding);
|
|
77
|
+
findings.push(headerFinding);
|
|
45
78
|
}
|
|
46
79
|
checks.push({ name: `${route.method || 'HEAD'} ${route.path}`, status: okStatus ? missingHeaders.length ? 'warn' : 'pass' : 'fail', message: `status=${probe.status || 'error'} latency=${probe.latencyMs}ms`, data: probe });
|
|
47
80
|
}
|
|
48
|
-
return finalizeStatus(createResult('routes', failures.length ? 'fail' : warnings.length ? 'warn' : 'pass', {
|
|
81
|
+
return finalizeStatus(createResult('routes', failures.length ? 'fail' : warnings.length ? 'warn' : 'pass', {
|
|
82
|
+
summary: localPreview ? 'HEAD/GET local preview route smoke matrix completed.' : 'HEAD/GET public route smoke matrix completed.',
|
|
83
|
+
verified: [
|
|
84
|
+
'Project boundary: ' + boundary.root,
|
|
85
|
+
localPreview ? 'Local preview routes checked with read-only HEAD/GET requests' : 'Routes checked with read-only HEAD/GET requests',
|
|
86
|
+
'Response status, redirects, headers, and latency captured'
|
|
87
|
+
],
|
|
88
|
+
warnings,
|
|
89
|
+
failures,
|
|
90
|
+
findings,
|
|
91
|
+
checks,
|
|
92
|
+
notVerified: localPreview ? ['Production security headers were not checked because only a local preview URL was probed'] : [],
|
|
93
|
+
data: { boundary, baseUrl: finalBase, routes, requiredHeaders, scope: localPreview ? 'local_preview' : 'remote' },
|
|
94
|
+
nextSafeStep: failures.length
|
|
95
|
+
? 'Investigate failing routes without deploying from opstruth.'
|
|
96
|
+
: localPreview
|
|
97
|
+
? 'Check an explicit production URL to verify deployed security headers.'
|
|
98
|
+
: 'Add missing required headers or attach route evidence.'
|
|
99
|
+
}), { strict });
|
|
49
100
|
}
|
package/src/commands/secrets.js
CHANGED
|
@@ -1,33 +1,58 @@
|
|
|
1
1
|
import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
|
|
2
|
-
import {
|
|
2
|
+
import { formatSecretSummary, scanRiskyReferencesDetailed } from '../lib/scan.js';
|
|
3
3
|
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
4
|
+
import { loadOpstruthConfig } from '../lib/config.js';
|
|
5
|
+
|
|
6
|
+
function secretFindingTitle(item) {
|
|
7
|
+
if (item.category === 'actionable_source_finding') return 'Actionable secret finding';
|
|
8
|
+
if (item.category === 'unknown_requires_review') return 'Unknown token-like content';
|
|
9
|
+
return 'Secret/reference classification';
|
|
10
|
+
}
|
|
4
11
|
|
|
5
12
|
export async function runSecrets({ cwd = process.cwd(), strict = false } = {}) {
|
|
6
13
|
const boundary = await resolveProjectBoundary(cwd);
|
|
7
|
-
const
|
|
8
|
-
const
|
|
14
|
+
const loaded = await loadOpstruthConfig(boundary.root);
|
|
15
|
+
const secretConfig = loaded.config?.secrets || {};
|
|
16
|
+
const scan = await scanRiskyReferencesDetailed(boundary.root, {
|
|
17
|
+
allowlistPaths: secretConfig.allowlistPaths || [],
|
|
18
|
+
allowlistPatterns: secretConfig.allowlistPatterns || []
|
|
19
|
+
});
|
|
20
|
+
const findingObjects = scan.findings.map((item) => createFinding({
|
|
9
21
|
status: 'warn',
|
|
10
22
|
area: 'secrets',
|
|
11
|
-
title:
|
|
23
|
+
title: secretFindingTitle(item),
|
|
12
24
|
finding: `${item.file}:${item.line} matched ${item.pattern}`,
|
|
13
25
|
evidence: [
|
|
14
26
|
'file: ' + item.file,
|
|
15
27
|
'line: ' + item.line,
|
|
16
28
|
'pattern: ' + item.pattern,
|
|
29
|
+
'category: ' + item.category,
|
|
30
|
+
'severity: ' + item.severity,
|
|
31
|
+
'kind: ' + item.kind,
|
|
32
|
+
'context: ' + item.context,
|
|
17
33
|
'redacted preview: ' + item.preview
|
|
18
34
|
],
|
|
19
35
|
whyItMatters: 'Secret-like values and service-role references can create account, data, or infrastructure exposure if committed or exposed to browsers.',
|
|
20
|
-
nextSafeStep: '
|
|
36
|
+
nextSafeStep: 'Review actionable and unknown findings first. Move real secrets to secret storage and keep only names/placeholders in source.'
|
|
21
37
|
}));
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
38
|
+
const secretSummary = formatSecretSummary(scan.summary);
|
|
39
|
+
const result = createResult('secrets', loaded.warning || scan.findings.length ? 'warn' : 'pass', {
|
|
40
|
+
summary: 'Redacted secret/reference scan completed with grouped categories. .env file contents are skipped unless tracked.',
|
|
41
|
+
verified: ['Project boundary scanned: ' + boundary.root, 'Source files scanned with redaction', '.env contents were not printed', 'Secret references grouped: ' + secretSummary],
|
|
42
|
+
warnings: [...(loaded.warning ? [loaded.warning] : []), ...findingObjects.map((finding) => finding.finding)],
|
|
26
43
|
findings: findingObjects,
|
|
27
44
|
skipped: boundary.message ? [boundary.message] : [],
|
|
28
|
-
checks: [{ name: 'secret reference scan', status: findings.length ? 'warn' : 'pass', message:
|
|
29
|
-
data: {
|
|
30
|
-
|
|
45
|
+
checks: [{ name: 'secret reference scan', status: scan.findings.length ? 'warn' : 'pass', message: secretSummary }],
|
|
46
|
+
data: {
|
|
47
|
+
boundary,
|
|
48
|
+
configFile: loaded.file,
|
|
49
|
+
allowlistPaths: secretConfig.allowlistPaths || [],
|
|
50
|
+
allowlistPatterns: secretConfig.allowlistPatterns || [],
|
|
51
|
+
secretSummary: scan.summary,
|
|
52
|
+
classifiedFindings: scan.records,
|
|
53
|
+
findings: scan.findings
|
|
54
|
+
},
|
|
55
|
+
nextSafeStep: scan.findings.length ? 'Review actionable and unknown findings first; treat documentation references and placeholders as context unless they contain real values.' : 'Keep secrets out of source and rerun before publishing.'
|
|
31
56
|
});
|
|
32
57
|
return finalizeStatus(result, { strict });
|
|
33
58
|
}
|