Complete RedFlag codebase with two major security audit implementations.
== A-1: Ed25519 Key Rotation Support ==
Server:
- SignCommand sets SignedAt timestamp and KeyID on every signature
- signing_keys database table (migration 020) for multi-key rotation
- InitializePrimaryKey registers active key at startup
- /api/v1/public-keys endpoint for rotation-aware agents
- SigningKeyQueries for key lifecycle management
Agent:
- Key-ID-aware verification via CheckKeyRotation
- FetchAndCacheAllActiveKeys for rotation pre-caching
- Cache metadata with TTL and staleness fallback
- SecurityLogger events for key rotation and command signing
== A-2: Replay Attack Fixes (F-1 through F-7) ==
F-5 CRITICAL - RetryCommand now signs via signAndCreateCommand
F-1 HIGH - v3 format: "{agent_id}:{cmd_id}:{type}:{hash}:{ts}"
F-7 HIGH - Migration 026: expires_at column with partial index
F-6 HIGH - GetPendingCommands/GetStuckCommands filter by expires_at
F-2 HIGH - Agent-side executedIDs dedup map with cleanup
F-4 HIGH - commandMaxAge reduced from 24h to 4h
F-3 CRITICAL - Old-format commands rejected after 48h via CreatedAt
Verification fixes: migration idempotency (ETHOS #4), log format
compliance (ETHOS #1), stale comments updated.
All 24 tests passing. Docker --no-cache build verified.
See docs/ for full audit reports and deviation log (DEV-001 to DEV-019).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
167 lines
4.4 KiB
TypeScript
167 lines
4.4 KiB
TypeScript
import { api, ApiError } from './api';
|
|
|
|
export interface ClientErrorLog {
|
|
subsystem: string;
|
|
error_type: 'javascript_error' | 'api_error' | 'ui_error' | 'validation_error';
|
|
message: string;
|
|
stack_trace?: string;
|
|
metadata?: Record<string, any>;
|
|
url: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
/**
|
|
* ClientErrorLogger provides reliable frontend error logging with retry logic
|
|
* Implements ETHOS #3: Assume Failure; Build for Resilience
|
|
*/
|
|
export class ClientErrorLogger {
|
|
private maxRetries = 3;
|
|
private baseDelayMs = 1000;
|
|
private localStorageKey = 'redflag-error-queue';
|
|
private offlineBuffer: ClientErrorLog[] = [];
|
|
private isOnline = navigator.onLine;
|
|
|
|
constructor() {
|
|
// Listen for online/offline events
|
|
window.addEventListener('online', () => this.flushOfflineBuffer());
|
|
window.addEventListener('offline', () => { this.isOnline = false; });
|
|
}
|
|
|
|
/**
|
|
* Log an error with automatic retry and offline queuing
|
|
*/
|
|
async logError(errorData: Omit<ClientErrorLog, 'url' | 'timestamp'>): Promise<void> {
|
|
const fullError: ClientErrorLog = {
|
|
...errorData,
|
|
url: window.location.href,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
|
|
// Try to send immediately
|
|
try {
|
|
await this.sendWithRetry(fullError);
|
|
return;
|
|
} catch (error) {
|
|
// If failed after retries, queue for later
|
|
this.queueForRetry(fullError);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send error to backend with exponential backoff retry
|
|
*/
|
|
private async sendWithRetry(error: ClientErrorLog): Promise<void> {
|
|
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
|
try {
|
|
await api.post('/logs/client-error', error, {
|
|
headers: { 'X-Error-Logger-Request': 'true' },
|
|
});
|
|
|
|
// Success, remove from queue if it was there
|
|
this.removeFromQueue(error);
|
|
return;
|
|
} catch (error) {
|
|
if (attempt === this.maxRetries) {
|
|
throw error; // Rethrow after final attempt
|
|
}
|
|
|
|
// Exponential backoff
|
|
await this.sleep(this.baseDelayMs * Math.pow(2, attempt - 1));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Queue error for retry when network is available
|
|
*/
|
|
private queueForRetry(error: ClientErrorLog): void {
|
|
try {
|
|
const queue = this.getQueue();
|
|
queue.push({
|
|
...error,
|
|
retryCount: (error as any).retryCount || 0,
|
|
queuedAt: new Date().toISOString(),
|
|
});
|
|
|
|
// Save to localStorage for persistence
|
|
localStorage.setItem(this.localStorageKey, JSON.stringify(queue));
|
|
|
|
// Also keep in memory buffer
|
|
this.offlineBuffer.push(error);
|
|
} catch (storageError) {
|
|
// localStorage might be full or unavailable
|
|
console.warn('Failed to queue error for retry:', storageError);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Flush offline buffer when coming back online
|
|
*/
|
|
private async flushOfflineBuffer(): Promise<void> {
|
|
if (!this.isOnline) return;
|
|
|
|
const queue = this.getQueue();
|
|
if (queue.length === 0) return;
|
|
|
|
const failed: typeof queue = [];
|
|
|
|
for (const queuedError of queue) {
|
|
try {
|
|
await this.sendWithRetry(queuedError);
|
|
} catch (error) {
|
|
failed.push(queuedError);
|
|
}
|
|
}
|
|
|
|
// Update queue with remaining failed items
|
|
if (failed.length < queue.length) {
|
|
localStorage.setItem(this.localStorageKey, JSON.stringify(failed));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current error queue from localStorage
|
|
*/
|
|
private getQueue(): any[] {
|
|
try {
|
|
const stored = localStorage.getItem(this.localStorageKey);
|
|
return stored ? JSON.parse(stored) : [];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove successfully sent error from queue
|
|
*/
|
|
private removeFromQueue(sentError: ClientErrorLog): void {
|
|
try {
|
|
const queue = this.getQueue();
|
|
const filtered = queue.filter(queued =>
|
|
queued.timestamp !== sentError.timestamp ||
|
|
queued.message !== sentError.message
|
|
);
|
|
|
|
if (filtered.length < queue.length) {
|
|
localStorage.setItem(this.localStorageKey, JSON.stringify(filtered));
|
|
}
|
|
} catch {
|
|
// Best effort cleanup
|
|
}
|
|
}
|
|
|
|
private sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
export const clientErrorLogger = new ClientErrorLogger();
|
|
|
|
// Auto-retry failed logs on app load
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('load', () => {
|
|
clientErrorLogger.flushOfflineBuffer().catch(() => {});
|
|
});
|
|
}
|