bootproof 0.1.0 → 0.4.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 +873 -109
- 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 +936 -38
- package/dist/diagnosis.js +114 -17
- package/dist/diff.d.ts +29 -0
- package/dist/diff.js +569 -0
- package/dist/exec.d.ts +30 -2
- package/dist/exec.js +332 -37
- package/dist/external-health.d.ts +16 -0
- package/dist/external-health.js +214 -0
- package/dist/infer.js +489 -41
- package/dist/plan.d.ts +2 -0
- package/dist/plan.js +49 -7
- package/dist/proof.d.ts +78 -2
- package/dist/proof.js +266 -13
- 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.d.ts +12 -1
- package/dist/remote.js +62 -18
- 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 +142 -0
- package/dist/repair.js +1566 -0
- package/dist/run.d.ts +6 -1
- package/dist/run.js +385 -46
- package/dist/sbom.d.ts +22 -0
- package/dist/sbom.js +99 -0
- package/dist/taxonomy.d.ts +8 -2
- package/dist/taxonomy.js +428 -8
- package/dist/types.d.ts +57 -2
- package/docs/AGENT_IN_THE_LOOP.md +171 -0
- package/docs/AGENT_RUN_RECEIPTS.md +38 -0
- package/docs/CI_ACTION.md +71 -5
- package/docs/DETERMINISTIC_REPAIR_SAFETY_MODEL.md +705 -0
- package/docs/FAILURE_TAXONOMY.md +30 -1
- package/docs/HONESTY_CONTRACT.md +55 -4
- package/docs/LAUNCH_PLAYBOOK.md +232 -0
- package/docs/REAL_REPO_EVIDENCE.md +77 -0
- package/docs/REAL_WORLD_FIXTURES.md +105 -0
- package/docs/REGISTRY.md +48 -28
- package/docs/RELEASE_CHECKLIST.md +9 -1
- package/docs/REPAIR_RECEIPT.md +224 -0
- 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 +13 -6
package/dist/plan.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Inference, RunPlan } from "./types.js";
|
|
2
|
+
export declare const REPAIRED_GENERATED_COMPOSE_MARKER = "# BootProof verified repair: remap-conflicting-service-port";
|
|
3
|
+
export declare function repoComposeRepairFile(repoComposeFile: string): string;
|
|
2
4
|
export declare function composeFileFor(inf: Inference): string | null;
|
|
3
5
|
export declare function envExampleFor(inf: Inference): string | null;
|
|
4
6
|
export declare function buildPlan(inf: Inference, provider: "docker" | "local"): RunPlan;
|
package/dist/plan.js
CHANGED
|
@@ -6,9 +6,23 @@ const SERVICE_IMAGES = {
|
|
|
6
6
|
redis: { image: "redis:7-alpine", port: 6379, env: {} },
|
|
7
7
|
mongodb: { image: "mongo:7", port: 27017, env: {} },
|
|
8
8
|
};
|
|
9
|
+
export const REPAIRED_GENERATED_COMPOSE_MARKER = "# BootProof verified repair: remap-conflicting-service-port";
|
|
10
|
+
export function repoComposeRepairFile(repoComposeFile) {
|
|
11
|
+
const directory = path.posix.dirname(repoComposeFile.replace(/\\/g, "/"));
|
|
12
|
+
const file = "docker-compose.bootproof.override.yml";
|
|
13
|
+
return directory === "." ? file : path.posix.join(directory, file);
|
|
14
|
+
}
|
|
9
15
|
export function composeFileFor(inf) {
|
|
16
|
+
if (inf.repoComposeFile)
|
|
17
|
+
return null;
|
|
10
18
|
if (!inf.services.length)
|
|
11
19
|
return null;
|
|
20
|
+
const existingPath = path.join(inf.repoPath, "docker-compose.bootproof.yml");
|
|
21
|
+
if (fs.existsSync(existingPath)) {
|
|
22
|
+
const existing = fs.readFileSync(existingPath, "utf8");
|
|
23
|
+
if (existing.includes(REPAIRED_GENERATED_COMPOSE_MARKER))
|
|
24
|
+
return existing;
|
|
25
|
+
}
|
|
12
26
|
const lines = ["# Generated by bootproof — review before use. Standard compose; no bootproof runtime required.", "services:"];
|
|
13
27
|
for (const s of inf.services) {
|
|
14
28
|
const spec = SERVICE_IMAGES[s.kind];
|
|
@@ -47,16 +61,42 @@ export function envExampleFor(inf) {
|
|
|
47
61
|
}
|
|
48
62
|
export function buildPlan(inf, provider) {
|
|
49
63
|
const steps = [];
|
|
50
|
-
|
|
51
|
-
|
|
64
|
+
const runsSourceComposeApplication = provider === "docker" &&
|
|
65
|
+
Boolean(inf.repoComposeFile) &&
|
|
66
|
+
inf.composeHealthCandidates.length > 0;
|
|
67
|
+
if (provider === "docker" && inf.repoComposeFile) {
|
|
68
|
+
const repairedCompose = repoComposeRepairFile(inf.repoComposeFile);
|
|
69
|
+
const usesRepairedCopy = fs.existsSync(path.join(inf.repoPath, repairedCompose));
|
|
70
|
+
steps.push({
|
|
71
|
+
id: "services",
|
|
72
|
+
kind: "service",
|
|
73
|
+
command: `docker compose -f ${usesRepairedCopy ? repairedCompose : inf.repoComposeFile} up -d`,
|
|
74
|
+
description: usesRepairedCopy
|
|
75
|
+
? "use the BootProof repaired copy of the repository Compose file"
|
|
76
|
+
: "defer to the repository's own compose file",
|
|
77
|
+
required: true,
|
|
78
|
+
});
|
|
52
79
|
}
|
|
53
|
-
if (inf.
|
|
54
|
-
steps.push({ id: "
|
|
80
|
+
else if (inf.services.length && provider === "docker") {
|
|
81
|
+
steps.push({ id: "services", kind: "service", command: "docker compose -f docker-compose.bootproof.yml up -d", description: `start ${inf.services.map(s => s.kind).join(", ")} in containers`, required: true });
|
|
55
82
|
}
|
|
56
|
-
if (
|
|
57
|
-
|
|
83
|
+
if (!runsSourceComposeApplication) {
|
|
84
|
+
for (const preparation of inf.preparationCommands) {
|
|
85
|
+
steps.push({
|
|
86
|
+
id: preparation.id,
|
|
87
|
+
kind: preparation.kind,
|
|
88
|
+
command: preparation.command,
|
|
89
|
+
description: `${preparation.description} (${preparation.source})`,
|
|
90
|
+
required: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (inf.appCommand) {
|
|
94
|
+
steps.push({ id: "start-app", kind: "start-app", command: inf.appCommand, description: `start app (${inf.appCommandSource})`, required: true });
|
|
95
|
+
}
|
|
58
96
|
}
|
|
59
|
-
const healthCandidates =
|
|
97
|
+
const healthCandidates = runsSourceComposeApplication
|
|
98
|
+
? [...inf.composeHealthCandidates]
|
|
99
|
+
: [...inf.healthCandidates];
|
|
60
100
|
const healthUrl = healthCandidates[0] ?? "";
|
|
61
101
|
if (inf.isApplication && healthUrl) {
|
|
62
102
|
steps.push({ id: "health", kind: "health", description: `poll ${healthUrl} for an HTTP response`, required: true });
|
|
@@ -66,6 +106,8 @@ export function buildPlan(inf, provider) {
|
|
|
66
106
|
steps,
|
|
67
107
|
healthUrl,
|
|
68
108
|
healthCandidates,
|
|
109
|
+
observedPort: inf.observedPort,
|
|
110
|
+
healthCandidateSource: inf.healthCandidateSource,
|
|
69
111
|
generatedFiles: [
|
|
70
112
|
...(composeFileFor(inf) ? [{ path: "docker-compose.bootproof.yml", purpose: "service containers" }] : []),
|
|
71
113
|
...(envExampleFor(inf) ? [{ path: ".env.bootproof.example", purpose: "suggested local env values (never auto-applied)" }] : []),
|
package/dist/proof.d.ts
CHANGED
|
@@ -1,6 +1,45 @@
|
|
|
1
|
-
import type { Attestation, ObservedStep, RunPlan, FailureClass } from "./types.js";
|
|
2
|
-
export declare const TOOL_ID = "bootproof@0.
|
|
1
|
+
import type { Attestation, AttestationTrust, ObservedStep, RunPlan, FailureClass, HealthEvidence, VerificationMode, ExternalVerificationClassification } from "./types.js";
|
|
2
|
+
export declare const TOOL_ID = "bootproof@0.4.0";
|
|
3
|
+
export type { AttestationTrust } from "./types.js";
|
|
4
|
+
export type SignerTrustTier = "invalid" | "self" | "known" | "unknown-foreign";
|
|
5
|
+
export interface SignatureTrustResult {
|
|
6
|
+
integrityValid: boolean;
|
|
7
|
+
tier: SignerTrustTier;
|
|
8
|
+
fingerprint: string | null;
|
|
9
|
+
label: string | null;
|
|
10
|
+
}
|
|
3
11
|
export declare function gitInfo(repo: string): Attestation["repo"];
|
|
12
|
+
export declare function knownSignersPath(): string;
|
|
13
|
+
export interface RotationResult {
|
|
14
|
+
schema: "bootproof/key-rotation/v1";
|
|
15
|
+
rotatedAt: string;
|
|
16
|
+
oldPublicKey: string;
|
|
17
|
+
newPublicKey: string;
|
|
18
|
+
backedUpTo: string | null;
|
|
19
|
+
reSignedAttestation: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Rotate the local ed25519 signing key. The old key's public key is archived
|
|
23
|
+
* (so existing attestations remain independently verifiable), a new keypair is
|
|
24
|
+
* generated, and the latest attestation in the given repo is optionally
|
|
25
|
+
* re-signed with the new key.
|
|
26
|
+
*
|
|
27
|
+
* Rotation does NOT invalidate existing attestations — they still carry the
|
|
28
|
+
* old public key inline and verify with it. Rotation only affects what key
|
|
29
|
+
* future attestations will be signed with.
|
|
30
|
+
*/
|
|
31
|
+
export declare function rotateSigner(opts?: {
|
|
32
|
+
repo?: string;
|
|
33
|
+
resignAttestation?: boolean;
|
|
34
|
+
backup?: boolean;
|
|
35
|
+
}): RotationResult;
|
|
36
|
+
export declare function signerFingerprint(publicKeyPem: string): string;
|
|
37
|
+
export declare function trustSigner(publicKeyPem: string, label?: string): {
|
|
38
|
+
fingerprint: string;
|
|
39
|
+
firstSeenAt: string;
|
|
40
|
+
label: string | null;
|
|
41
|
+
};
|
|
42
|
+
export declare function evaluateDetachedSignature(body: Buffer, signature: string | null | undefined, publicKeyPem: string | null | undefined): SignatureTrustResult;
|
|
4
43
|
export declare function buildAttestation(input: {
|
|
5
44
|
repo: string;
|
|
6
45
|
plan: RunPlan;
|
|
@@ -9,16 +48,53 @@ export declare function buildAttestation(input: {
|
|
|
9
48
|
booted: boolean;
|
|
10
49
|
healthVerified: boolean;
|
|
11
50
|
healthObservation: string | null;
|
|
51
|
+
healthEvidence?: HealthEvidence | null;
|
|
12
52
|
observedHealthCandidates?: string[];
|
|
13
53
|
failureClass: FailureClass | null;
|
|
14
54
|
failureEvidence: string | null;
|
|
15
55
|
explanation: string;
|
|
56
|
+
verificationMode?: VerificationMode;
|
|
57
|
+
bootproofOrchestrated?: boolean;
|
|
58
|
+
externalHealthUrl?: string | null;
|
|
59
|
+
observedStatus?: number | null;
|
|
60
|
+
observedFinalUrl?: string | null;
|
|
61
|
+
observedAt?: string | null;
|
|
62
|
+
responseSnippet?: string;
|
|
63
|
+
classification?: ExternalVerificationClassification | null;
|
|
64
|
+
trust?: AttestationTrust;
|
|
16
65
|
}): Attestation;
|
|
66
|
+
/**
|
|
67
|
+
* Detect GitHub Actions OIDC environment. Present only when the workflow has
|
|
68
|
+
* `permissions: id-token: write`. The presence of these env vars IS the consent —
|
|
69
|
+
* the workflow author explicitly granted the OIDC scope.
|
|
70
|
+
*/
|
|
71
|
+
export declare function detectOidcEnv(env?: NodeJS.ProcessEnv): {
|
|
72
|
+
requestUrl: string;
|
|
73
|
+
requestToken: string;
|
|
74
|
+
} | null;
|
|
75
|
+
/**
|
|
76
|
+
* Fetch the OIDC JWT from GitHub Actions and decode its claims (without verification —
|
|
77
|
+
* verification is the receiver's job, not the signer's). Returns a compact record of
|
|
78
|
+
* claims suitable for embedding in the attestation trust block.
|
|
79
|
+
*/
|
|
80
|
+
export declare function fetchOidcClaims(requestUrl: string, requestToken: string, fetchImpl?: typeof fetch): Promise<Record<string, string>>;
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the trust block for an attestation. When `--ci-oidc` is requested and
|
|
83
|
+
* the GitHub Actions OIDC environment is present, fetch the OIDC token and return
|
|
84
|
+
* a ci_oidc_signed trust block. Otherwise return local_developer_signed.
|
|
85
|
+
*/
|
|
86
|
+
export declare function resolveTrust(opts?: {
|
|
87
|
+
ciOidc?: boolean;
|
|
88
|
+
env?: NodeJS.ProcessEnv;
|
|
89
|
+
fetchImpl?: typeof fetch;
|
|
90
|
+
}): Promise<AttestationTrust>;
|
|
17
91
|
export declare function signDetached(body: Buffer): {
|
|
18
92
|
signature: string;
|
|
19
93
|
publicKeyPem: string;
|
|
20
94
|
};
|
|
21
95
|
export declare function verifyDetached(body: Buffer, signature: string, publicKeyPem: string): boolean;
|
|
22
96
|
export declare function verifySignature(att: Attestation): boolean;
|
|
97
|
+
export declare function evaluateAttestationSignature(att: Attestation): SignatureTrustResult;
|
|
98
|
+
export declare function currentGitHead(repo: string): string | null;
|
|
23
99
|
export declare function attestationPath(repo: string): string;
|
|
24
100
|
export declare function writeAttestation(repo: string, att: Attestation): string;
|
package/dist/proof.js
CHANGED
|
@@ -3,11 +3,13 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
|
-
|
|
6
|
+
import { buildExecutionEnv } from "./exec.js";
|
|
7
|
+
import { redactJsonValue } from "./redact.js";
|
|
8
|
+
export const TOOL_ID = "bootproof@0.4.0";
|
|
7
9
|
export function gitInfo(repo) {
|
|
8
10
|
const git = (...args) => {
|
|
9
11
|
try {
|
|
10
|
-
return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }).trim();
|
|
12
|
+
return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", env: buildExecutionEnv() }).trim();
|
|
11
13
|
}
|
|
12
14
|
catch {
|
|
13
15
|
return null;
|
|
@@ -18,7 +20,7 @@ export function gitInfo(repo) {
|
|
|
18
20
|
const status = git("status", "--porcelain");
|
|
19
21
|
return {
|
|
20
22
|
path: repo,
|
|
21
|
-
remote: git("
|
|
23
|
+
remote: git("config", "--get", "remote.origin.url"),
|
|
22
24
|
commit: git("rev-parse", "HEAD"),
|
|
23
25
|
dirty: status === null ? null : status.length > 0,
|
|
24
26
|
};
|
|
@@ -26,6 +28,9 @@ export function gitInfo(repo) {
|
|
|
26
28
|
function signerKeyPath() {
|
|
27
29
|
return path.join(os.homedir(), ".bootproof", "signer.json");
|
|
28
30
|
}
|
|
31
|
+
export function knownSignersPath() {
|
|
32
|
+
return path.join(os.homedir(), ".bootproof", "known_signers.json");
|
|
33
|
+
}
|
|
29
34
|
function loadOrCreateSigner() {
|
|
30
35
|
const p = signerKeyPath();
|
|
31
36
|
if (fs.existsSync(p)) {
|
|
@@ -39,27 +44,204 @@ function loadOrCreateSigner() {
|
|
|
39
44
|
fs.writeFileSync(p, JSON.stringify({ privateKeyPem, publicKeyPem }), { mode: 0o600 });
|
|
40
45
|
return { privateKey: crypto.createPrivateKey(privateKeyPem), publicKeyPem };
|
|
41
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* Rotate the local ed25519 signing key. The old key's public key is archived
|
|
49
|
+
* (so existing attestations remain independently verifiable), a new keypair is
|
|
50
|
+
* generated, and the latest attestation in the given repo is optionally
|
|
51
|
+
* re-signed with the new key.
|
|
52
|
+
*
|
|
53
|
+
* Rotation does NOT invalidate existing attestations — they still carry the
|
|
54
|
+
* old public key inline and verify with it. Rotation only affects what key
|
|
55
|
+
* future attestations will be signed with.
|
|
56
|
+
*/
|
|
57
|
+
export function rotateSigner(opts = {}) {
|
|
58
|
+
const p = signerKeyPath();
|
|
59
|
+
const oldKey = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
|
|
60
|
+
const oldPublicKeyPem = oldKey?.publicKeyPem ?? null;
|
|
61
|
+
// Generate the new keypair.
|
|
62
|
+
const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
|
|
63
|
+
const newPrivateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
|
64
|
+
const newPublicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
|
|
65
|
+
// Back up the old key before overwriting (unless explicitly skipped).
|
|
66
|
+
let backedUpTo = null;
|
|
67
|
+
if (opts.backup !== false && oldKey) {
|
|
68
|
+
const backupDir = path.join(os.homedir(), ".bootproof", "archived-keys");
|
|
69
|
+
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
|
70
|
+
const backupName = `signer-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
|
71
|
+
backedUpTo = path.join(backupDir, backupName);
|
|
72
|
+
fs.writeFileSync(backedUpTo, JSON.stringify(oldKey, null, 2), { mode: 0o600 });
|
|
73
|
+
}
|
|
74
|
+
// Write the new key.
|
|
75
|
+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
76
|
+
fs.writeFileSync(p, JSON.stringify({ privateKeyPem: newPrivateKeyPem, publicKeyPem: newPublicKeyPem }), { mode: 0o600 });
|
|
77
|
+
// Optionally re-sign the latest attestation with the new key.
|
|
78
|
+
let reSigned = false;
|
|
79
|
+
if (opts.resignAttestation && opts.repo) {
|
|
80
|
+
const attPath = attestationPath(opts.repo);
|
|
81
|
+
if (fs.existsSync(attPath)) {
|
|
82
|
+
const att = JSON.parse(fs.readFileSync(attPath, "utf8"));
|
|
83
|
+
// Re-sign: the canonical body excludes signature and signer fields.
|
|
84
|
+
const body = canonicalBody(att);
|
|
85
|
+
const newPrivateKey = crypto.createPrivateKey(newPrivateKeyPem);
|
|
86
|
+
att.signature = crypto.sign(null, body, newPrivateKey).toString("base64");
|
|
87
|
+
att.signer = { publicKey: newPublicKeyPem, algorithm: "ed25519" };
|
|
88
|
+
fs.writeFileSync(attPath, JSON.stringify(att, null, 2) + "\n");
|
|
89
|
+
reSigned = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
schema: "bootproof/key-rotation/v1",
|
|
94
|
+
rotatedAt: new Date().toISOString(),
|
|
95
|
+
oldPublicKey: oldPublicKeyPem ?? "(no prior key existed)",
|
|
96
|
+
newPublicKey: newPublicKeyPem,
|
|
97
|
+
backedUpTo,
|
|
98
|
+
reSignedAttestation: reSigned,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function localSignerPublicKey() {
|
|
102
|
+
const p = signerKeyPath();
|
|
103
|
+
if (!fs.existsSync(p))
|
|
104
|
+
return null;
|
|
105
|
+
try {
|
|
106
|
+
const saved = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
107
|
+
return typeof saved.publicKeyPem === "string" ? saved.publicKeyPem : null;
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function emptyKnownSignerStore() {
|
|
114
|
+
return { schema: "bootproof/known-signers/v1", signers: {} };
|
|
115
|
+
}
|
|
116
|
+
function readKnownSignerStore() {
|
|
117
|
+
const p = knownSignersPath();
|
|
118
|
+
if (!fs.existsSync(p))
|
|
119
|
+
return emptyKnownSignerStore();
|
|
120
|
+
try {
|
|
121
|
+
const value = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
122
|
+
if (value.schema !== "bootproof/known-signers/v1" || !value.signers || typeof value.signers !== "object") {
|
|
123
|
+
return emptyKnownSignerStore();
|
|
124
|
+
}
|
|
125
|
+
return { schema: value.schema, signers: value.signers };
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return emptyKnownSignerStore();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export function signerFingerprint(publicKeyPem) {
|
|
132
|
+
const publicKey = crypto.createPublicKey(publicKeyPem);
|
|
133
|
+
const spki = publicKey.export({ type: "spki", format: "der" });
|
|
134
|
+
return `sha256:${crypto.createHash("sha256").update(spki).digest("hex")}`;
|
|
135
|
+
}
|
|
136
|
+
export function trustSigner(publicKeyPem, label) {
|
|
137
|
+
const fingerprint = signerFingerprint(publicKeyPem);
|
|
138
|
+
const store = readKnownSignerStore();
|
|
139
|
+
const existing = store.signers[fingerprint];
|
|
140
|
+
const record = existing ?? {
|
|
141
|
+
firstSeenAt: new Date().toISOString(),
|
|
142
|
+
...(label ? { label } : {}),
|
|
143
|
+
};
|
|
144
|
+
if (label)
|
|
145
|
+
record.label = label;
|
|
146
|
+
store.signers[fingerprint] = record;
|
|
147
|
+
const p = knownSignersPath();
|
|
148
|
+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
149
|
+
fs.writeFileSync(p, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
|
|
150
|
+
return {
|
|
151
|
+
fingerprint,
|
|
152
|
+
firstSeenAt: record.firstSeenAt,
|
|
153
|
+
label: record.label ?? null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function signerTrust(publicKeyPem) {
|
|
157
|
+
let fingerprint;
|
|
158
|
+
try {
|
|
159
|
+
fingerprint = signerFingerprint(publicKeyPem);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return { tier: "invalid", fingerprint: null, label: null };
|
|
163
|
+
}
|
|
164
|
+
const localPublicKey = localSignerPublicKey();
|
|
165
|
+
if (localPublicKey) {
|
|
166
|
+
try {
|
|
167
|
+
if (signerFingerprint(localPublicKey) === fingerprint) {
|
|
168
|
+
return { tier: "self", fingerprint, label: null };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// A malformed local signer cannot establish trust in a foreign artifact.
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const known = readKnownSignerStore().signers[fingerprint];
|
|
176
|
+
if (known)
|
|
177
|
+
return { tier: "known", fingerprint, label: known.label ?? null };
|
|
178
|
+
return { tier: "unknown-foreign", fingerprint, label: null };
|
|
179
|
+
}
|
|
180
|
+
export function evaluateDetachedSignature(body, signature, publicKeyPem) {
|
|
181
|
+
if (!signature || !publicKeyPem || !verifyDetached(body, signature, publicKeyPem)) {
|
|
182
|
+
return { integrityValid: false, tier: "invalid", fingerprint: null, label: null };
|
|
183
|
+
}
|
|
184
|
+
return { integrityValid: true, ...signerTrust(publicKeyPem) };
|
|
185
|
+
}
|
|
42
186
|
function canonicalBody(att) {
|
|
43
187
|
const { signature: _s, signer: _k, ...body } = att;
|
|
44
188
|
return Buffer.from(JSON.stringify(body));
|
|
45
189
|
}
|
|
46
190
|
export function buildAttestation(input) {
|
|
191
|
+
const verificationMode = input.verificationMode ?? "bootproof-orchestrated";
|
|
192
|
+
const bootproofOrchestrated = verificationMode === "external-health"
|
|
193
|
+
? false
|
|
194
|
+
: input.bootproofOrchestrated ?? true;
|
|
195
|
+
const redactionsApplied = new Set();
|
|
196
|
+
const redact = (value) => {
|
|
197
|
+
const redacted = redactJsonValue(value);
|
|
198
|
+
for (const rule of redacted.applied)
|
|
199
|
+
redactionsApplied.add(rule);
|
|
200
|
+
return redacted.value;
|
|
201
|
+
};
|
|
202
|
+
const repo = gitInfo(input.repo);
|
|
203
|
+
const persistedRepo = {
|
|
204
|
+
...repo,
|
|
205
|
+
remote: redact(repo.remote),
|
|
206
|
+
};
|
|
207
|
+
const persistedPlan = redact(input.plan);
|
|
208
|
+
const persistedObserved = redact(input.observed);
|
|
209
|
+
const persistedHealthObservation = redact(input.healthObservation);
|
|
210
|
+
const persistedHealthEvidence = redact(input.healthEvidence ?? null);
|
|
211
|
+
const persistedObservedHealthCandidates = redact(input.observedHealthCandidates ?? []);
|
|
212
|
+
const persistedFailureEvidence = redact(input.failureEvidence);
|
|
213
|
+
const persistedExplanation = redact(input.explanation);
|
|
214
|
+
const persistedExternalHealthUrl = redact(input.externalHealthUrl ?? null);
|
|
215
|
+
const persistedObservedFinalUrl = redact(input.observedFinalUrl ?? null);
|
|
216
|
+
const persistedResponseSnippet = redact(input.responseSnippet ?? "");
|
|
47
217
|
const att = {
|
|
48
218
|
schema: "bootproof/attestation/v1",
|
|
49
219
|
tool: TOOL_ID,
|
|
50
|
-
|
|
220
|
+
verificationMode,
|
|
221
|
+
bootproofOrchestrated,
|
|
222
|
+
externalHealthUrl: persistedExternalHealthUrl,
|
|
223
|
+
observedStatus: input.observedStatus ?? null,
|
|
224
|
+
observedFinalUrl: persistedObservedFinalUrl,
|
|
225
|
+
observedAt: input.observedAt ?? null,
|
|
226
|
+
responseSnippet: persistedResponseSnippet,
|
|
227
|
+
classification: input.classification ?? null,
|
|
228
|
+
redactionsApplied: [...redactionsApplied].sort(),
|
|
229
|
+
repo: persistedRepo,
|
|
51
230
|
environment: { os: `${os.platform()} ${os.release()}`, arch: os.arch(), node: process.version },
|
|
52
|
-
trust: { level: "local_developer_signed", signer: "local_ed25519", oidc: null },
|
|
53
|
-
plan:
|
|
54
|
-
observed:
|
|
231
|
+
trust: input.trust ?? { level: "local_developer_signed", signer: "local_ed25519", oidc: null },
|
|
232
|
+
plan: persistedPlan,
|
|
233
|
+
observed: persistedObserved,
|
|
55
234
|
result: {
|
|
56
235
|
booted: input.booted,
|
|
57
236
|
healthVerified: input.healthVerified,
|
|
58
|
-
healthObservation:
|
|
59
|
-
|
|
237
|
+
healthObservation: persistedHealthObservation,
|
|
238
|
+
healthEvidence: persistedHealthEvidence,
|
|
239
|
+
observedHealthCandidates: persistedObservedHealthCandidates,
|
|
240
|
+
observedPort: persistedPlan.observedPort ?? null,
|
|
241
|
+
healthCandidateSource: persistedPlan.healthCandidateSource ?? "inferred",
|
|
60
242
|
failureClass: input.failureClass,
|
|
61
|
-
failureEvidence:
|
|
62
|
-
explanation:
|
|
243
|
+
failureEvidence: persistedFailureEvidence,
|
|
244
|
+
explanation: persistedExplanation,
|
|
63
245
|
},
|
|
64
246
|
startedAt: input.startedAt,
|
|
65
247
|
finishedAt: new Date().toISOString(),
|
|
@@ -71,6 +253,67 @@ export function buildAttestation(input) {
|
|
|
71
253
|
att.signer = { publicKey: publicKeyPem, algorithm: "ed25519" };
|
|
72
254
|
return att;
|
|
73
255
|
}
|
|
256
|
+
/**
|
|
257
|
+
* Detect GitHub Actions OIDC environment. Present only when the workflow has
|
|
258
|
+
* `permissions: id-token: write`. The presence of these env vars IS the consent —
|
|
259
|
+
* the workflow author explicitly granted the OIDC scope.
|
|
260
|
+
*/
|
|
261
|
+
export function detectOidcEnv(env = process.env) {
|
|
262
|
+
const url = env.ACTIONS_ID_TOKEN_REQUEST_URL?.trim();
|
|
263
|
+
const token = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN?.trim();
|
|
264
|
+
if (!url || !token)
|
|
265
|
+
return null;
|
|
266
|
+
return { requestUrl: url, requestToken: token };
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Fetch the OIDC JWT from GitHub Actions and decode its claims (without verification —
|
|
270
|
+
* verification is the receiver's job, not the signer's). Returns a compact record of
|
|
271
|
+
* claims suitable for embedding in the attestation trust block.
|
|
272
|
+
*/
|
|
273
|
+
export async function fetchOidcClaims(requestUrl, requestToken, fetchImpl = fetch) {
|
|
274
|
+
const url = new URL(requestUrl);
|
|
275
|
+
url.searchParams.set("audience", "bootproof.dev");
|
|
276
|
+
const response = await fetchImpl(url.toString(), {
|
|
277
|
+
headers: { authorization: `bearer ${requestToken}` },
|
|
278
|
+
});
|
|
279
|
+
if (!response.ok) {
|
|
280
|
+
throw new Error(`OIDC token request failed with HTTP ${response.status}`);
|
|
281
|
+
}
|
|
282
|
+
const body = await response.json();
|
|
283
|
+
if (!body.value || typeof body.value !== "string") {
|
|
284
|
+
throw new Error("OIDC token response did not contain a 'value' field");
|
|
285
|
+
}
|
|
286
|
+
// Decode the JWT payload (middle segment). No verification — the receiver verifies.
|
|
287
|
+
const parts = body.value.split(".");
|
|
288
|
+
if (parts.length !== 3)
|
|
289
|
+
throw new Error("OIDC token is not a valid JWT");
|
|
290
|
+
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
291
|
+
// Extract the claims that matter for provenance. Stringify everything for the schema.
|
|
292
|
+
const claims = {};
|
|
293
|
+
for (const key of ["iss", "sub", "aud", "ref", "repository", "repository_owner", "run_id", "run_attempt", "event_name", "workflow", "job_workflow_ref"]) {
|
|
294
|
+
if (payload[key] !== undefined && payload[key] !== null) {
|
|
295
|
+
claims[key] = String(payload[key]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return claims;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Resolve the trust block for an attestation. When `--ci-oidc` is requested and
|
|
302
|
+
* the GitHub Actions OIDC environment is present, fetch the OIDC token and return
|
|
303
|
+
* a ci_oidc_signed trust block. Otherwise return local_developer_signed.
|
|
304
|
+
*/
|
|
305
|
+
export async function resolveTrust(opts = {}) {
|
|
306
|
+
if (!opts.ciOidc) {
|
|
307
|
+
return { level: "local_developer_signed", signer: "local_ed25519", oidc: null };
|
|
308
|
+
}
|
|
309
|
+
const oidcEnv = detectOidcEnv(opts.env);
|
|
310
|
+
if (!oidcEnv) {
|
|
311
|
+
throw new Error("--ci-oidc was requested but ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN are not set. "
|
|
312
|
+
+ "Ensure the workflow has `permissions: id-token: write`.");
|
|
313
|
+
}
|
|
314
|
+
const claims = await fetchOidcClaims(oidcEnv.requestUrl, oidcEnv.requestToken, opts.fetchImpl);
|
|
315
|
+
return { level: "ci_oidc_signed", signer: "local_ed25519", oidc: claims };
|
|
316
|
+
}
|
|
74
317
|
export function signDetached(body) {
|
|
75
318
|
const { privateKey, publicKeyPem } = loadOrCreateSigner();
|
|
76
319
|
return { signature: crypto.sign(null, body, privateKey).toString("base64"), publicKeyPem };
|
|
@@ -86,11 +329,21 @@ export function verifyDetached(body, signature, publicKeyPem) {
|
|
|
86
329
|
export function verifySignature(att) {
|
|
87
330
|
if (!att.signature || !att.signer)
|
|
88
331
|
return false;
|
|
332
|
+
return verifyDetached(canonicalBody(att), att.signature, att.signer.publicKey);
|
|
333
|
+
}
|
|
334
|
+
export function evaluateAttestationSignature(att) {
|
|
335
|
+
return evaluateDetachedSignature(canonicalBody(att), att.signature, att.signer?.publicKey);
|
|
336
|
+
}
|
|
337
|
+
export function currentGitHead(repo) {
|
|
89
338
|
try {
|
|
90
|
-
return
|
|
339
|
+
return execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], {
|
|
340
|
+
encoding: "utf8",
|
|
341
|
+
env: buildExecutionEnv(),
|
|
342
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
343
|
+
}).trim();
|
|
91
344
|
}
|
|
92
345
|
catch {
|
|
93
|
-
return
|
|
346
|
+
return null;
|
|
94
347
|
}
|
|
95
348
|
}
|
|
96
349
|
export function attestationPath(repo) {
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Attestation } from "./types.js";
|
|
2
|
+
interface ReceiptRecord {
|
|
3
|
+
schema: string;
|
|
4
|
+
id: string;
|
|
5
|
+
capturedAt: string;
|
|
6
|
+
capturedBy: string;
|
|
7
|
+
trust: {
|
|
8
|
+
level: string;
|
|
9
|
+
signer: string;
|
|
10
|
+
oidc: string | null;
|
|
11
|
+
upgradePath: string[];
|
|
12
|
+
};
|
|
13
|
+
repo: {
|
|
14
|
+
url: string;
|
|
15
|
+
commit: string;
|
|
16
|
+
branch: string;
|
|
17
|
+
label: string;
|
|
18
|
+
};
|
|
19
|
+
inference: {
|
|
20
|
+
language: string;
|
|
21
|
+
packageManager: string;
|
|
22
|
+
startCommand: string;
|
|
23
|
+
} | null;
|
|
24
|
+
plan: Array<{
|
|
25
|
+
step: string;
|
|
26
|
+
command: string;
|
|
27
|
+
exitCode: number;
|
|
28
|
+
durationMs: number;
|
|
29
|
+
}>;
|
|
30
|
+
log: Array<{
|
|
31
|
+
t: number;
|
|
32
|
+
level: string;
|
|
33
|
+
line: string;
|
|
34
|
+
}>;
|
|
35
|
+
observed: {
|
|
36
|
+
kind: string;
|
|
37
|
+
url?: string;
|
|
38
|
+
status?: number;
|
|
39
|
+
latencyMs?: number;
|
|
40
|
+
body?: string;
|
|
41
|
+
signal?: string | null;
|
|
42
|
+
code?: number;
|
|
43
|
+
windowMs?: number;
|
|
44
|
+
exitCode?: number;
|
|
45
|
+
};
|
|
46
|
+
booted: boolean;
|
|
47
|
+
healthVerified: boolean;
|
|
48
|
+
failureClass: string | null;
|
|
49
|
+
}
|
|
50
|
+
declare function attestationToRecord(att: Attestation): ReceiptRecord;
|
|
51
|
+
export declare function emitLivingReceipt(att: Attestation, outPath: string): string;
|
|
52
|
+
export { attestationToRecord };
|