Skip to content
Merged
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
8 changes: 7 additions & 1 deletion src/cli/commands/model_method_run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,13 @@ export const modelMethodRunCommand = new Command()
`${definition.id}-${timestamp}.log`,
);
const runLogCategory: string[] = [];
await runFileSink.register(runLogCategory, logFilePath, redactor);
const logBoundary = swampPath(repoDir);
await runFileSink.register(
runLogCategory,
logFilePath,
redactor,
logBoundary,
);

runLogger.info("Found model {name} ({type})", {
name: definition.name,
Expand Down
3 changes: 3 additions & 0 deletions src/domain/models/user_model_loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
SWAMP_DATA_DIR,
SWAMP_SUBDIRS,
} from "../../infrastructure/persistence/paths.ts";
import { assertSafePath } from "../../infrastructure/persistence/safe_path.ts";

const logger = getLogger(["swamp", "models", "loader"]);

Expand Down Expand Up @@ -367,6 +368,8 @@ export class UserModelLoader {

// Bundle and write to cache
const js = await bundleExtension(absolutePath, denoPath);
const bundleBoundary = join(this.repoDir, SWAMP_DATA_DIR);
await assertSafePath(bundlePath, bundleBoundary);
await Deno.mkdir(dirname(bundlePath), { recursive: true });
await Deno.writeTextFile(bundlePath, js);
logger.debug`Wrote bundle cache: ${bundlePath}`;
Expand Down
5 changes: 5 additions & 0 deletions src/domain/vaults/local_encryption_vault_provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
SWAMP_SUBDIRS,
swampPath,
} from "../../infrastructure/persistence/paths.ts";
import { assertSafePath } from "../../infrastructure/persistence/safe_path.ts";

/**
* Configuration options for local encryption vault.
Expand Down Expand Up @@ -62,6 +63,7 @@ export class LocalEncryptionVaultProvider implements VaultProvider {
private readonly name: string;
private readonly config: LocalEncryptionConfig;
private readonly vaultDir: string;
private readonly secretsBoundary: string;
/** Cache for key material (not the derived key, since each secret has unique salt) */
private keyMaterialCache?: CryptoKey;

Expand All @@ -71,6 +73,7 @@ export class LocalEncryptionVaultProvider implements VaultProvider {
// Compute secrets directory from base_dir + vault name
// Path: {base_dir}/.swamp/secrets/local_encryption/{vault_name}
const baseDir = config.base_dir ?? Deno.cwd();
this.secretsBoundary = swampPath(baseDir);
this.vaultDir = swampPath(
baseDir,
SWAMP_SUBDIRS.secrets,
Expand Down Expand Up @@ -114,6 +117,7 @@ export class LocalEncryptionVaultProvider implements VaultProvider {
const encryptedData = await this.encrypt(secretValue, masterKey, salt);

const encryptedFilePath = join(this.vaultDir, `${secretKey}.enc`);
await assertSafePath(encryptedFilePath, this.secretsBoundary);
await atomicWriteTextFile(
encryptedFilePath,
JSON.stringify(encryptedData, null, 2),
Expand Down Expand Up @@ -480,6 +484,7 @@ export class LocalEncryptionVaultProvider implements VaultProvider {
* Ensures the vault directory exists.
*/
private async ensureVaultDirectory(): Promise<void> {
await assertSafePath(this.vaultDir, this.secretsBoundary);
try {
await Deno.mkdir(this.vaultDir, { recursive: true, mode: 0o700 });
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions src/domain/workflows/execution_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,10 +803,12 @@ export class WorkflowExecutionService {
`workflow-run-${run.id}.log`,
);
const workflowLogCategory: string[] = [];
const workflowLogBoundary = swampPath(this.repoDir);
await runFileSink.register(
workflowLogCategory,
workflowLogPath,
secretRedactor,
workflowLogBoundary,
);
run.setLogFile(workflowLogPath);

Expand Down
7 changes: 7 additions & 0 deletions src/infrastructure/logging/run_file_sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import { getTextFormatter, type LogRecord, type Sink } from "@logtape/logtape";
import type { SecretRedactor } from "../../domain/secrets/mod.ts";
import { assertSafePath } from "../persistence/safe_path.ts";

/**
* Formats a LogRecord as a plain text line for file output.
Expand Down Expand Up @@ -69,6 +70,7 @@ export class RunFileSink {
categoryPrefix: string[],
filePath: string,
redactor?: SecretRedactor,
boundary?: string,
): Promise<void> {
const key = prefixKey(categoryPrefix);
// Close existing writer if any
Expand All @@ -77,6 +79,11 @@ export class RunFileSink {
existing.fd.close();
}

// Validate the file path stays within the expected boundary
if (boundary) {
await assertSafePath(filePath, boundary);
}

// Ensure parent directory exists
const dir = filePath.substring(0, filePath.lastIndexOf("/"));
if (dir) {
Expand Down
2 changes: 2 additions & 0 deletions src/infrastructure/persistence/json_telemetry_repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { join } from "@std/path";
import { atomicWriteTextFile } from "./atomic_write.ts";
import type { TelemetryRepository } from "../../domain/telemetry/repositories.ts";
import { SWAMP_SUBDIRS, swampPath } from "./paths.ts";
import { assertSafePath } from "./safe_path.ts";
import {
TelemetryEntry,
type TelemetryEntryData,
Expand All @@ -46,6 +47,7 @@ export class JsonTelemetryRepository implements TelemetryRepository {
async save(entry: TelemetryEntry): Promise<void> {
try {
const telemetryDir = this.getTelemetryDir();
await assertSafePath(telemetryDir, swampPath(this.repoDir));
await ensureDir(telemetryDir);

const date = entry.startedAt.toISOString().split("T")[0];
Expand Down
116 changes: 116 additions & 0 deletions src/infrastructure/persistence/safe_path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Swamp, an Automation Framework
// Copyright (C) 2026 System Initiative, Inc.
//
// This file is part of Swamp.
//
// Swamp is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License version 3
// as published by the Free Software Foundation, with the Swamp
// Extension and Definition Exception (found in the "COPYING-EXCEPTION"
// file).
//
// Swamp is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with Swamp. If not, see <https://www.gnu.org/licenses/>.

import { dirname, join, resolve } from "@std/path";

/**
* Error thrown when a path resolves outside its expected boundary,
* typically due to a symlink pointing outside the repository.
*/
export class PathTraversalError extends Error {
readonly path: string;
readonly boundary: string;
readonly resolvedTarget: string;

constructor(path: string, boundary: string, resolvedTarget: string) {
super(
`Path traversal detected: "${path}" resolves to "${resolvedTarget}" which is outside boundary "${boundary}"`,
);
this.name = "PathTraversalError";
this.path = path;
this.boundary = boundary;
this.resolvedTarget = resolvedTarget;
}
}

/**
* Resolves a path to its real filesystem location, handling the case where
* the path (or parts of it) may not exist yet.
*
* If the path exists, uses `Deno.realPath()` directly. Otherwise, walks up
* to the nearest existing ancestor, resolves that, and appends the remaining
* non-existent segments.
*/
async function resolveRealPath(path: string): Promise<string> {
const normalized = resolve(path);

try {
return await Deno.realPath(normalized);
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) {
throw e;
}
}

// Path doesn't exist — walk up to find the nearest existing ancestor
const segments: string[] = [];
let current = normalized;

while (true) {
const parent = dirname(current);
if (parent === current) {
// Reached filesystem root without finding an existing path;
// fall back to the lexically resolved path
return normalized;
}

segments.unshift(current.slice(parent.length + 1));
current = parent;

try {
const realParent = await Deno.realPath(current);
return join(realParent, ...segments);
} catch (e) {
if (!(e instanceof Deno.errors.NotFound)) {
throw e;
}
// Keep walking up
}
}
}

/**
* Asserts that `path` resolves (following symlinks) to a location within
* `boundary`.
*
* This protects against symlink-based path traversal attacks where a
* directory like `.swamp/outputs` is replaced with a symlink pointing
* outside the repository.
*
* The check handles paths that don't yet exist by resolving the nearest
* existing ancestor and appending the remaining segments.
*
* @param path - The path to validate
* @param boundary - The directory the path must stay within
* @throws {PathTraversalError} if the resolved path escapes the boundary
*/
export async function assertSafePath(
path: string,
boundary: string,
): Promise<void> {
const resolvedBoundary = await resolveRealPath(boundary);
const resolvedPath = await resolveRealPath(path);

if (
resolvedPath !== resolvedBoundary &&
!resolvedPath.startsWith(resolvedBoundary + "/")
) {
throw new PathTraversalError(path, boundary, resolvedPath);
}
}
Loading