@bli-cockpit/cli 0.2.28 → 0.2.30
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 +22 -15
- package/dist/adapters/local-sources.js +1 -0
- package/dist/adapters/raw-evidence-completeness.js +226 -0
- package/dist/adapters/raw-evidence-git-diff.js +90 -0
- package/dist/adapters/raw-evidence-keys.js +92 -0
- package/dist/adapters/raw-evidence-manifest.js +132 -0
- package/dist/adapters/raw-evidence-pack-store.js +136 -0
- package/dist/adapters/raw-evidence-sanitize.js +190 -0
- package/dist/adapters/raw-evidence.js +656 -1257
- package/dist/commands/backfill.js +7 -0
- package/dist/commands/cli-io.js +92 -0
- package/dist/commands/collection-report.js +139 -0
- package/dist/commands/collection-roots.js +153 -0
- package/dist/commands/doctor.js +19 -17
- package/dist/commands/install-receipts.js +193 -0
- package/dist/commands/install-update.js +305 -0
- package/dist/commands/local-auth.js +268 -0
- package/dist/commands/local-discovery.js +100 -0
- package/dist/commands/local-help.js +281 -0
- package/dist/commands/local.js +182 -1872
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sessions.js +162 -0
- package/dist/commands/status.js +230 -0
- package/dist/evidence-upload-client.js +43 -2
- package/dist/raw-evidence-gc.js +1 -1
- package/dist/raw-evidence-staging.js +15 -2
- package/dist/upload-agent-artifacts.js +153 -0
- package/dist/upload-envelope.js +407 -0
- package/dist/upload-evidence-delivery.js +505 -0
- package/dist/upload-http.js +46 -0
- package/dist/upload-session-reports.js +404 -0
- package/dist/upload.js +132 -1264
- package/package.json +2 -2
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the bytes live on this disk.
|
|
3
|
+
*
|
|
4
|
+
* Staging first, promotion second (BLI-3066). A pack's id is derived from the
|
|
5
|
+
* content in it, so it cannot be known until collection is done: bytes land in
|
|
6
|
+
* a private `.staging-<pid>-<rand>` directory, and only then does the directory
|
|
7
|
+
* become its content-keyed name. Identical content on the next sync resolves to
|
|
8
|
+
* the identical directory instead of a 560th copy.
|
|
9
|
+
*
|
|
10
|
+
* Everything written here is mode `0600` inside a `0700` directory, on every
|
|
11
|
+
* platform except Windows, where the chmod is a no-op.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { makeManifest, manifestDescribesEntries, } from "./raw-evidence-manifest.js";
|
|
16
|
+
import { writeRawEvidenceStagingState, } from "../raw-evidence-staging.js";
|
|
17
|
+
/**
|
|
18
|
+
* Turn the staging directory into the content-keyed pack directory.
|
|
19
|
+
*
|
|
20
|
+
* Three outcomes, all of them named:
|
|
21
|
+
* - the pack does not exist yet: rename staging into place (`new`);
|
|
22
|
+
* - it exists and holds every file this pass staged: adopt it and delete the
|
|
23
|
+
* staging copy (`reused`) — nothing is copied twice;
|
|
24
|
+
* - it exists but is missing files: fill the gaps from staging
|
|
25
|
+
* (`restaged_incomplete`), because a half-written pack must not be trusted.
|
|
26
|
+
*
|
|
27
|
+
* Never deletes an existing pack directory wholesale: another pack's entries
|
|
28
|
+
* can point into it through the staged-object index.
|
|
29
|
+
*/
|
|
30
|
+
export async function promoteStagedPack(options) {
|
|
31
|
+
const evidenceDir = path.join(options.rawEvidenceRoot, options.packId);
|
|
32
|
+
const priorPackCount = await countPriorPacks(options.rawEvidenceRoot, options.workContextId, options.packId);
|
|
33
|
+
const existing = await fs.stat(evidenceDir).catch(() => null);
|
|
34
|
+
if (!existing?.isDirectory()) {
|
|
35
|
+
try {
|
|
36
|
+
await fs.rename(options.stagingDir, evidenceDir);
|
|
37
|
+
return {
|
|
38
|
+
state: "new",
|
|
39
|
+
evidenceDir,
|
|
40
|
+
priorPackCount,
|
|
41
|
+
refilledFileCount: 0,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A concurrent sync can win the race to the same content-keyed name.
|
|
46
|
+
// Losing it is fine: the winner staged the identical bytes.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const refilledFileCount = await refillMissingPackFiles(evidenceDir, options.entries);
|
|
50
|
+
await fs
|
|
51
|
+
.rm(options.stagingDir, { recursive: true, force: true })
|
|
52
|
+
.catch(() => undefined);
|
|
53
|
+
return {
|
|
54
|
+
state: refilledFileCount > 0 ? "restaged_incomplete" : "reused",
|
|
55
|
+
evidenceDir,
|
|
56
|
+
priorPackCount,
|
|
57
|
+
refilledFileCount,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** A file already there at the right size is left alone; anything else is recopied. */
|
|
61
|
+
async function refillMissingPackFiles(evidenceDir, entries) {
|
|
62
|
+
let refilledFileCount = 0;
|
|
63
|
+
await ensurePrivateDir(path.join(evidenceDir, "files"));
|
|
64
|
+
for (const entry of entries) {
|
|
65
|
+
if (!entry.staged_in_pack)
|
|
66
|
+
continue;
|
|
67
|
+
const target = path.join(evidenceDir, "files", path.basename(entry.local_path));
|
|
68
|
+
const info = await fs.stat(target).catch(() => null);
|
|
69
|
+
if (info?.isFile() && info.size === entry.byte_size)
|
|
70
|
+
continue;
|
|
71
|
+
await fs.copyFile(entry.local_path, target).catch(() => undefined);
|
|
72
|
+
await chmodPrivate(target, 0o600);
|
|
73
|
+
refilledFileCount += 1;
|
|
74
|
+
}
|
|
75
|
+
return refilledFileCount;
|
|
76
|
+
}
|
|
77
|
+
async function countPriorPacks(rawEvidenceRoot, workContextId, packId) {
|
|
78
|
+
const entries = await fs
|
|
79
|
+
.readdir(rawEvidenceRoot, { withFileTypes: true })
|
|
80
|
+
.catch(() => []);
|
|
81
|
+
return entries.filter((entry) => entry.isDirectory() &&
|
|
82
|
+
entry.name !== packId &&
|
|
83
|
+
entry.name.startsWith(`${workContextId}-`)).length;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Write the manifest, or keep the one already in a reused pack.
|
|
87
|
+
*
|
|
88
|
+
* Byte-stability matters here: the manifest's object key is the only key that
|
|
89
|
+
* embeds the pack id, so rewriting it with a fresh `created_at` every sync
|
|
90
|
+
* would push different bytes at the same content-addressed key forever. A
|
|
91
|
+
* reused pack whose manifest already describes exactly these files keeps it.
|
|
92
|
+
*/
|
|
93
|
+
export async function stageManifest(options) {
|
|
94
|
+
if (options.reusePack) {
|
|
95
|
+
const existing = await fs.readFile(options.manifestPath).catch(() => null);
|
|
96
|
+
if (existing && manifestDescribesEntries(existing, options.entries)) {
|
|
97
|
+
return existing;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const manifestBytes = Buffer.from(`${JSON.stringify(makeManifest({
|
|
101
|
+
context: options.context,
|
|
102
|
+
packId: options.packId,
|
|
103
|
+
entries: options.entries,
|
|
104
|
+
skipped: options.skipped,
|
|
105
|
+
redacted: options.redacted,
|
|
106
|
+
reused: options.reused,
|
|
107
|
+
}), null, 2)}\n`, "utf8");
|
|
108
|
+
await fs.writeFile(options.manifestPath, manifestBytes, { mode: 0o600 });
|
|
109
|
+
await chmodPrivate(options.manifestPath, 0o600);
|
|
110
|
+
return manifestBytes;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Record which content hashes are staged where, so the next sync can adopt the
|
|
114
|
+
* copy instead of writing it again. Losing this file costs reuse and attempt
|
|
115
|
+
* counts, not evidence, so it must never fail a collection — but it must never
|
|
116
|
+
* be silent either.
|
|
117
|
+
*/
|
|
118
|
+
export async function persistStagingState(stateDir, staging, nowIso) {
|
|
119
|
+
staging.updated_at = nowIso;
|
|
120
|
+
await writeRawEvidenceStagingState(stateDir, staging).catch((error) => {
|
|
121
|
+
console.error("[raw-evidence] staging state write failed", JSON.stringify({
|
|
122
|
+
reason: "staging_state_write_failed",
|
|
123
|
+
detail: error instanceof Error ? error.name : typeof error,
|
|
124
|
+
staged_count: Object.keys(staging.staged).length,
|
|
125
|
+
}));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
export async function ensurePrivateDir(dir) {
|
|
129
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
130
|
+
await chmodPrivate(dir, 0o700);
|
|
131
|
+
}
|
|
132
|
+
export async function chmodPrivate(target, mode) {
|
|
133
|
+
if (process.platform === "win32")
|
|
134
|
+
return;
|
|
135
|
+
await fs.chmod(target, mode).catch(() => undefined);
|
|
136
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { containsSecretLikeContent, redactSecretLikeContent, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { sha256 } from "./raw-evidence-keys.js";
|
|
3
|
+
export function sanitizeTextEvidenceForUpload(options) {
|
|
4
|
+
const originalBytes = options.originalBytes ?? Buffer.from(options.text, "utf8");
|
|
5
|
+
// Deliberately outside the try: the content guard is the cheap check that
|
|
6
|
+
// decides which masking strategy applies, and it is not the thing whose
|
|
7
|
+
// crash the stub below is for.
|
|
8
|
+
const secretLikeContent = containsSecretLikeContent(options.text);
|
|
9
|
+
try {
|
|
10
|
+
const redactionResult = (options.redact ?? redactSecretLikeContent)(options.text, {
|
|
11
|
+
appliedBy: "local_collector",
|
|
12
|
+
redactedFields: options.redactedFields,
|
|
13
|
+
});
|
|
14
|
+
if (options.secretLikeFileName) {
|
|
15
|
+
return maskFileWithSecretLikeName({
|
|
16
|
+
text: options.text,
|
|
17
|
+
originalBytes,
|
|
18
|
+
redactedFields: options.redactedFields,
|
|
19
|
+
redactionResult,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (secretLikeContent) {
|
|
23
|
+
return maskSecretBearingLinesOnly({
|
|
24
|
+
text: options.text,
|
|
25
|
+
originalBytes,
|
|
26
|
+
redactedFields: options.redactedFields,
|
|
27
|
+
redactionResult,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (redactionResult.redacted) {
|
|
31
|
+
return keepDeterministicRedaction({
|
|
32
|
+
text: options.text,
|
|
33
|
+
originalBytes,
|
|
34
|
+
redactedFields: options.redactedFields,
|
|
35
|
+
redactionResult,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return { status: "clean", bytes: originalBytes };
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return stubForCrashedRedaction({
|
|
42
|
+
text: options.text,
|
|
43
|
+
originalBytes,
|
|
44
|
+
redactedFields: options.redactedFields,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The name itself (`.env.production`, `id_rsa`) is the signal, so nothing in
|
|
50
|
+
* this file is trusted: whatever the redactor left behind is re-checked, and a
|
|
51
|
+
* still-secret-looking result is replaced wholesale by one marker line.
|
|
52
|
+
*/
|
|
53
|
+
function maskFileWithSecretLikeName(input) {
|
|
54
|
+
const sanitizedText = input.redactionResult.redacted
|
|
55
|
+
? input.redactionResult.text
|
|
56
|
+
: input.text;
|
|
57
|
+
const safeText = containsSecretLikeContent(sanitizedText)
|
|
58
|
+
? "[REDACTED_LINE:secret_redaction_failed]\n"
|
|
59
|
+
: sanitizedText;
|
|
60
|
+
const sanitizedBytes = Buffer.from(safeText, "utf8");
|
|
61
|
+
return {
|
|
62
|
+
status: "redacted",
|
|
63
|
+
bytes: sanitizedBytes,
|
|
64
|
+
redaction: withRedactionContentMetadata(input.redactionResult.metadata ??
|
|
65
|
+
fallbackRedactionMetadata({
|
|
66
|
+
ruleId: "secret_like_file_name",
|
|
67
|
+
originalText: input.text,
|
|
68
|
+
redactedFields: input.redactedFields,
|
|
69
|
+
fullContentRedacted: false,
|
|
70
|
+
}), { originalBytes: input.originalBytes, sanitizedBytes }),
|
|
71
|
+
completenessLabel: "secret_like_name_masked",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** Only the lines that matched are replaced; every other line uploads intact. */
|
|
75
|
+
function maskSecretBearingLinesOnly(input) {
|
|
76
|
+
const redaction = input.redactionResult.metadata ??
|
|
77
|
+
fallbackRedactionMetadata({
|
|
78
|
+
ruleId: "secret_like_content_guard",
|
|
79
|
+
originalText: input.text,
|
|
80
|
+
redactedFields: input.redactedFields,
|
|
81
|
+
});
|
|
82
|
+
let maskedText = maskSecretBearingLines(input.text, redaction);
|
|
83
|
+
if (containsSecretLikeContent(maskedText)) {
|
|
84
|
+
maskedText = "[REDACTED_LINE:secret_redaction_failed]\n";
|
|
85
|
+
}
|
|
86
|
+
const sanitizedBytes = Buffer.from(maskedText, "utf8");
|
|
87
|
+
return {
|
|
88
|
+
status: "redacted",
|
|
89
|
+
bytes: sanitizedBytes,
|
|
90
|
+
redaction: withRedactionContentMetadata(redaction, {
|
|
91
|
+
originalBytes: input.originalBytes,
|
|
92
|
+
sanitizedBytes,
|
|
93
|
+
}),
|
|
94
|
+
completenessLabel: "secret_content_masked",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** The redactor found and replaced something the content guard did not flag. */
|
|
98
|
+
function keepDeterministicRedaction(input) {
|
|
99
|
+
const sanitizedBytes = Buffer.from(input.redactionResult.text, "utf8");
|
|
100
|
+
return {
|
|
101
|
+
status: "redacted",
|
|
102
|
+
bytes: sanitizedBytes,
|
|
103
|
+
redaction: withRedactionContentMetadata(input.redactionResult.metadata ??
|
|
104
|
+
fallbackRedactionMetadata({
|
|
105
|
+
ruleId: "secret_redaction_failed",
|
|
106
|
+
originalText: input.text,
|
|
107
|
+
redactedFields: input.redactedFields,
|
|
108
|
+
}), { originalBytes: input.originalBytes, sanitizedBytes }),
|
|
109
|
+
completenessLabel: "secret_content_masked",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function stubForCrashedRedaction(input) {
|
|
113
|
+
const sanitizedBytes = Buffer.from("[REDACTED_LINE:redaction_crashed_stubbed]\n", "utf8");
|
|
114
|
+
return {
|
|
115
|
+
status: "redacted",
|
|
116
|
+
bytes: sanitizedBytes,
|
|
117
|
+
redaction: withRedactionContentMetadata(fallbackRedactionMetadata({
|
|
118
|
+
ruleId: "redaction_crashed_stubbed",
|
|
119
|
+
originalText: input.text,
|
|
120
|
+
redactedFields: input.redactedFields,
|
|
121
|
+
}), { originalBytes: input.originalBytes, sanitizedBytes }),
|
|
122
|
+
completenessLabel: "redaction_crashed_stubbed",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function maskSecretBearingLines(text, redaction) {
|
|
126
|
+
if (redaction.redacted_ranges.length === 0) {
|
|
127
|
+
return "[REDACTED_LINE:secret_like_content_guard]\n";
|
|
128
|
+
}
|
|
129
|
+
const segments = text.match(/[^\n]*(?:\n|$)/gu)?.filter(Boolean) ?? [];
|
|
130
|
+
let offset = 0;
|
|
131
|
+
return segments
|
|
132
|
+
.map((segment) => {
|
|
133
|
+
const start = offset;
|
|
134
|
+
const end = offset + segment.length;
|
|
135
|
+
offset = end;
|
|
136
|
+
const matched = redaction.redacted_ranges.find((range) => range.start < end && start < range.end);
|
|
137
|
+
if (!matched)
|
|
138
|
+
return segment;
|
|
139
|
+
return `[REDACTED_LINE:${matched.rule_id}]${lineEndingOf(segment)}`;
|
|
140
|
+
})
|
|
141
|
+
.join("");
|
|
142
|
+
}
|
|
143
|
+
function lineEndingOf(segment) {
|
|
144
|
+
if (segment.endsWith("\r\n"))
|
|
145
|
+
return "\r\n";
|
|
146
|
+
if (segment.endsWith("\n"))
|
|
147
|
+
return "\n";
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
function fallbackRedactionMetadata(options) {
|
|
151
|
+
const fullContentRedacted = options.fullContentRedacted !== false;
|
|
152
|
+
return {
|
|
153
|
+
schema_version: "raw-evidence-redaction.v1",
|
|
154
|
+
status: "sanitized",
|
|
155
|
+
mode: "deterministic_text_replacement",
|
|
156
|
+
applied_by: ["local_collector"],
|
|
157
|
+
rule_counts: [
|
|
158
|
+
{
|
|
159
|
+
rule_id: options.ruleId,
|
|
160
|
+
match_count: 1,
|
|
161
|
+
redacted_char_count: fullContentRedacted
|
|
162
|
+
? options.originalText.length
|
|
163
|
+
: 0,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
secret_like_match_count: 1,
|
|
167
|
+
redacted_fields: options.redactedFields,
|
|
168
|
+
redacted_ranges: fullContentRedacted && options.originalText.length > 0
|
|
169
|
+
? [
|
|
170
|
+
{
|
|
171
|
+
start: 0,
|
|
172
|
+
end: options.originalText.length,
|
|
173
|
+
rule_id: options.ruleId,
|
|
174
|
+
},
|
|
175
|
+
]
|
|
176
|
+
: [],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function withRedactionContentMetadata(metadata, options) {
|
|
180
|
+
if (!metadata) {
|
|
181
|
+
throw new Error("redacted evidence is missing redaction metadata");
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
...metadata,
|
|
185
|
+
original_content_hash_sha256: sha256(options.originalBytes),
|
|
186
|
+
sanitized_content_hash_sha256: sha256(options.sanitizedBytes),
|
|
187
|
+
original_byte_size: options.originalBytes.byteLength,
|
|
188
|
+
sanitized_byte_size: options.sanitizedBytes.byteLength,
|
|
189
|
+
};
|
|
190
|
+
}
|