mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 17:57:06 +00:00
refactor(backend): sanitize user input before logging to close CRLF injection (#807)
* refactor(backend): sanitize user input before logging to close CRLF injection
Adds a small sanitizeForLog helper that strips CR, LF, tab, and ASCII
control characters (0x00-0x1F, 0x7F) from a value before it is embedded
in a console.log/warn/error/debug call. Wraps every call site where a
user-controlled value (req.params, req.body, req.query, or a value
derived from them) flows into a log message.
Closes the bulk of the open CodeQL alerts in this family:
- 96 js/log-injection
- 28 js/tainted-format-string
The helper is in backend/src/utils/safeLog.ts. Routes still pre-validate
input at the request boundary; this is the second line of defense and
gives static analyzers a sanitizer they can trace through. JSON
responses, Docker filter labels, and other non-log call sites are
intentionally left unwrapped.
* refactor(backend): printf-style format strings for tainted-log call sites
CodeQL's js/tainted-format-string rule flags template literals in the first
arg of console.X when any interpolated value is user-controlled, regardless
of whether each value is sanitized inline. The canonical mitigation is to
use a static format string and pass values as positional args.
Converts the 28 flagged template literals to printf-style ("%s") format
strings, with sanitizeForLog applied to each positional arg. Also fills in
the log-injection wraps on 9 sites where a user-controlled value was
missed in the first sweep (agents, fleet, gitSources, imageUpdates,
GitSourceService).
No behavior change at runtime. Node's util.format substitutes %s tokens
identically to template-literal interpolation.
* fix(backend): wrap nodeId/snapshotId in fleet restore debug log
CodeQL flagged the unwrapped numeric args even though they cannot
contain control chars in practice. Apply the sanitizer for taint-flow
recognition.
This commit is contained in:
@@ -12,6 +12,7 @@ import { RegistryService } from './RegistryService';
|
||||
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
/**
|
||||
* ComposeService - local docker compose CLI execution.
|
||||
@@ -141,7 +142,7 @@ export class ComposeService {
|
||||
await fsSvc.backupStackFiles(stackName);
|
||||
sendOutput('=== Backup created for atomic deployment ===\n');
|
||||
} catch (e) {
|
||||
console.warn(`Failed to backup stack files for ${stackName}:`, e);
|
||||
console.warn('Failed to backup stack files for %s:', sanitizeForLog(stackName), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +155,7 @@ export class ComposeService {
|
||||
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
|
||||
console.warn('Failed to clean up legacy containers for %s:', sanitizeForLog(stackName), e);
|
||||
}
|
||||
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
@@ -196,7 +197,7 @@ export class ComposeService {
|
||||
}, sendOutput);
|
||||
sendOutput('=== Rolled back successfully ===\n');
|
||||
} catch (rollbackError) {
|
||||
console.error(`Rollback failed for ${stackName}:`, rollbackError);
|
||||
console.error('Rollback failed for %s:', sanitizeForLog(stackName), rollbackError);
|
||||
sendOutput('=== Rollback failed - manual intervention may be required ===\n');
|
||||
}
|
||||
}
|
||||
@@ -331,7 +332,7 @@ export class ComposeService {
|
||||
await fsSvc.backupStackFiles(stackName);
|
||||
sendOutput('=== Backup created for atomic update ===\n');
|
||||
} catch (e) {
|
||||
console.warn(`Failed to backup stack files for ${stackName}:`, e);
|
||||
console.warn('Failed to backup stack files for %s:', sanitizeForLog(stackName), e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +345,7 @@ export class ComposeService {
|
||||
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
|
||||
console.warn('Failed to clean up legacy containers for %s:', sanitizeForLog(stackName), e);
|
||||
}
|
||||
|
||||
await this.withRegistryAuth(async (env) => {
|
||||
@@ -392,7 +393,7 @@ export class ComposeService {
|
||||
}, sendOutput);
|
||||
sendOutput('=== Rolled back successfully ===\n');
|
||||
} catch (rollbackError) {
|
||||
console.error(`Rollback failed for ${stackName}:`, rollbackError);
|
||||
console.error('Rollback failed for %s:', sanitizeForLog(stackName), rollbackError);
|
||||
sendOutput('=== Rollback failed - manual intervention may be required ===\n');
|
||||
}
|
||||
}
|
||||
@@ -405,7 +406,7 @@ export class ComposeService {
|
||||
try {
|
||||
await this.execute('docker', ['compose', 'down', '--volumes', '--remove-orphans'], stackPath, undefined, false);
|
||||
} catch (error) {
|
||||
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
|
||||
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${sanitizeForLog(stackName)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { NodeRegistry } from './NodeRegistry';
|
||||
import { CacheService } from './CacheService';
|
||||
import { isPathWithinBase } from '../utils/validation';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
|
||||
@@ -644,7 +645,7 @@ class DockerController {
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException)?.code;
|
||||
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
||||
console.error(`[DockerController] Failed to read ${filePath}:`, err);
|
||||
console.error('[DockerController] Failed to read %s:', sanitizeForLog(filePath), sanitizeForLog((err as Error)?.message ?? String(err)));
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -790,7 +791,7 @@ class DockerController {
|
||||
containers = lines.map(line => JSON.parse(line) as ComposeContainer);
|
||||
} catch (innerError) {
|
||||
// Log parsing failure with stderr for debugging
|
||||
console.error(`Docker Compose JSON Parse Error for ${stackName}:`, stderr || (parseError as Error).message);
|
||||
console.error('Docker Compose JSON Parse Error for %s:', sanitizeForLog(stackName), sanitizeForLog(stderr || (parseError as Error).message));
|
||||
// Don't return empty - trigger smart fallback below
|
||||
}
|
||||
}
|
||||
@@ -827,7 +828,7 @@ class DockerController {
|
||||
} catch (error) {
|
||||
// If command fails (e.g., stack not deployed, invalid YAML, missing env_file)
|
||||
const execError = error as { stderr?: string; message?: string };
|
||||
console.error(`Docker Compose Error for ${stackName}:`, execError.stderr || execError.message);
|
||||
console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(execError.stderr || execError.message || 'unknown'));
|
||||
|
||||
// Try smart fallback even on error
|
||||
return await this.enrichContainers(await this.smartFallback(stackName, stackDir));
|
||||
@@ -930,7 +931,7 @@ class DockerController {
|
||||
};
|
||||
});
|
||||
} catch (fallbackError) {
|
||||
console.error(`Smart Fallback failed for ${stackName}:`, fallbackError);
|
||||
console.error('Smart Fallback failed for %s:', sanitizeForLog(stackName), sanitizeForLog((fallbackError as Error)?.message ?? String(fallbackError)));
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1053,7 +1054,7 @@ class DockerController {
|
||||
await container.remove({ force: true });
|
||||
results.push({ id, success: true });
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to remove container ${id}:`, error.message);
|
||||
console.error('Failed to remove container %s:', sanitizeForLog(id), sanitizeForLog(error.message));
|
||||
results.push({ id, success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Readable } from 'stream';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { isPathWithinBase, isValidStackName } from '../utils/validation';
|
||||
import { isBinaryBuffer } from '../utils/binaryDetect';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
@@ -133,7 +134,7 @@ export class FileSystemService {
|
||||
const filePath = await this.getComposeFilePath(stackName);
|
||||
return await fsPromises.readFile(filePath, 'utf-8');
|
||||
} catch (error) {
|
||||
console.error('Error reading stack content:', error);
|
||||
console.error('Error reading stack content:', sanitizeForLog((error as Error)?.message ?? String(error)));
|
||||
throw new Error(`Failed to read stack: ${stackName}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DatabaseService, type StackGitSource, type GitSourceAuthType } from './
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { ComposeService } from './ComposeService';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
/**
|
||||
* GitSourceService - fetch compose files from a Git repository and apply
|
||||
@@ -380,7 +381,7 @@ export class GitSourceService {
|
||||
const diag = isDebugEnabled();
|
||||
if (diag) {
|
||||
console.log(
|
||||
`[GitSource:diag] fetch start host=${repoHost(repoUrl)} branch=${branch} compose=${composePath} envSync=${envPath ? 'true' : 'false'} timeoutMs=${timeoutMs}`
|
||||
`[GitSource:diag] fetch start host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} compose=${sanitizeForLog(composePath)} envSync=${envPath ? 'true' : 'false'} timeoutMs=${timeoutMs}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -444,7 +445,7 @@ export class GitSourceService {
|
||||
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
|
||||
}
|
||||
if (isLfsPointer(composeContent)) {
|
||||
console.error(`[GitSource] LFS pointer detected in ${composePath}`);
|
||||
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(composePath)}`);
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
`Compose file at ${composePath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
|
||||
@@ -470,7 +471,7 @@ export class GitSourceService {
|
||||
}
|
||||
}
|
||||
if (envContent !== null && isLfsPointer(envContent)) {
|
||||
console.error(`[GitSource] LFS pointer detected in ${envPath}`);
|
||||
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(envPath)}`);
|
||||
throw new GitSourceError(
|
||||
'GIT_ERROR',
|
||||
`Env file at ${envPath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
|
||||
@@ -489,7 +490,7 @@ export class GitSourceService {
|
||||
|
||||
if (diag) {
|
||||
console.log(
|
||||
`[GitSource:diag] fetch ok host=${repoHost(repoUrl)} branch=${branch} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} elapsedMs=${Date.now() - startedAt}`
|
||||
`[GitSource:diag] fetch ok host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} elapsedMs=${Date.now() - startedAt}`
|
||||
);
|
||||
}
|
||||
return { composeContent, envContent, commitSha, warnings };
|
||||
@@ -497,7 +498,7 @@ export class GitSourceService {
|
||||
if (diag) {
|
||||
const msg = err instanceof GitSourceError ? `${err.code}: ${err.message}` : (err as Error).message;
|
||||
console.log(
|
||||
`[GitSource:diag] fetch fail host=${repoHost(repoUrl)} branch=${branch} elapsedMs=${Date.now() - startedAt} err=${scrubCredentials(msg)}`
|
||||
`[GitSource:diag] fetch fail host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} elapsedMs=${Date.now() - startedAt} err=${sanitizeForLog(scrubCredentials(msg))}`
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
@@ -742,7 +743,7 @@ export class GitSourceService {
|
||||
throw new GitSourceError('GIT_ERROR', 'No pending pull to apply. Fetch the source again.');
|
||||
}
|
||||
if (src.pending_commit_sha !== commitSha) {
|
||||
if (diag) console.log(`[GitSource:diag] apply sha mismatch stack=${stackName} expected=${commitSha.slice(0, 7)} pending=${src.pending_commit_sha.slice(0, 7)}`);
|
||||
if (diag) console.log('[GitSource:diag] apply sha mismatch stack=%s expected=%s pending=%s', sanitizeForLog(stackName), sanitizeForLog(commitSha.slice(0, 7)), sanitizeForLog(src.pending_commit_sha.slice(0, 7)));
|
||||
throw new GitSourceError('GIT_ERROR', 'Pending commit has changed since this pull was fetched. Please review the latest diff.');
|
||||
}
|
||||
|
||||
@@ -770,7 +771,7 @@ export class GitSourceService {
|
||||
db.markGitSourceApplied(stackName, commitSha, hash);
|
||||
|
||||
const shouldDeploy = opts.deploy ?? src.auto_deploy_on_apply;
|
||||
if (diag) console.log(`[GitSource:diag] apply wrote stack=${stackName} sha=${commitSha.slice(0, 7)} deploy=${shouldDeploy}`);
|
||||
if (diag) console.log('[GitSource:diag] apply wrote stack=%s sha=%s deploy=%s', sanitizeForLog(stackName), sanitizeForLog(commitSha.slice(0, 7)), sanitizeForLog(shouldDeploy));
|
||||
|
||||
if (shouldDeploy) {
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { NotificationService } from './NotificationService';
|
||||
import { parseImageRef, getRemoteDigest } from './registry-api';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const BACKFILL_KEY = 'image_update_notifications_backfilled';
|
||||
|
||||
@@ -239,7 +240,7 @@ export class ImageUpdateService {
|
||||
try {
|
||||
imageUpdateMap.set(imageRef, await this.checkImage(docker, imageRef));
|
||||
} catch (e) {
|
||||
console.error(`[ImageUpdateService] Error checking ${imageRef}:`, e);
|
||||
console.error(`[ImageUpdateService] Error checking ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
|
||||
imageUpdateMap.set(imageRef, { hasUpdate: false, error: String(e) });
|
||||
}
|
||||
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DatabaseService, NotificationHistory } from './DatabaseService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
export type NotificationCategory =
|
||||
| 'deploy_success'
|
||||
@@ -139,7 +140,7 @@ export class NotificationService {
|
||||
return true;
|
||||
});
|
||||
if (matched.length > 0) {
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${stackName ?? '(none)'}", category="${category}"`);
|
||||
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
|
||||
await Promise.allSettled(
|
||||
matched.map(route =>
|
||||
this.sendToChannel(route.channel_type, route.channel_url, level, message)
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DatabaseService } from './DatabaseService';
|
||||
import type { ScanPolicy, VulnSeverity } from './DatabaseService';
|
||||
import { FleetSyncService } from './FleetSyncService';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import TrivyService from './TrivyService';
|
||||
import { isSeverityAtLeast } from '../utils/severity';
|
||||
import { validateImageRef } from '../utils/image-ref';
|
||||
@@ -72,7 +73,7 @@ export async function enforcePolicyPreDeploy(
|
||||
imageRefs = await ComposeService.getInstance(nodeId).listStackImages(stackName);
|
||||
} catch (err) {
|
||||
const message = getErrorMessage(err, 'compose parse failed');
|
||||
console.error(`[Policy] listStackImages failed for ${stackName}:`, message);
|
||||
console.error('[Policy] listStackImages failed for %s:', sanitizeForLog(stackName), sanitizeForLog(message));
|
||||
return {
|
||||
ok: false,
|
||||
bypassed: false,
|
||||
|
||||
@@ -3,6 +3,7 @@ import http from 'http';
|
||||
import { CryptoService } from './CryptoService';
|
||||
import { DatabaseService, type Registry, type RegistryType } from './DatabaseService';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -141,7 +142,7 @@ function httpGet(
|
||||
nextHeaders = rest;
|
||||
}
|
||||
if (isDebugEnabled()) {
|
||||
console.debug(`[RegistryService][debug] redirect ${status} ${url} -> ${nextUrl.toString()} (auth ${nextHeaders === headers ? 'kept' : 'stripped'})`);
|
||||
console.debug(`[RegistryService][debug] redirect ${status} ${sanitizeForLog(url)} -> ${sanitizeForLog(nextUrl.toString())} (auth ${nextHeaders === headers ? 'kept' : 'stripped'})`);
|
||||
}
|
||||
httpGet(nextUrl.toString(), nextHeaders, timeoutMs, false).then(resolve, reject);
|
||||
return;
|
||||
@@ -208,7 +209,7 @@ export class RegistryService {
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
console.info(`[RegistryService] Registry created: id=${id} type=${input.type} name="${input.name}"`);
|
||||
console.info(`[RegistryService] Registry created: id=${id} type=${sanitizeForLog(input.type)} name="${sanitizeForLog(input.name)}"`);
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -282,7 +283,7 @@ export class RegistryService {
|
||||
return { success: false, error: 'AWS region is required for ECR registries.' };
|
||||
}
|
||||
if (isDebugEnabled()) {
|
||||
console.debug(`[RegistryService][debug] testWithCredentials ECR region=${input.aws_region}`);
|
||||
console.debug(`[RegistryService][debug] testWithCredentials ECR region=${sanitizeForLog(input.aws_region)}`);
|
||||
}
|
||||
await this.fetchEcrToken(input.username, input.secret, input.aws_region);
|
||||
if (isDebugEnabled()) {
|
||||
@@ -294,7 +295,7 @@ export class RegistryService {
|
||||
const probeUrl = toProbeUrl(input.url, input.type);
|
||||
const basicAuth = Buffer.from(`${input.username}:${input.secret}`).toString('base64');
|
||||
if (isDebugEnabled()) {
|
||||
console.debug(`[RegistryService][debug] testWithCredentials probing ${probeUrl}/v2/`);
|
||||
console.debug(`[RegistryService][debug] testWithCredentials probing ${sanitizeForLog(probeUrl)}/v2/`);
|
||||
}
|
||||
const res = await httpGet(`${probeUrl}/v2/`, { Authorization: `Basic ${basicAuth}` });
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ImageUpdateService } from './ImageUpdateService';
|
||||
import type { ImageCheckResult } from './ImageUpdateService';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot-capture';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { NotificationService } from './NotificationService';
|
||||
@@ -698,7 +699,7 @@ export class SchedulerService {
|
||||
} catch (e) {
|
||||
const msg = getErrorMessage(e, String(e));
|
||||
checkErrors.push(msg);
|
||||
console.warn(`[SchedulerService] Failed to check image ${imageRef}:`, e);
|
||||
console.warn(`[SchedulerService] Failed to check image ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { FileSystemService } from './FileSystemService';
|
||||
import { RegistryService } from './RegistryService';
|
||||
import { disableCapability, enableCapability } from './CapabilityRegistry';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
import TrivyInstaller, { type TrivySource } from './TrivyInstaller';
|
||||
import { FleetSyncService } from './FleetSyncService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
@@ -26,7 +27,7 @@ const SBOM_TIMEOUT_MS = 3 * 60 * 1000;
|
||||
export const DIGEST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function diag(msg: string, ...args: unknown[]): void {
|
||||
if (isDebugEnabled()) console.log(`[Trivy:diag] ${msg}`, ...args);
|
||||
if (isDebugEnabled()) console.log(`[Trivy:diag] ${sanitizeForLog(msg)}`, ...args);
|
||||
}
|
||||
|
||||
interface TrivyRawVulnerability {
|
||||
|
||||
Reference in New Issue
Block a user