- New EML_OBJECT_KEY_REGEX enforces phishing/<id>/<id>.eml, rejects traversal - presignDownload/presignUpload/downloadToBuffer take optional keyRegex, defaulting to OBJECT_KEY_REGEX so existing LogLift call sites are unchanged - OBJECT_KEY_REGEX itself left untouched (skill-doc rule)
224 lines
6.9 KiB
TypeScript
224 lines
6.9 KiB
TypeScript
/**
|
|
* Backblaze B2 client (S3-compatible) for the LogLift evidence pipeline.
|
|
*
|
|
* Implements AWS Signature Version 4 presigned URLs (matches the n8n
|
|
* collector's expectations) for both downloads (Pulse fetching uploaded
|
|
* payloads) and uploads (Pulse handing the collector a presigned PUT
|
|
* target so the script doesn't carry credentials).
|
|
*
|
|
* Port of the SigV4 implementation from `docs/LogLift Review.json` —
|
|
* battle-tested in production via the existing n8n flow.
|
|
*/
|
|
|
|
import { createHash, createHmac } from 'crypto';
|
|
|
|
export interface B2Config {
|
|
keyId: string;
|
|
secret: string;
|
|
bucket: string;
|
|
region: string;
|
|
/** S3-compatible endpoint, e.g. `s3.us-west-002.backblazeb2.com` (no scheme). */
|
|
endpoint: string;
|
|
}
|
|
|
|
/** Hard cap on bytes Pulse will read from a B2 object. */
|
|
export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024; // 25 MB
|
|
|
|
/**
|
|
* Object-key shape we accept from inbound webhooks. Path-traversal guard
|
|
* — must be `{client_id_or_uuid}/{computer_name}/eventlogs_{timestamp}.json.gz`.
|
|
*/
|
|
export const OBJECT_KEY_REGEX =
|
|
/^[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+\/eventlogs_[0-9_]+\.json\.gz$/;
|
|
|
|
/**
|
|
* Object-key shape for raw `.eml` evidence uploads (Phase 16 / D-05):
|
|
* `phishing/{reportId}/{attachmentId}.eml`. This is a SEPARATE regex from
|
|
* OBJECT_KEY_REGEX — per the B2 evidence skill doc, never loosen the
|
|
* existing LogLift guard to accommodate a new shape. Path-traversal safe:
|
|
* each segment is restricted to `[A-Za-z0-9_-]+`, so `..` cannot appear.
|
|
*/
|
|
export const EML_OBJECT_KEY_REGEX =
|
|
/^phishing\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\.eml$/;
|
|
|
|
export class B2NotConfiguredError extends Error {
|
|
constructor() {
|
|
super(
|
|
'Backblaze B2 is not configured. Set B2_KEY_ID + B2_APP_KEY (and optionally B2_BUCKET / B2_REGION / B2_ENDPOINT).'
|
|
);
|
|
this.name = 'B2NotConfiguredError';
|
|
}
|
|
}
|
|
|
|
export class B2InvalidObjectKeyError extends Error {
|
|
constructor(objectKey: string) {
|
|
super(`Invalid object key shape: ${objectKey.slice(0, 200)}`);
|
|
this.name = 'B2InvalidObjectKeyError';
|
|
}
|
|
}
|
|
|
|
export function isB2Configured(): boolean {
|
|
return !!(process.env.B2_KEY_ID && process.env.B2_APP_KEY);
|
|
}
|
|
|
|
export function getB2Config(): B2Config {
|
|
const keyId = process.env.B2_KEY_ID;
|
|
const secret = process.env.B2_APP_KEY;
|
|
if (!keyId || !secret) throw new B2NotConfiguredError();
|
|
return {
|
|
keyId,
|
|
secret,
|
|
bucket: process.env.B2_BUCKET || 'wulf-audits',
|
|
region: process.env.B2_REGION || 'us-west-002',
|
|
endpoint: process.env.B2_ENDPOINT || 's3.us-west-002.backblazeb2.com',
|
|
};
|
|
}
|
|
|
|
function sign(key: Buffer | string, msg: string): Buffer {
|
|
return createHmac('sha256', key).update(msg, 'utf8').digest();
|
|
}
|
|
|
|
function deriveSigningKey(
|
|
secret: string,
|
|
dateStamp: string,
|
|
region: string,
|
|
service: string
|
|
): Buffer {
|
|
const kDate = sign('AWS4' + secret, dateStamp);
|
|
const kRegion = sign(kDate, region);
|
|
const kService = sign(kRegion, service);
|
|
return sign(kService, 'aws4_request');
|
|
}
|
|
|
|
interface PresignParams {
|
|
method: 'GET' | 'PUT';
|
|
objectKey: string;
|
|
expiresInSeconds: number;
|
|
config: B2Config;
|
|
}
|
|
|
|
function presign(params: PresignParams): string {
|
|
const { method, objectKey, expiresInSeconds, config } = params;
|
|
const host = config.endpoint;
|
|
// We do NOT URL-encode slashes in the path itself; SigV4 wants the
|
|
// literal canonical URI with the object key as-is (slashes intact).
|
|
const canonicalUri = '/' + config.bucket + '/' + objectKey;
|
|
const algorithm = 'AWS4-HMAC-SHA256';
|
|
|
|
const now = new Date();
|
|
const amzDate = now.toISOString().replace(/[-:]/g, '').split('.')[0] + 'Z';
|
|
const dateStamp = amzDate.slice(0, 8);
|
|
const credentialScope = `${dateStamp}/${config.region}/s3/aws4_request`;
|
|
|
|
const canonicalHeaders = `host:${host}\n`;
|
|
const signedHeaders = 'host';
|
|
|
|
const qs: Record<string, string> = {
|
|
'X-Amz-Algorithm': algorithm,
|
|
'X-Amz-Credential': encodeURIComponent(`${config.keyId}/${credentialScope}`),
|
|
'X-Amz-Date': amzDate,
|
|
'X-Amz-Expires': String(expiresInSeconds),
|
|
'X-Amz-SignedHeaders': signedHeaders,
|
|
};
|
|
|
|
const canonicalQueryString = Object.keys(qs)
|
|
.sort()
|
|
.map((k) => `${k}=${qs[k]}`)
|
|
.join('&');
|
|
|
|
const payloadHash = 'UNSIGNED-PAYLOAD';
|
|
|
|
const canonicalRequest = [
|
|
method,
|
|
canonicalUri,
|
|
canonicalQueryString,
|
|
canonicalHeaders,
|
|
signedHeaders,
|
|
payloadHash,
|
|
].join('\n');
|
|
|
|
const stringToSign = [
|
|
algorithm,
|
|
amzDate,
|
|
credentialScope,
|
|
createHash('sha256').update(canonicalRequest, 'utf8').digest('hex'),
|
|
].join('\n');
|
|
|
|
const signingKey = deriveSigningKey(config.secret, dateStamp, config.region, 's3');
|
|
const signature = createHmac('sha256', signingKey)
|
|
.update(stringToSign, 'utf8')
|
|
.digest('hex');
|
|
|
|
return `https://${host}${canonicalUri}?${canonicalQueryString}&X-Amz-Signature=${signature}`;
|
|
}
|
|
|
|
export function presignDownload(
|
|
objectKey: string,
|
|
expiresInSeconds = 600,
|
|
cfg: B2Config = getB2Config(),
|
|
keyRegex: RegExp = OBJECT_KEY_REGEX
|
|
): string {
|
|
if (!keyRegex.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
|
|
return presign({ method: 'GET', objectKey, expiresInSeconds, config: cfg });
|
|
}
|
|
|
|
export function presignUpload(
|
|
objectKey: string,
|
|
expiresInSeconds = 1800,
|
|
cfg: B2Config = getB2Config(),
|
|
keyRegex: RegExp = OBJECT_KEY_REGEX
|
|
): string {
|
|
if (!keyRegex.test(objectKey)) throw new B2InvalidObjectKeyError(objectKey);
|
|
return presign({ method: 'PUT', objectKey, expiresInSeconds, config: cfg });
|
|
}
|
|
|
|
/**
|
|
* Stream a B2 object to a Buffer. Caps at MAX_DOWNLOAD_BYTES — refuses to
|
|
* read past that even if the server returns more.
|
|
*/
|
|
export async function downloadToBuffer(
|
|
objectKey: string,
|
|
cfg: B2Config = getB2Config(),
|
|
keyRegex: RegExp = OBJECT_KEY_REGEX
|
|
): Promise<Buffer> {
|
|
const url = presignDownload(objectKey, 600, cfg, keyRegex);
|
|
const res = await fetch(url);
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`B2 GET ${objectKey} → ${res.status} ${res.statusText}: ${text.slice(0, 300)}`);
|
|
}
|
|
// Best-effort content-length check before reading the body.
|
|
const contentLength = res.headers.get('content-length');
|
|
if (contentLength && Number(contentLength) > MAX_DOWNLOAD_BYTES) {
|
|
throw new Error(
|
|
`B2 object ${objectKey} too large: ${contentLength} bytes (cap ${MAX_DOWNLOAD_BYTES})`
|
|
);
|
|
}
|
|
if (!res.body) {
|
|
throw new Error(`B2 GET ${objectKey} returned no body`);
|
|
}
|
|
|
|
const reader = res.body.getReader();
|
|
const chunks: Uint8Array[] = [];
|
|
let total = 0;
|
|
for (;;) {
|
|
const { value, done } = await reader.read();
|
|
if (done) break;
|
|
if (!value) continue;
|
|
total += value.byteLength;
|
|
if (total > MAX_DOWNLOAD_BYTES) {
|
|
try { await reader.cancel(); } catch { /* ignore */ }
|
|
throw new Error(
|
|
`B2 object ${objectKey} exceeded ${MAX_DOWNLOAD_BYTES} bytes mid-stream`
|
|
);
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
return Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)));
|
|
}
|
|
|
|
// Test-only exports.
|
|
export const _B2_INTERNALS = {
|
|
deriveSigningKey,
|
|
presign,
|
|
};
|