Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions apps/admin-dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import { SandboxTab } from './tabs/SandboxTab';
import { Transaction, AuditReceipt, SecurityFinding } from '@fidusgate/core-types';

const API_BASE = '/api';
const SAFE_RESOURCE_ID = /^[a-zA-Z0-9._@-]{1,128}$/;

function assertSafeResourceId(value: string, label: string): string {
if (!SAFE_RESOURCE_ID.test(value)) {
throw new Error(`Invalid ${label}`);
}
return value;
}

export default function App() {
// State variables
Expand Down Expand Up @@ -464,7 +472,8 @@ export default function App() {

const handleDownloadComplianceReceipt = async (logId: string) => {
try {
const res = await fetch(`${API_BASE}/logs/compliance/${logId}/export`, {
const safeLogId = assertSafeResourceId(logId, 'logId');
const res = await fetch(`${API_BASE}/logs/compliance/${encodeURIComponent(safeLogId)}/export`, {
headers: getHeaders()
});

Expand All @@ -473,7 +482,7 @@ export default function App() {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `fidusgate-compliance-receipt-${logId}.json`;
a.download = `fidusgate-compliance-receipt-${safeLogId}.json`;
document.body.appendChild(a);
a.click();
a.remove();
Expand Down Expand Up @@ -900,7 +909,8 @@ export default function App() {

setConsensusLoading(true);
try {
const res = await fetch(`${API_BASE}/consensus/requests/${actionId}/override`, {
const safeActionId = assertSafeResourceId(actionId, 'actionId');
const res = await fetch(`${API_BASE}/consensus/requests/${encodeURIComponent(safeActionId)}/override`, {
method: 'POST',
headers: getHeaders()
});
Expand Down
1 change: 1 addition & 0 deletions apps/secure-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"@fidusgate/database": "1.0.0",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-rate-limit": "^7.5.1",
"jsonwebtoken": "^9.0.3",
"ws": "^8.21.0"
},
Expand Down
4 changes: 3 additions & 1 deletion apps/secure-gateway/src/ai-firewall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* and measures cosine similarity against known adversarial injection profiles.
*/

import { sanitizeLogValue } from './security-sanitize';

export interface FirewallResult {
secure: boolean;
reason?: string;
Expand Down Expand Up @@ -145,7 +147,7 @@ export function isPromptSecure(prompt: string): FirewallResult {
if (decoded.length >= 6 && isPrintableText(decoded)) {
const decodedResult = isPromptSecure(decoded);
if (!decodedResult.secure) {
console.warn(`🛡️ [PROMPT FIREWALL BLOCKED]: Obfuscated Base64 injection detected: "${candidate}" -> "${decoded}"`);
console.warn(`🛡️ [PROMPT FIREWALL BLOCKED]: Obfuscated Base64 injection detected: "${sanitizeLogValue(candidate)}" -> "${sanitizeLogValue(decoded)}"`);
return {
secure: false,
reason: `Adversarial obfuscated input blocked: Base64 payload contains blocked pattern.`,
Expand Down
2 changes: 1 addition & 1 deletion apps/secure-gateway/src/cedar-evaluator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export class CedarEvaluator {
const ast = this.parseExpression(tokens);
this.rules.push({ effect, conditionStr, ast });
} catch (err: any) {
console.error(`[CedarEvaluator] Error parsing rule when-condition: "${conditionStr}". Error: ${err.message}`);
console.error('[CedarEvaluator] Error parsing rule when-condition (sanitized details omitted)');
}
}
}
Expand Down
3 changes: 1 addition & 2 deletions apps/secure-gateway/src/cedar.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import test from 'node:test';
import assert from 'node:assert';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { CedarEvaluator } from './cedar-evaluator';
import { isCommandLineSecure, parseShellCommand } from './command-auditor';
import { FidusGateDatabase } from '@fidusgate/database';
Expand Down Expand Up @@ -1063,7 +1062,7 @@ test('FidusGate Cedar Policy & Command Auditor Integration Tests', async (t) =>
assert.strictEqual(approved.status, 'approved');
assert.strictEqual(approved.reviewer, 'admin-reviewer');

const req2 = await db.createBudgetExtensionRequest('req-test-2', 20000, 'Testing rejection', 'dev-2');
await db.createBudgetExtensionRequest('req-test-2', 20000, 'Testing rejection', 'dev-2');
const rejectedReq = await db.rejectBudgetExtensionRequest('req-test-2', 'admin-reviewer');
assert.ok(rejectedReq);
assert.strictEqual(rejectedReq.status, 'rejected');
Expand Down
21 changes: 14 additions & 7 deletions apps/secure-gateway/src/compliance-trackers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as path from 'node:path';
import * as fs from 'node:fs';
import { assertSafeRelativePath, assertSafeSubagentId } from './security-sanitize';

export type BroadcastWSFn = (event: string, data: any) => void;

Expand Down Expand Up @@ -200,19 +201,20 @@ export class IBPComplianceTracker {
}

public recordSubagentTokenUsage(subagentId: string, estimatedTokens: number, maxBudget?: number) {
const safeSubagentId = assertSafeSubagentId(subagentId);
if (!this.state.subagentBudgets) {
this.state.subagentBudgets = {};
}
if (!this.state.subagentBudgets[subagentId]) {
this.state.subagentBudgets[subagentId] = {
if (!this.state.subagentBudgets[safeSubagentId]) {
this.state.subagentBudgets[safeSubagentId] = {
tokenBudget: maxBudget || 20000,
tokensConsumed: 0
};
} else if (maxBudget !== undefined) {
this.state.subagentBudgets[subagentId].tokenBudget = maxBudget;
this.state.subagentBudgets[safeSubagentId].tokenBudget = maxBudget;
}

this.state.subagentBudgets[subagentId].tokensConsumed += estimatedTokens;
this.state.subagentBudgets[safeSubagentId].tokensConsumed += estimatedTokens;
this.state.tokensConsumed += estimatedTokens;
this.saveState();
this.broadcastWS('ibp_state_updated', this.getState());
Expand Down Expand Up @@ -367,13 +369,18 @@ export class PLMComplianceTracker {
// version-bump gate — only an explicit version bump should.
if (filePath.endsWith('package.json')) {
try {
const absPath = path.resolve(process.cwd(), filePath);
const safeFilePath = assertSafeRelativePath(filePath, 'filePath');
const baseDir = path.resolve(process.cwd());
const absPath = path.resolve(baseDir, safeFilePath);
if (!absPath.startsWith(baseDir + path.sep)) {
return;
}
if (fs.existsSync(absPath)) {
const pkg = JSON.parse(fs.readFileSync(absPath, 'utf8'));
const version: string = pkg.version || '';
const prevVersion: string = this._lastKnownPackageVersion[filePath] || '';
const prevVersion: string = this._lastKnownPackageVersion[safeFilePath] || '';
if (!prevVersion || version !== prevVersion) {
this._lastKnownPackageVersion[filePath] = version;
this._lastKnownPackageVersion[safeFilePath] = version;
// Only mark updated if version actually changed (not on first write)
if (prevVersion && version !== prevVersion) {
this.state.releaseVersionUpdated = true;
Expand Down
63 changes: 43 additions & 20 deletions apps/secure-gateway/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import express from 'express';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
import { routeModel } from './model-router';
import jwt from 'jsonwebtoken';
import { execSync } from 'node:child_process';
import { execFileSync, execSync } from 'node:child_process';
import * as path from 'node:path';
import * as fs from 'node:fs';
import { performance } from 'node:perf_hooks';
import { FidusGateDatabase, CommandLogEntry, FilesystemDriftEntry, QuarantineRecord } from '@fidusgate/database';
import { FidusGateDatabase } from '@fidusgate/database';
import { buildDossier, conductInterview } from './interview-engine';
import * as http from 'node:http';
import { verifyReceipt, generateKeyPair, createAttestedSession, verifyAuditChain } from '@fidusgate/crypto-utils';
Expand All @@ -19,6 +19,11 @@ import { startConsensusExpiryWorker } from './cron-worker';
import { isPromptSecure } from './ai-firewall';
import { auditConsensusRequest } from './consensus-auditor';
import { policyCodePassesSafetyChecks, verifyAuthorizePrincipalSignature } from './principal-signature';
import {
assertSafeSubagentId,
safeRecordKey,
sanitizeLogValue,
} from './security-sanitize';
import { auditSandboxSyscalls } from './ebpf-monitor';
import { createProxyVerifier } from './proxy-verifier';
import * as ws from 'ws';
Expand Down Expand Up @@ -78,25 +83,26 @@ const principalViolationCounts: Record<string, number> = {};

export async function recordPrincipalViolation(principal: string): Promise<void> {
if (!principal || principal === 'sb:issuer:test' || principal === 'mcp-agent@fidusgate.internal') return;
principalViolationCounts[principal] = (principalViolationCounts[principal] || 0) + 1;
if (principalViolationCounts[principal] >= 3) {
const principalKey = safeRecordKey(principal, 'principal');
principalViolationCounts[principalKey] = (principalViolationCounts[principalKey] || 0) + 1;
if (principalViolationCounts[principalKey] >= 3) {
const existing = await db.getQuarantineRecord(principal);
if (!existing) {
await db.quarantinePrincipal({
principalId: principal,
quarantinedAt: new Date().toISOString(),
reason: `Auto-quarantined: 3 consecutive Cedar policy denials`,
evidence: [`${principalViolationCounts[principal]} consecutive Cedar denials`]
evidence: [`${principalViolationCounts[principalKey]} consecutive Cedar denials`]
});
log('security', `🔒 PRINCIPAL AUTO-QUARANTINED after repeated Cedar violations: ${principal}`);
broadcastWS('principal_quarantined', { principalId: principal, reason: 'consecutive_violations' });
}
delete principalViolationCounts[principal];
delete principalViolationCounts[principalKey];
}
}

export function resetPrincipalViolations(principal: string): void {
delete principalViolationCounts[principal];
delete principalViolationCounts[safeRecordKey(principal, 'principal')];
}

export function handleSuccessfulExecution() {
Expand Down Expand Up @@ -215,6 +221,13 @@ const autoThrottleMiddleware = (req: any, res: any, next: any) => {
next();
};

const sandboxPatchRateLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
});



// Load FidusGate MCP Configuration and policies
Expand Down Expand Up @@ -311,7 +324,9 @@ const JWT_SECRET: string = (() => {
// Logger helper with security tagging
function log(level: 'info' | 'warn' | 'error' | 'security', message: string, meta?: any) {
const timestamp = new Date().toISOString();
const formatted = `[${timestamp}] [${level.toUpperCase()}] ${message}${meta ? ' ' + JSON.stringify(meta) : ''}`;
const safeMessage = sanitizeLogValue(message);
const safeMeta = meta !== undefined ? sanitizeLogValue(meta) : '';
const formatted = `[${timestamp}] [${level.toUpperCase()}] ${safeMessage}${safeMeta ? ' ' + safeMeta : ''}`;
if (process.argv.includes('--mcp')) {
console.error(formatted);
} else {
Expand Down Expand Up @@ -2056,13 +2071,24 @@ app.get('/api/auth/attested-claims', requireAuth(['developer', 'admin', 'auditor
}
});

function resolveSandboxPatchPath(subagentId: unknown): string {
if (subagentId === undefined || subagentId === null || subagentId === '') {
return path.resolve(process.cwd(), '.memory/pending-sandbox.patch');
}
const safeId = assertSafeSubagentId(subagentId);
const baseDir = path.resolve(process.cwd(), '.memory/subagents');
const patchPath = path.resolve(baseDir, safeId, 'pending-sandbox.patch');
if (!patchPath.startsWith(baseDir + path.sep)) {
throw new Error('Invalid subagentId: path traversal blocked.');
}
return patchPath;
}

// 16. GET /api/sandbox/patch - Retrieve pending sandbox overlay patch (Role: developer, admin, auditor)
app.get('/api/sandbox/patch', requireAuth(['developer', 'admin', 'auditor']), (req, res) => {
app.get('/api/sandbox/patch', sandboxPatchRateLimiter, autoThrottleMiddleware, requireAuth(['developer', 'admin', 'auditor']), (req, res) => {
try {
const { subagentId } = req.query;
const patchPath = subagentId
? path.resolve(process.cwd(), `.memory/subagents/${subagentId}/pending-sandbox.patch`)
: path.resolve(process.cwd(), '.memory/pending-sandbox.patch');
const patchPath = resolveSandboxPatchPath(subagentId);

if (fs.existsSync(patchPath)) {
const patch = fs.readFileSync(patchPath, 'utf8');
Expand All @@ -2076,12 +2102,10 @@ app.get('/api/sandbox/patch', requireAuth(['developer', 'admin', 'auditor']), (r
});

// 17. POST /api/sandbox/apply - Apply/Merge sandbox diff patch (Role: admin)
app.post('/api/sandbox/apply', requireAuth(['admin']), async (req, res) => {
app.post('/api/sandbox/apply', sandboxPatchRateLimiter, autoThrottleMiddleware, requireAuth(['admin']), async (req, res) => {
try {
const { subagentId } = req.body;
const patchPath = subagentId
? path.resolve(process.cwd(), `.memory/subagents/${subagentId}/pending-sandbox.patch`)
: path.resolve(process.cwd(), '.memory/pending-sandbox.patch');
const patchPath = resolveSandboxPatchPath(subagentId);

if (!fs.existsSync(patchPath)) {
res.status(404).json({ error: 'No pending sandbox patch found to apply.' });
Expand All @@ -2094,8 +2118,7 @@ app.post('/api/sandbox/apply', requireAuth(['admin']), async (req, res) => {
log('info', `Administrator applying sandbox patch: ${patchPath}`);

try {
// Use git apply to merge patch cleanly
execSync(`git apply --whitespace=nowarn "${patchPath}"`, { cwd: process.cwd() });
execFileSync('git', ['apply', '--whitespace=nowarn', patchPath], { cwd: process.cwd() });

// Delete patch file after successful merge
fs.unlinkSync(patchPath);
Expand All @@ -2116,7 +2139,7 @@ app.post('/api/sandbox/apply', requireAuth(['admin']), async (req, res) => {
await db.addCommandLog({
id: `cmd_${Math.floor(100000 + Math.random() * 900000)}`,
timestamp: new Date().toISOString(),
command: `git apply ${subagentId ? `.memory/subagents/${subagentId}/pending-sandbox.patch` : '.memory/pending-sandbox.patch'}`,
command: `git apply ${subagentId ? `.memory/subagents/${assertSafeSubagentId(subagentId)}/pending-sandbox.patch` : '.memory/pending-sandbox.patch'}`,
user: userEmail,
role: userRole,
status: 'success',
Expand Down
41 changes: 41 additions & 0 deletions apps/secure-gateway/src/security-sanitize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const SAFE_ID_PATTERN = /^[a-zA-Z0-9._@-]{1,128}$/;
const SAFE_PRINCIPAL_PATTERN = /^[a-zA-Z0-9._@:/-]{1,256}$/;
const SAFE_SUBAGENT_ID_PATTERN = /^[a-zA-Z0-9_-]{1,64}$/;
const SAFE_RELATIVE_PATH_PATTERN = /^[a-zA-Z0-9/_.@-]{1,512}$/;

/** Strip control characters and newlines from log output (CodeQL log-injection). */
export function sanitizeLogValue(value: unknown): string {
if (value === null || value === undefined) {
return String(value);
}
const text = typeof value === 'string' ? value : JSON.stringify(value);
return text.replace(/[\0-\x1f\x7f]/g, '?');
}

export function assertSafeResourceId(value: unknown, label: string): string {
if (typeof value !== 'string' || !SAFE_ID_PATTERN.test(value)) {
throw new Error(`Invalid ${label}: must match ${SAFE_ID_PATTERN.source}`);
}
return value;
}

export function assertSafeSubagentId(value: unknown): string {
if (typeof value !== 'string' || !SAFE_SUBAGENT_ID_PATTERN.test(value)) {
throw new Error('Invalid subagentId: alphanumeric, underscore, and hyphen only (max 64 chars).');
}
return value;
}

export function assertSafeRelativePath(value: unknown, label: string): string {
if (typeof value !== 'string' || !SAFE_RELATIVE_PATH_PATTERN.test(value) || value.includes('..')) {
throw new Error(`Invalid ${label}: path must be relative and must not contain ..`);
}
return value;
}

export function safeRecordKey(value: unknown, label: string): string {
if (typeof value !== 'string' || !SAFE_PRINCIPAL_PATTERN.test(value)) {
throw new Error(`Invalid ${label}: must match principal id format`);
}
return value;
}
Loading
Loading