bootproof 0.3.0 → 0.4.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.
- package/README.md +844 -152
- package/dist/agent-plan.d.ts +44 -0
- package/dist/agent-plan.js +826 -0
- package/dist/agent-run.d.ts +117 -0
- package/dist/agent-run.js +459 -0
- package/dist/ai-repair.d.ts +58 -0
- package/dist/ai-repair.js +380 -0
- package/dist/cli.js +730 -46
- package/dist/diagnosis.js +101 -16
- package/dist/diff.d.ts +29 -0
- package/dist/diff.js +569 -0
- package/dist/exec.d.ts +30 -2
- package/dist/exec.js +329 -51
- package/dist/external-health.d.ts +16 -0
- package/dist/external-health.js +214 -0
- package/dist/infer.js +238 -39
- package/dist/plan.js +2 -0
- package/dist/proof.d.ts +78 -2
- package/dist/proof.js +265 -12
- package/dist/receipt.d.ts +52 -0
- package/dist/receipt.js +356 -0
- package/dist/redact.d.ts +4 -0
- package/dist/redact.js +86 -2
- package/dist/registry.d.ts +82 -30
- package/dist/registry.js +355 -53
- package/dist/remote.js +3 -3
- package/dist/repair-playbooks.d.ts +24 -0
- package/dist/repair-playbooks.js +593 -0
- package/dist/repair-safety.d.ts +130 -0
- package/dist/repair-safety.js +766 -0
- package/dist/repair.d.ts +43 -11
- package/dist/repair.js +716 -7
- package/dist/run.d.ts +3 -0
- package/dist/run.js +218 -41
- package/dist/sbom.d.ts +22 -0
- package/dist/sbom.js +99 -0
- package/dist/taxonomy.d.ts +8 -3
- package/dist/taxonomy.js +404 -8
- package/dist/types.d.ts +40 -1
- package/docs/AGENT_IN_THE_LOOP.md +171 -0
- package/docs/AGENT_RUN_RECEIPTS.md +38 -0
- package/docs/CI_ACTION.md +67 -2
- package/docs/DETERMINISTIC_REPAIR_SAFETY_MODEL.md +705 -0
- package/docs/DISTRIBUTION.md +83 -0
- package/docs/FAILURE_TAXONOMY.md +28 -1
- package/docs/HONESTY_CONTRACT.md +34 -12
- package/docs/LAUNCH_PLAYBOOK.md +232 -0
- package/docs/REAL_WORLD_FIXTURES.md +105 -0
- package/docs/REGISTRY.md +48 -28
- package/docs/REPAIR_RECEIPT.md +54 -8
- package/docs/agent-loop-gap-analysis.md +188 -0
- package/docs/examples/registry-seeds/advertised-port-mismatch.json +28 -0
- package/docs/examples/registry-seeds/airbyte-abctl-external-orchestrator.json +36 -0
- package/docs/examples/registry-seeds/go-ollama-service.json +36 -0
- package/docs/examples/registry-seeds/laravel-vite-sqlite.json +36 -0
- package/docs/examples/registry-seeds/monorepo-ambiguous-health.json +29 -0
- package/docs/examples/registry-seeds/php-composer.json +33 -0
- package/docs/examples/registry-seeds/rails-bundler.json +32 -0
- package/docs/examples/registry-seeds/sentry-devenv-direnv.json +41 -0
- package/docs/schemas/action-verdict-v1.schema.json +64 -0
- package/docs/schemas/agent-plan-v1.schema.json +148 -0
- package/docs/schemas/agent-run-receipts-v1.schema.json +192 -0
- package/docs/schemas/ai-repair-suggestion-v1.schema.json +70 -0
- package/docs/schemas/ci-context-v1.schema.json +63 -0
- package/docs/schemas/diff-result-v1.schema.json +66 -0
- package/docs/schemas/federated-receipt-v1.schema.json +51 -0
- package/docs/schemas/registry-entry-v1.schema.json +95 -0
- package/docs/schemas/registry-seed-example-v1.schema.json +102 -0
- package/docs/schemas/repair-action-v1.schema.json +136 -0
- package/docs/schemas/repair-receipt-v1.schema.json +221 -0
- package/package.json +21 -11
package/dist/exec.js
CHANGED
|
@@ -1,26 +1,154 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import http from "node:http";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import { redactText } from "./redact.js";
|
|
5
|
+
const EVIDENCE_LIMIT = 4000;
|
|
6
|
+
const head = (s) => (s.length > EVIDENCE_LIMIT ? s.slice(0, EVIDENCE_LIMIT) : s);
|
|
7
|
+
const tail = (s) => (s.length > EVIDENCE_LIMIT ? s.slice(-EVIDENCE_LIMIT) : s);
|
|
8
|
+
function meaningfulLines(evidenceHead, evidenceTail) {
|
|
9
|
+
return `${evidenceHead}\n${evidenceTail}`
|
|
10
|
+
.split(/\r?\n/)
|
|
11
|
+
.map(line => line.trim())
|
|
12
|
+
.filter(line => line.length > 0 &&
|
|
13
|
+
!/^(?:from\s+)?\S+:\d+(?::in\b|$)/i.test(line) &&
|
|
14
|
+
!/^at\s+\S+/i.test(line) &&
|
|
15
|
+
!/^#\d+\s+/i.test(line));
|
|
16
|
+
}
|
|
17
|
+
function detectCause(text) {
|
|
18
|
+
const checks = [
|
|
19
|
+
[/(?:missing|no such file|does not exist|could not find)[^\n]*config\/database\.yml|config\/database\.yml[^\n]*(?:missing|no such file|does not exist|could not find)/i, "missing config/database.yml"],
|
|
20
|
+
[/(?:missing|no such file|does not exist|could not find)[^\n]*config\/gitlab\.yml|config\/gitlab\.yml[^\n]*(?:missing|no such file|does not exist|could not find)/i, "missing config/gitlab.yml"],
|
|
21
|
+
[/(?:PG::ConnectionBad|postgres(?:ql)?|port 5432)[^\n]*(?:connection refused|could not connect)|(?:connection refused|could not connect)[^\n]*(?:postgres(?:ql)?|port 5432)/i, "PostgreSQL connection refused"],
|
|
22
|
+
[/(?:postgres(?:ql)?[^\n]*)?role\s+["']?[^"'\n]+["']?\s+does not exist/i, "PostgreSQL role missing"],
|
|
23
|
+
[/(?:database schema|relation\s+\S+\s+does not exist|no such table|pending migrations?)/i, "database schema missing"],
|
|
24
|
+
[/(?:unsupported|not supported)[^\n]*(?:postgres(?:ql)?|database)[^\n]*version|(?:postgres(?:ql)?|database)[^\n]*version[^\n]*(?:unsupported|not supported)/i, "unsupported database version"],
|
|
25
|
+
[/(?:unsupported|not supported)[^\n]*database (?:config|configuration)|database (?:config|configuration)[^\n]*(?:unsupported|not supported)/i, "unsupported database configuration"],
|
|
26
|
+
];
|
|
27
|
+
return checks.find(([pattern]) => pattern.test(text))?.[1];
|
|
28
|
+
}
|
|
29
|
+
export function extractProcessEvidence(evidenceHead, evidenceTail) {
|
|
30
|
+
const lines = meaningfulLines(evidenceHead, evidenceTail);
|
|
31
|
+
const firstExceptionLine = lines.find(line => /\b(?:[A-Z]\w*(?:::[A-Z]\w*)*(?:Error|Exception)|PG::\w+|ActiveRecord::\w+|Errno::\w+|RuntimeError|LoadError|NameError|NoMethodError)\b/.test(line));
|
|
32
|
+
const firstErrorLine = lines.find(line => /\b(?:error|fatal|failed|failure|refused|missing|unsupported|could not|cannot|no such file|does not exist)\b/i.test(line));
|
|
33
|
+
const combined = `${evidenceHead}\n${evidenceTail}`;
|
|
34
|
+
const detectedCause = detectCause(combined);
|
|
35
|
+
return {
|
|
36
|
+
evidenceHead,
|
|
37
|
+
evidenceTail,
|
|
38
|
+
...(firstErrorLine ? { firstErrorLine: redactText(firstErrorLine).text } : {}),
|
|
39
|
+
...(firstExceptionLine ? { firstExceptionLine: redactText(firstExceptionLine).text } : {}),
|
|
40
|
+
...(detectedCause ? { detectedCause } : {}),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export function execResultEvidence(result) {
|
|
44
|
+
const evidenceHead = [result.stderrHead, result.stdoutHead].filter(Boolean).join("\n");
|
|
45
|
+
const evidenceTail = result.stderr || result.stdout;
|
|
46
|
+
return extractProcessEvidence(evidenceHead, evidenceTail);
|
|
47
|
+
}
|
|
48
|
+
export function processEvidenceText(evidence) {
|
|
49
|
+
return [
|
|
50
|
+
evidence.evidenceHead,
|
|
51
|
+
evidence.evidenceTail,
|
|
52
|
+
evidence.firstErrorLine ? `First error: ${evidence.firstErrorLine}` : "",
|
|
53
|
+
evidence.firstExceptionLine ? `First exception: ${evidence.firstExceptionLine}` : "",
|
|
54
|
+
evidence.detectedCause ? `Detected cause: ${evidence.detectedCause}` : "",
|
|
55
|
+
].filter(Boolean).join("\n");
|
|
56
|
+
}
|
|
57
|
+
function setExecutionEnvValue(env, name, value) {
|
|
58
|
+
for (const existing of Object.keys(env)) {
|
|
59
|
+
if (existing !== name && existing.toLowerCase() === name.toLowerCase())
|
|
60
|
+
delete env[existing];
|
|
61
|
+
}
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
delete env[name];
|
|
64
|
+
else
|
|
65
|
+
env[name] = value;
|
|
66
|
+
}
|
|
67
|
+
export function buildExecutionEnv(overrides = {}) {
|
|
68
|
+
const env = { ...process.env };
|
|
69
|
+
for (const name of ["PATH", "HOME", "SHELL"]) {
|
|
70
|
+
const inherited = Object.keys(process.env).find(existing => existing.toLowerCase() === name.toLowerCase());
|
|
71
|
+
if (inherited)
|
|
72
|
+
setExecutionEnvValue(env, name, process.env[inherited]);
|
|
73
|
+
}
|
|
74
|
+
setExecutionEnvValue(env, "CI", "true");
|
|
75
|
+
setExecutionEnvValue(env, "BOOTPROOF", "1");
|
|
76
|
+
for (const [name, value] of Object.entries(overrides)) {
|
|
77
|
+
setExecutionEnvValue(env, name, value);
|
|
78
|
+
}
|
|
79
|
+
return env;
|
|
80
|
+
}
|
|
81
|
+
export function extractLeadingEnvironmentAssignments(command) {
|
|
82
|
+
const environment = {};
|
|
83
|
+
let remaining = command;
|
|
84
|
+
while (true) {
|
|
85
|
+
const match = remaining.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|<>]+))\s+/);
|
|
86
|
+
if (!match)
|
|
87
|
+
break;
|
|
88
|
+
environment[match[1]] = match[2] ?? match[3] ?? match[4] ?? "";
|
|
89
|
+
remaining = remaining.slice(match[0].length);
|
|
90
|
+
}
|
|
91
|
+
if (!remaining.trim() || Object.keys(environment).length === 0) {
|
|
92
|
+
return { command, environment: {} };
|
|
93
|
+
}
|
|
94
|
+
return { command: remaining, environment };
|
|
95
|
+
}
|
|
96
|
+
function shellInvocation(command, env) {
|
|
97
|
+
if (process.platform !== "win32")
|
|
98
|
+
return { command, env: buildExecutionEnv(env) };
|
|
99
|
+
const extracted = extractLeadingEnvironmentAssignments(command);
|
|
100
|
+
return {
|
|
101
|
+
command: extracted.command,
|
|
102
|
+
env: buildExecutionEnv({ ...env, ...extracted.environment }),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
5
105
|
export function runToCompletion(command, cwd, timeoutMs, env) {
|
|
6
106
|
return new Promise(resolve => {
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
child.
|
|
107
|
+
const invocation = shellInvocation(command, env);
|
|
108
|
+
const child = spawn(invocation.command, { cwd, shell: true, detached: process.platform !== "win32", env: invocation.env });
|
|
109
|
+
let stdoutHead = "", stdout = "", stderrHead = "", stderr = "", timedOut = false;
|
|
110
|
+
child.stdout?.on("data", d => {
|
|
111
|
+
const chunk = String(d);
|
|
112
|
+
stdoutHead = head(stdoutHead + chunk);
|
|
113
|
+
stdout = tail(stdout + chunk);
|
|
114
|
+
});
|
|
115
|
+
child.stderr?.on("data", d => {
|
|
116
|
+
const chunk = String(d);
|
|
117
|
+
stderrHead = head(stderrHead + chunk);
|
|
118
|
+
stderr = tail(stderr + chunk);
|
|
119
|
+
});
|
|
11
120
|
const timer = setTimeout(() => { timedOut = true; killTree(child.pid); }, timeoutMs);
|
|
12
|
-
child.on("close", code => { clearTimeout(timer); resolve({ exitCode: code, timedOut,
|
|
13
|
-
child.on("error", err => {
|
|
121
|
+
child.on("close", code => { clearTimeout(timer); resolve({ exitCode: code, timedOut, stdoutHead, stdout, stderrHead, stderr }); });
|
|
122
|
+
child.on("error", err => {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
const error = String(err);
|
|
125
|
+
resolve({
|
|
126
|
+
exitCode: null,
|
|
127
|
+
timedOut,
|
|
128
|
+
stdoutHead,
|
|
129
|
+
stdout,
|
|
130
|
+
stderrHead: head(stderrHead + error),
|
|
131
|
+
stderr: tail(stderr + error),
|
|
132
|
+
});
|
|
133
|
+
});
|
|
14
134
|
});
|
|
15
135
|
}
|
|
16
136
|
export function superviseApp(command, cwd, env) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
137
|
+
const invocation = shellInvocation(command, env);
|
|
138
|
+
const child = spawn(invocation.command, { cwd, shell: true, detached: process.platform !== "win32", env: invocation.env });
|
|
139
|
+
let outHead = "", outTail = "", exit = null;
|
|
140
|
+
const capture = (data) => {
|
|
141
|
+
const chunk = String(data);
|
|
142
|
+
outHead = head(outHead + chunk);
|
|
143
|
+
outTail = tail(outTail + chunk);
|
|
144
|
+
};
|
|
145
|
+
child.stdout?.on("data", capture);
|
|
146
|
+
child.stderr?.on("data", capture);
|
|
147
|
+
child.on("exit", code => { exit = { code, early: true }; });
|
|
148
|
+
child.on("error", () => { exit ??= { code: null, early: true }; });
|
|
22
149
|
return {
|
|
23
|
-
output: () =>
|
|
150
|
+
output: () => outTail,
|
|
151
|
+
evidence: () => extractProcessEvidence(outHead, outTail),
|
|
24
152
|
exited: () => exit,
|
|
25
153
|
stop: async () => {
|
|
26
154
|
if (exit)
|
|
@@ -40,13 +168,39 @@ function killTree(pid, signal = "SIGTERM") {
|
|
|
40
168
|
return;
|
|
41
169
|
try {
|
|
42
170
|
if (process.platform === "win32") {
|
|
43
|
-
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore" });
|
|
171
|
+
spawnSync("taskkill", ["/pid", String(pid), "/T", "/F"], { stdio: "ignore", env: buildExecutionEnv() });
|
|
44
172
|
}
|
|
45
173
|
else
|
|
46
174
|
process.kill(-pid, signal); // negative pid = whole process group
|
|
47
175
|
}
|
|
48
176
|
catch { /* already gone */ }
|
|
49
177
|
}
|
|
178
|
+
const EXPECTED_REDIRECT_PATHS = ["/users/sign_in", "/login", "/signin", "/auth", "/session/new"];
|
|
179
|
+
const BODY_EXCERPT_LIMIT = 1000;
|
|
180
|
+
function acceptedAsHealthy(statusCode, redirectLocation) {
|
|
181
|
+
if (statusCode !== null && statusCode >= 200 && statusCode < 300)
|
|
182
|
+
return true;
|
|
183
|
+
if (statusCode === null || statusCode < 300 || statusCode >= 400 || !redirectLocation)
|
|
184
|
+
return false;
|
|
185
|
+
const normalizedLocation = redirectLocation.toLowerCase();
|
|
186
|
+
return EXPECTED_REDIRECT_PATHS.some(expected => normalizedLocation.includes(expected));
|
|
187
|
+
}
|
|
188
|
+
function normalizedHeaders(headers) {
|
|
189
|
+
const normalized = {};
|
|
190
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
191
|
+
if (value === undefined)
|
|
192
|
+
continue;
|
|
193
|
+
if (["authorization", "cookie", "proxy-authorization", "set-cookie"].includes(name.toLowerCase())) {
|
|
194
|
+
normalized[name] = "[redacted]";
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
normalized[name] = redactText(Array.isArray(value) ? value.join(", ") : value).text;
|
|
198
|
+
}
|
|
199
|
+
return normalized;
|
|
200
|
+
}
|
|
201
|
+
function connectionErrorMessage(error) {
|
|
202
|
+
return error.message || error.code || "connection failed";
|
|
203
|
+
}
|
|
50
204
|
function cleanUrl(value) {
|
|
51
205
|
return value.replace(/[),.;\]}]+$/, "");
|
|
52
206
|
}
|
|
@@ -63,47 +217,141 @@ export function extractHealthCandidates(output) {
|
|
|
63
217
|
}
|
|
64
218
|
return [...candidates];
|
|
65
219
|
}
|
|
66
|
-
|
|
220
|
+
function effectivePort(url) {
|
|
221
|
+
if (url.port)
|
|
222
|
+
return url.port;
|
|
223
|
+
return url.protocol === "https:" ? "443" : url.protocol === "http:" ? "80" : "";
|
|
224
|
+
}
|
|
225
|
+
export function detectHealthCandidatePortMismatch(inferredHealthUrl, advertisedHealthUrls, selectedCommand) {
|
|
226
|
+
if (!inferredHealthUrl)
|
|
227
|
+
return null;
|
|
228
|
+
try {
|
|
229
|
+
const inferred = new URL(inferredHealthUrl);
|
|
230
|
+
for (const advertisedHealthUrl of advertisedHealthUrls) {
|
|
231
|
+
const advertised = new URL(advertisedHealthUrl);
|
|
232
|
+
const advertisedPort = effectivePort(advertised);
|
|
233
|
+
if (advertisedPort &&
|
|
234
|
+
effectivePort(inferred) !== advertisedPort) {
|
|
235
|
+
return {
|
|
236
|
+
inferredHealthUrl,
|
|
237
|
+
advertisedHealthUrl,
|
|
238
|
+
advertisedPort,
|
|
239
|
+
selectedCommand: redactText(selectedCommand).text,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
export function healthCandidatePortMismatchEvidence(mismatch) {
|
|
250
|
+
if (!mismatch)
|
|
251
|
+
return "";
|
|
252
|
+
return [
|
|
253
|
+
"Health candidate port mismatch",
|
|
254
|
+
`inferredHealthUrl: ${mismatch.inferredHealthUrl}`,
|
|
255
|
+
`advertisedHealthUrl: ${mismatch.advertisedHealthUrl}`,
|
|
256
|
+
`advertisedPort: ${mismatch.advertisedPort}`,
|
|
257
|
+
`selectedCommand: ${mismatch.selectedCommand}`,
|
|
258
|
+
].join("\n");
|
|
259
|
+
}
|
|
260
|
+
export async function pollHealthCandidates(initialUrls, timeoutMs, output = () => "", intervalMs = 1000, shouldStop = () => false) {
|
|
67
261
|
const started = Date.now();
|
|
68
262
|
let attempts = 0;
|
|
69
263
|
const candidates = new Set(initialUrls);
|
|
70
264
|
const discoveredCandidates = new Set();
|
|
71
|
-
|
|
265
|
+
let latestResponse = null;
|
|
266
|
+
let latestConnectionError = null;
|
|
267
|
+
while (Date.now() - started < timeoutMs && !shouldStop()) {
|
|
72
268
|
for (const candidate of extractHealthCandidates(output())) {
|
|
73
269
|
if (!candidates.has(candidate))
|
|
74
270
|
discoveredCandidates.add(candidate);
|
|
75
271
|
candidates.add(candidate);
|
|
76
272
|
}
|
|
77
273
|
for (const url of candidates) {
|
|
274
|
+
if (shouldStop())
|
|
275
|
+
break;
|
|
78
276
|
attempts++;
|
|
79
|
-
const
|
|
80
|
-
if (
|
|
277
|
+
const evidence = await probe(url);
|
|
278
|
+
if (evidence.statusCode !== null)
|
|
279
|
+
latestResponse = evidence;
|
|
280
|
+
else if (evidence.connectionError || !latestConnectionError)
|
|
281
|
+
latestConnectionError = evidence;
|
|
282
|
+
if (evidence.acceptedAsHealthy) {
|
|
283
|
+
if (shouldStop())
|
|
284
|
+
break;
|
|
81
285
|
return {
|
|
82
286
|
responded: true,
|
|
83
|
-
status,
|
|
287
|
+
status: evidence.statusCode,
|
|
84
288
|
attempts,
|
|
85
289
|
elapsedMs: Date.now() - started,
|
|
86
290
|
url,
|
|
87
291
|
candidates: [...candidates],
|
|
88
292
|
discoveredCandidates: [...discoveredCandidates],
|
|
293
|
+
evidence,
|
|
89
294
|
};
|
|
90
295
|
}
|
|
91
296
|
}
|
|
92
|
-
|
|
297
|
+
const waitUntil = Date.now() + intervalMs;
|
|
298
|
+
while (Date.now() < waitUntil && !shouldStop()) {
|
|
299
|
+
await new Promise(r => setTimeout(r, Math.min(25, waitUntil - Date.now())));
|
|
300
|
+
}
|
|
93
301
|
}
|
|
94
302
|
for (const candidate of extractHealthCandidates(output())) {
|
|
95
303
|
if (!candidates.has(candidate))
|
|
96
304
|
discoveredCandidates.add(candidate);
|
|
97
305
|
candidates.add(candidate);
|
|
98
306
|
}
|
|
307
|
+
const evidence = latestResponse ?? latestConnectionError;
|
|
99
308
|
return {
|
|
100
|
-
responded:
|
|
101
|
-
status: null,
|
|
309
|
+
responded: evidence?.statusCode !== null && evidence?.statusCode !== undefined,
|
|
310
|
+
status: evidence?.statusCode ?? null,
|
|
102
311
|
attempts,
|
|
103
312
|
elapsedMs: Date.now() - started,
|
|
104
|
-
url: null,
|
|
313
|
+
url: evidence?.requestedUrl ?? null,
|
|
105
314
|
candidates: [...candidates],
|
|
106
315
|
discoveredCandidates: [...discoveredCandidates],
|
|
316
|
+
evidence,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
export async function probeHealthCandidatesOnce(initialUrls) {
|
|
320
|
+
const started = Date.now();
|
|
321
|
+
const candidates = [...new Set(initialUrls)];
|
|
322
|
+
let attempts = 0;
|
|
323
|
+
let latestResponse = null;
|
|
324
|
+
let latestConnectionError = null;
|
|
325
|
+
for (const url of candidates) {
|
|
326
|
+
attempts++;
|
|
327
|
+
const evidence = await probe(url);
|
|
328
|
+
if (evidence.statusCode !== null)
|
|
329
|
+
latestResponse = evidence;
|
|
330
|
+
else if (evidence.connectionError || !latestConnectionError)
|
|
331
|
+
latestConnectionError = evidence;
|
|
332
|
+
if (evidence.acceptedAsHealthy) {
|
|
333
|
+
return {
|
|
334
|
+
responded: true,
|
|
335
|
+
status: evidence.statusCode,
|
|
336
|
+
attempts,
|
|
337
|
+
elapsedMs: Date.now() - started,
|
|
338
|
+
url,
|
|
339
|
+
candidates,
|
|
340
|
+
discoveredCandidates: [],
|
|
341
|
+
evidence,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const evidence = latestResponse ?? latestConnectionError;
|
|
346
|
+
return {
|
|
347
|
+
responded: evidence?.statusCode !== null && evidence?.statusCode !== undefined,
|
|
348
|
+
status: evidence?.statusCode ?? null,
|
|
349
|
+
attempts,
|
|
350
|
+
elapsedMs: Date.now() - started,
|
|
351
|
+
url: evidence?.requestedUrl ?? null,
|
|
352
|
+
candidates,
|
|
353
|
+
discoveredCandidates: [],
|
|
354
|
+
evidence,
|
|
107
355
|
};
|
|
108
356
|
}
|
|
109
357
|
export function pollHealth(url, timeoutMs, intervalMs = 1000) {
|
|
@@ -111,32 +359,62 @@ export function pollHealth(url, timeoutMs, intervalMs = 1000) {
|
|
|
111
359
|
}
|
|
112
360
|
function probe(url) {
|
|
113
361
|
return new Promise(resolve => {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
362
|
+
let settled = false;
|
|
363
|
+
const finish = (evidence) => {
|
|
364
|
+
if (settled)
|
|
365
|
+
return;
|
|
366
|
+
settled = true;
|
|
367
|
+
resolve(evidence);
|
|
368
|
+
};
|
|
369
|
+
const connectionFailure = (message) => ({
|
|
370
|
+
requestedUrl: url,
|
|
371
|
+
statusCode: null,
|
|
372
|
+
statusText: null,
|
|
373
|
+
headers: {},
|
|
374
|
+
redirectLocation: null,
|
|
375
|
+
bodyExcerpt: "",
|
|
376
|
+
timestamp: new Date().toISOString(),
|
|
377
|
+
acceptedAsHealthy: false,
|
|
378
|
+
connectionError: redactText(message).text,
|
|
379
|
+
});
|
|
380
|
+
let transport;
|
|
381
|
+
try {
|
|
382
|
+
transport = new URL(url).protocol === "https:" ? https : http;
|
|
383
|
+
}
|
|
384
|
+
catch (error) {
|
|
385
|
+
finish(connectionFailure(connectionErrorMessage(error)));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const req = transport.get(url, { timeout: 3000 }, res => {
|
|
389
|
+
let bodyExcerpt = "";
|
|
390
|
+
res.setEncoding("utf8");
|
|
391
|
+
res.on("data", chunk => {
|
|
392
|
+
if (bodyExcerpt.length < BODY_EXCERPT_LIMIT) {
|
|
393
|
+
bodyExcerpt += String(chunk).slice(0, BODY_EXCERPT_LIMIT - bodyExcerpt.length);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
res.on("end", () => {
|
|
397
|
+
const statusCode = res.statusCode ?? null;
|
|
398
|
+
const headers = normalizedHeaders(res.headers);
|
|
399
|
+
const redirectLocation = headers.location ?? null;
|
|
400
|
+
finish({
|
|
401
|
+
requestedUrl: url,
|
|
402
|
+
statusCode,
|
|
403
|
+
statusText: statusCode === null ? null : res.statusMessage || http.STATUS_CODES[statusCode] || null,
|
|
404
|
+
headers,
|
|
405
|
+
redirectLocation,
|
|
406
|
+
bodyExcerpt: redactText(bodyExcerpt).text,
|
|
407
|
+
timestamp: new Date().toISOString(),
|
|
408
|
+
acceptedAsHealthy: acceptedAsHealthy(statusCode, redirectLocation),
|
|
409
|
+
connectionError: null,
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
res.on("error", error => finish(connectionFailure(connectionErrorMessage(error))));
|
|
413
|
+
});
|
|
414
|
+
req.on("timeout", () => {
|
|
415
|
+
finish(connectionFailure("request timed out after 3000ms"));
|
|
416
|
+
req.destroy();
|
|
417
|
+
});
|
|
418
|
+
req.on("error", error => finish(connectionFailure(connectionErrorMessage(error))));
|
|
117
419
|
});
|
|
118
420
|
}
|
|
119
|
-
export function minimalEnv(extra = {}) {
|
|
120
|
-
const keep = [
|
|
121
|
-
"PATH",
|
|
122
|
-
"HOME",
|
|
123
|
-
"USER",
|
|
124
|
-
"SHELL",
|
|
125
|
-
"TMPDIR",
|
|
126
|
-
"TEMP",
|
|
127
|
-
"LANG",
|
|
128
|
-
"TERM",
|
|
129
|
-
"NODE_OPTIONS",
|
|
130
|
-
"COREPACK_HOME",
|
|
131
|
-
"npm_config_cache",
|
|
132
|
-
"SystemRoot",
|
|
133
|
-
"SYSTEMROOT",
|
|
134
|
-
"ComSpec",
|
|
135
|
-
"PATHEXT",
|
|
136
|
-
];
|
|
137
|
-
const env = {};
|
|
138
|
-
for (const k of keep)
|
|
139
|
-
if (process.env[k])
|
|
140
|
-
env[k] = process.env[k];
|
|
141
|
-
return { ...env, ...extra, CI: "true", BOOTPROOF: "1" };
|
|
142
|
-
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Attestation, ExternalVerificationClassification } from "./types.js";
|
|
2
|
+
export interface ExternalHealthObservation {
|
|
3
|
+
requestedUrl: string;
|
|
4
|
+
statusCode: number | null;
|
|
5
|
+
statusText: string | null;
|
|
6
|
+
finalUrl: string;
|
|
7
|
+
headers: Record<string, string>;
|
|
8
|
+
redirectLocation: string | null;
|
|
9
|
+
responseSnippet: string;
|
|
10
|
+
observedAt: string;
|
|
11
|
+
classification: ExternalVerificationClassification;
|
|
12
|
+
verified: boolean;
|
|
13
|
+
connectionError: string | null;
|
|
14
|
+
}
|
|
15
|
+
export declare function observeExternalHealth(value: string, timeoutMs?: number): Promise<ExternalHealthObservation>;
|
|
16
|
+
export declare function buildExternalHealthAttestation(repo: string, url: string, timeoutMs?: number): Promise<Attestation>;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { buildAttestation } from "./proof.js";
|
|
4
|
+
import { redactText } from "./redact.js";
|
|
5
|
+
const RESPONSE_SNIPPET_LIMIT = 1000;
|
|
6
|
+
const SENSITIVE_HEADER_NAME = /authorization|cookie|token|secret|api[-_]?key|password|passwd|credential|private[-_]?key|signature/i;
|
|
7
|
+
const SENSITIVE_FIELD_NAME = String.raw `(?:access[_-]?token|refresh[_-]?token|token|secret|password|passwd|api[_-]?key|private[_-]?key|authorization|cookie|session)`;
|
|
8
|
+
function parsedExternalUrl(value) {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
11
|
+
throw new Error(`unsupported external health URL protocol: ${url.protocol}`);
|
|
12
|
+
}
|
|
13
|
+
if (url.username || url.password) {
|
|
14
|
+
throw new Error("external health URLs must not contain credentials");
|
|
15
|
+
}
|
|
16
|
+
return url;
|
|
17
|
+
}
|
|
18
|
+
function safeRecordedUrl(value) {
|
|
19
|
+
const url = new URL(value);
|
|
20
|
+
url.username = "";
|
|
21
|
+
url.password = "";
|
|
22
|
+
url.hash = "";
|
|
23
|
+
for (const name of new Set(url.searchParams.keys())) {
|
|
24
|
+
url.searchParams.set(name, "[redacted]");
|
|
25
|
+
}
|
|
26
|
+
return url.toString();
|
|
27
|
+
}
|
|
28
|
+
function safeRedirectLocation(location, baseUrl) {
|
|
29
|
+
try {
|
|
30
|
+
const sanitized = new URL(location, baseUrl);
|
|
31
|
+
const safe = safeRecordedUrl(sanitized);
|
|
32
|
+
if (location.startsWith("/")) {
|
|
33
|
+
const parsed = new URL(safe);
|
|
34
|
+
return `${parsed.pathname}${parsed.search}`;
|
|
35
|
+
}
|
|
36
|
+
return safe;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return redactText(location).text;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function redactExternalText(value) {
|
|
43
|
+
return redactText(value).text
|
|
44
|
+
.replace(new RegExp(`("${SENSITIVE_FIELD_NAME}"\\s*:\\s*)"(?:\\\\.|[^"\\\\])*"`, "gi"), '$1"[redacted]"')
|
|
45
|
+
.replace(new RegExp(`\\b(${SENSITIVE_FIELD_NAME}=)[^&\\s]+`, "gi"), "$1[redacted]");
|
|
46
|
+
}
|
|
47
|
+
function safeResponseHeaders(headers, baseUrl) {
|
|
48
|
+
const safe = {};
|
|
49
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
50
|
+
if (value === undefined)
|
|
51
|
+
continue;
|
|
52
|
+
if (SENSITIVE_HEADER_NAME.test(name)) {
|
|
53
|
+
safe[name] = "[redacted]";
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const joined = Array.isArray(value) ? value.join(", ") : value;
|
|
57
|
+
safe[name] = name.toLowerCase() === "location"
|
|
58
|
+
? safeRedirectLocation(joined, baseUrl)
|
|
59
|
+
: redactExternalText(joined);
|
|
60
|
+
}
|
|
61
|
+
return safe;
|
|
62
|
+
}
|
|
63
|
+
function requestOnce(url, timeoutMs) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const transport = url.protocol === "https:" ? https : http;
|
|
66
|
+
const request = transport.get(url, { timeout: timeoutMs }, response => {
|
|
67
|
+
let responseSnippet = "";
|
|
68
|
+
response.setEncoding("utf8");
|
|
69
|
+
response.on("data", chunk => {
|
|
70
|
+
if (responseSnippet.length >= RESPONSE_SNIPPET_LIMIT)
|
|
71
|
+
return;
|
|
72
|
+
responseSnippet += String(chunk).slice(0, RESPONSE_SNIPPET_LIMIT - responseSnippet.length);
|
|
73
|
+
});
|
|
74
|
+
response.on("end", () => {
|
|
75
|
+
const statusCode = response.statusCode ?? 0;
|
|
76
|
+
const headers = safeResponseHeaders(response.headers, url.toString());
|
|
77
|
+
resolve({
|
|
78
|
+
requestedUrl: safeRecordedUrl(url),
|
|
79
|
+
statusCode,
|
|
80
|
+
statusText: response.statusMessage || http.STATUS_CODES[statusCode] || "",
|
|
81
|
+
headers,
|
|
82
|
+
redirectLocation: headers.location ?? null,
|
|
83
|
+
responseSnippet: redactExternalText(responseSnippet).slice(0, RESPONSE_SNIPPET_LIMIT),
|
|
84
|
+
observedAt: new Date().toISOString(),
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
response.on("error", reject);
|
|
88
|
+
});
|
|
89
|
+
request.on("timeout", () => {
|
|
90
|
+
request.destroy(new Error(`request timed out after ${timeoutMs}ms`));
|
|
91
|
+
});
|
|
92
|
+
request.on("error", reject);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export async function observeExternalHealth(value, timeoutMs = 5000) {
|
|
96
|
+
const initialUrl = parsedExternalUrl(value);
|
|
97
|
+
try {
|
|
98
|
+
const response = await requestOnce(initialUrl, timeoutMs);
|
|
99
|
+
const finalUrl = response.requestedUrl;
|
|
100
|
+
const authRequired = response.statusCode === 401 || response.statusCode === 403;
|
|
101
|
+
const verified = response.statusCode >= 200 && response.statusCode < 400;
|
|
102
|
+
const classification = authRequired
|
|
103
|
+
? "auth_required"
|
|
104
|
+
: verified
|
|
105
|
+
? "external_service_verified"
|
|
106
|
+
: "external_health_unreachable";
|
|
107
|
+
return {
|
|
108
|
+
requestedUrl: response.requestedUrl,
|
|
109
|
+
statusCode: response.statusCode,
|
|
110
|
+
statusText: response.statusText,
|
|
111
|
+
finalUrl,
|
|
112
|
+
headers: response.headers,
|
|
113
|
+
redirectLocation: response.redirectLocation,
|
|
114
|
+
responseSnippet: response.responseSnippet,
|
|
115
|
+
observedAt: response.observedAt,
|
|
116
|
+
classification,
|
|
117
|
+
verified,
|
|
118
|
+
connectionError: null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
123
|
+
return {
|
|
124
|
+
requestedUrl: safeRecordedUrl(initialUrl),
|
|
125
|
+
statusCode: null,
|
|
126
|
+
statusText: null,
|
|
127
|
+
finalUrl: safeRecordedUrl(initialUrl),
|
|
128
|
+
headers: {},
|
|
129
|
+
redirectLocation: null,
|
|
130
|
+
responseSnippet: "",
|
|
131
|
+
observedAt: new Date().toISOString(),
|
|
132
|
+
classification: "external_health_unreachable",
|
|
133
|
+
verified: false,
|
|
134
|
+
connectionError: redactExternalText(message),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function statusLabel(observation) {
|
|
139
|
+
if (observation.statusCode === null)
|
|
140
|
+
return "no HTTP response";
|
|
141
|
+
return `HTTP ${observation.statusCode}${observation.statusText ? ` ${observation.statusText}` : ""}`;
|
|
142
|
+
}
|
|
143
|
+
export async function buildExternalHealthAttestation(repo, url, timeoutMs = 5000) {
|
|
144
|
+
const startedAt = new Date().toISOString();
|
|
145
|
+
const observation = await observeExternalHealth(url, timeoutMs);
|
|
146
|
+
const status = statusLabel(observation);
|
|
147
|
+
const healthEvidence = {
|
|
148
|
+
requestedUrl: observation.requestedUrl,
|
|
149
|
+
statusCode: observation.statusCode,
|
|
150
|
+
statusText: observation.statusText,
|
|
151
|
+
headers: observation.headers,
|
|
152
|
+
redirectLocation: observation.redirectLocation,
|
|
153
|
+
bodyExcerpt: observation.responseSnippet,
|
|
154
|
+
timestamp: observation.observedAt,
|
|
155
|
+
acceptedAsHealthy: observation.verified,
|
|
156
|
+
connectionError: observation.connectionError,
|
|
157
|
+
};
|
|
158
|
+
const explanation = observation.verified
|
|
159
|
+
? `Observed ${status} from an externally managed service. BootProof did not start or orchestrate the service.`
|
|
160
|
+
: observation.classification === "auth_required"
|
|
161
|
+
? `Observed ${status}; authentication is required, so external health was not fully verified. BootProof did not start or orchestrate the service.`
|
|
162
|
+
: `External health was not verified: ${observation.connectionError ?? status}. BootProof did not start or orchestrate the service.`;
|
|
163
|
+
return buildAttestation({
|
|
164
|
+
repo,
|
|
165
|
+
plan: {
|
|
166
|
+
provider: "local",
|
|
167
|
+
steps: [{
|
|
168
|
+
id: "external-health",
|
|
169
|
+
kind: "health",
|
|
170
|
+
description: "Observe an externally managed HTTP health endpoint",
|
|
171
|
+
required: true,
|
|
172
|
+
}],
|
|
173
|
+
healthUrl: observation.requestedUrl,
|
|
174
|
+
healthCandidates: [observation.requestedUrl],
|
|
175
|
+
observedPort: observation.statusCode === null
|
|
176
|
+
? null
|
|
177
|
+
: Number(new URL(observation.finalUrl).port || (new URL(observation.finalUrl).protocol === "https:" ? 443 : 80)),
|
|
178
|
+
healthCandidateSource: observation.statusCode === null ? "inferred" : "observed",
|
|
179
|
+
generatedFiles: [],
|
|
180
|
+
},
|
|
181
|
+
observed: [{
|
|
182
|
+
id: "external-health",
|
|
183
|
+
kind: "health",
|
|
184
|
+
startedAt,
|
|
185
|
+
finishedAt: observation.observedAt,
|
|
186
|
+
exitCode: null,
|
|
187
|
+
ok: observation.verified,
|
|
188
|
+
observation: observation.verified
|
|
189
|
+
? `${status} observed at ${observation.finalUrl}; service ownership is external`
|
|
190
|
+
: `${observation.classification}: ${observation.connectionError ?? status}; service ownership is external`,
|
|
191
|
+
}],
|
|
192
|
+
startedAt,
|
|
193
|
+
booted: false,
|
|
194
|
+
healthVerified: observation.verified,
|
|
195
|
+
healthObservation: observation.verified ? `${status} at ${observation.finalUrl}` : null,
|
|
196
|
+
healthEvidence,
|
|
197
|
+
observedHealthCandidates: [observation.requestedUrl],
|
|
198
|
+
failureClass: observation.classification === "external_service_verified"
|
|
199
|
+
? null
|
|
200
|
+
: observation.classification,
|
|
201
|
+
failureEvidence: observation.verified
|
|
202
|
+
? null
|
|
203
|
+
: observation.connectionError ?? `${status} at ${observation.finalUrl}`,
|
|
204
|
+
explanation,
|
|
205
|
+
verificationMode: "external-health",
|
|
206
|
+
bootproofOrchestrated: false,
|
|
207
|
+
externalHealthUrl: observation.requestedUrl,
|
|
208
|
+
observedStatus: observation.statusCode,
|
|
209
|
+
observedFinalUrl: observation.finalUrl,
|
|
210
|
+
observedAt: observation.observedAt,
|
|
211
|
+
responseSnippet: observation.responseSnippet,
|
|
212
|
+
classification: observation.classification,
|
|
213
|
+
});
|
|
214
|
+
}
|