@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.
Files changed (33) hide show
  1. package/README.md +22 -15
  2. package/dist/adapters/local-sources.js +1 -0
  3. package/dist/adapters/raw-evidence-completeness.js +226 -0
  4. package/dist/adapters/raw-evidence-git-diff.js +90 -0
  5. package/dist/adapters/raw-evidence-keys.js +92 -0
  6. package/dist/adapters/raw-evidence-manifest.js +132 -0
  7. package/dist/adapters/raw-evidence-pack-store.js +136 -0
  8. package/dist/adapters/raw-evidence-sanitize.js +190 -0
  9. package/dist/adapters/raw-evidence.js +656 -1257
  10. package/dist/commands/backfill.js +7 -0
  11. package/dist/commands/cli-io.js +92 -0
  12. package/dist/commands/collection-report.js +139 -0
  13. package/dist/commands/collection-roots.js +153 -0
  14. package/dist/commands/doctor.js +19 -17
  15. package/dist/commands/install-receipts.js +193 -0
  16. package/dist/commands/install-update.js +305 -0
  17. package/dist/commands/local-auth.js +268 -0
  18. package/dist/commands/local-discovery.js +100 -0
  19. package/dist/commands/local-help.js +281 -0
  20. package/dist/commands/local.js +182 -1872
  21. package/dist/commands/public-root.js +1 -1
  22. package/dist/commands/sessions.js +162 -0
  23. package/dist/commands/status.js +230 -0
  24. package/dist/evidence-upload-client.js +43 -2
  25. package/dist/raw-evidence-gc.js +1 -1
  26. package/dist/raw-evidence-staging.js +15 -2
  27. package/dist/upload-agent-artifacts.js +153 -0
  28. package/dist/upload-envelope.js +407 -0
  29. package/dist/upload-evidence-delivery.js +505 -0
  30. package/dist/upload-http.js +46 -0
  31. package/dist/upload-session-reports.js +404 -0
  32. package/dist/upload.js +132 -1264
  33. 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
+ }