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: 6 additions & 2 deletions src/cli/commands/model_method_run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import { coerceInputTypes, parseInputs } from "../input_parser.ts";
import { parseTags } from "./data_search.ts";
import { InputValidationService } from "../../domain/inputs/mod.ts";
import { SecretRedactor } from "../../domain/secrets/mod.ts";
import { join } from "@std/path";
import {
SWAMP_SUBDIRS,
Expand Down Expand Up @@ -152,6 +153,9 @@ export const modelMethodRunCommand = new Command()
// Create run logger for real-time output
const runLogger = getRunLogger(definition.name, methodName);

// Create secret redactor — populated during vault resolution, used by log sink and data writers
const redactor = new SecretRedactor();

// Register run file sink target for log persistence
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const logFilePath = join(
Expand All @@ -161,7 +165,7 @@ export const modelMethodRunCommand = new Command()
`${definition.id}-${timestamp}.log`,
);
const runLogCategory: string[] = [];
await runFileSink.register(runLogCategory, logFilePath);
await runFileSink.register(runLogCategory, logFilePath, redactor);

runLogger.info("Found model {name} ({type})", {
name: definition.name,
Expand Down Expand Up @@ -251,7 +255,7 @@ export const modelMethodRunCommand = new Command()

// Resolve runtime expressions (vault and env) at runtime (never persisted)
evaluatedDefinition = await evaluationService
.resolveRuntimeExpressionsInDefinition(evaluatedDefinition);
.resolveRuntimeExpressionsInDefinition(evaluatedDefinition, redactor);

runLogger.info("Executing method {method}", { method: methodName });

Expand Down
13 changes: 12 additions & 1 deletion src/domain/expressions/expression_evaluation_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
type ModelResolverRepositories,
} from "./model_resolver.ts";
import { CyclicDependencyError } from "./errors.ts";
import type { SecretRedactor } from "../secrets/mod.ts";
import {
CyclicDependencyError as TopoCyclicError,
type GraphNode,
Expand Down Expand Up @@ -363,10 +364,12 @@ export class ExpressionEvaluationService {
* This is the runtime phase — vault secrets and env variables are resolved here and never persisted.
*
* @param definition - The definition (may contain remaining ${{ vault.get(...) }} or ${{ env.* }} expressions)
* @param redactor - Optional SecretRedactor to register resolved secret values for redaction
* @returns A new definition with all runtime expressions resolved
*/
async resolveRuntimeExpressionsInDefinition(
definition: Definition,
redactor?: SecretRedactor,
): Promise<Definition> {
const definitionData = definition.toData();
const expressions = extractExpressions(definitionData);
Expand All @@ -383,6 +386,7 @@ export class ExpressionEvaluationService {
return await this.resolveRuntimeInExpressions(
definitionData,
runtimeExpressions,
redactor,
);
}

Expand All @@ -391,9 +395,13 @@ export class ExpressionEvaluationService {
* This is the runtime phase — vault secrets and env variables are resolved here and never persisted.
*
* @param data - The data (may contain remaining runtime expressions)
* @param redactor - Optional SecretRedactor to register resolved secret values for redaction
* @returns The data with all runtime expressions resolved
*/
async resolveRuntimeExpressionsInData(data: unknown): Promise<unknown> {
async resolveRuntimeExpressionsInData(
data: unknown,
redactor?: SecretRedactor,
): Promise<unknown> {
const expressions = extractExpressions(data);
const runtimeExpressions = expressions.filter((expr) =>
containsRuntimeExpression(expr.celExpression)
Expand All @@ -410,6 +418,7 @@ export class ExpressionEvaluationService {
if (containsVaultExpression(expr.celExpression)) {
resolvedCelExpr = await this.modelResolver.resolveVaultExpressions(
expr.celExpression,
redactor,
);
}
const value = this.celEvaluator.evaluate(resolvedCelExpr, {
Expand Down Expand Up @@ -444,6 +453,7 @@ export class ExpressionEvaluationService {
private async resolveRuntimeInExpressions(
definitionData: ReturnType<Definition["toData"]>,
runtimeExpressions: ExpressionLocation[],
redactor?: SecretRedactor,
): Promise<Definition> {
const evaluatedValues = new Map<string, unknown>();
for (const expr of runtimeExpressions) {
Expand All @@ -452,6 +462,7 @@ export class ExpressionEvaluationService {
if (containsVaultExpression(expr.celExpression)) {
resolvedCelExpr = await this.modelResolver.resolveVaultExpressions(
expr.celExpression,
redactor,
);
}
const value = this.celEvaluator.evaluate(resolvedCelExpr, {
Expand Down
8 changes: 7 additions & 1 deletion src/domain/expressions/model_resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type { UnifiedDataRepository } from "../../infrastructure/persistence/uni
import type { Data } from "../data/data.ts";
import { ModelNotFoundError } from "./errors.ts";
import { VaultService } from "../vaults/vault_service.ts";
import type { SecretRedactor } from "../secrets/mod.ts";

/**
* Builds env context from Deno environment variables.
Expand Down Expand Up @@ -766,9 +767,13 @@ export class ModelResolver {
* Resolves vault expressions in a string by evaluating vault.get() calls.
*
* @param value - The string that may contain vault expressions
* @param redactor - Optional SecretRedactor to register resolved secret values for redaction
* @returns The string with vault expressions resolved to actual secret values
*/
async resolveVaultExpressions(value: string): Promise<string> {
async resolveVaultExpressions(
value: string,
redactor?: SecretRedactor,
): Promise<string> {
// Pattern to match vault.get(vaultName, secretKey) expressions
// Handles both quoted and unquoted arguments
const vaultPattern =
Expand All @@ -788,6 +793,7 @@ export class ModelResolver {
const [fullMatch, , vaultName, , secretKey] = match;
try {
const secretValue = await vaultService.get(vaultName, secretKey);
redactor?.addSecret(secretValue);
// Escape special characters to prevent CEL parsing issues and injection attacks.
// For $ and `, we use a \\X prefix (two backslashes + char) so that:
// - CEL sees \\ → produces one \, then the char is a plain literal
Expand Down
20 changes: 20 additions & 0 deletions src/domain/secrets/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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/>.

export { SecretRedactor } from "./secret_redactor.ts";
60 changes: 60 additions & 0 deletions src/domain/secrets/secret_redactor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// 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/>.

/**
* Redacts secret values from text to prevent plaintext leakage into
* persisted data files and log output.
*/
export class SecretRedactor {
private secrets: Set<string> = new Set();

/**
* Registers a secret value for redaction.
* Values shorter than 3 characters are ignored to prevent false-positive redaction.
*/
addSecret(value: string): void {
if (value.length < 3) return;
this.secrets.add(value);
// Also add JSON-escaped version for JSON data files
const jsonEscaped = JSON.stringify(value).slice(1, -1);
if (jsonEscaped !== value) {
this.secrets.add(jsonEscaped);
}
}

/**
* Replaces all registered secret values in the text with `***`.
* Longer secrets are replaced first to handle substring overlap.
*/
redact(text: string): string {
if (this.secrets.size === 0) return text;
// Sort longest-first to handle substring overlap (e.g., "abc" vs "abcdef")
const sorted = Array.from(this.secrets).sort((a, b) => b.length - a.length);
let result = text;
for (const secret of sorted) {
result = result.split(secret).join("***");
}
return result;
}

/** Whether any secrets have been registered. */
get hasSecrets(): boolean {
return this.secrets.size > 0;
}
}
98 changes: 98 additions & 0 deletions src/domain/secrets/secret_redactor_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// 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 { assertEquals } from "@std/assert";
import { SecretRedactor } from "./secret_redactor.ts";

Deno.test("SecretRedactor", async (t) => {
await t.step("basic redaction replaces secret with ***", () => {
const redactor = new SecretRedactor();
redactor.addSecret("my-secret-value");
assertEquals(
redactor.redact("the password is my-secret-value here"),
"the password is *** here",
);
});

await t.step("secrets shorter than 3 chars are ignored", () => {
const redactor = new SecretRedactor();
redactor.addSecret("ab");
redactor.addSecret("");
redactor.addSecret("x");
assertEquals(redactor.hasSecrets, false);
assertEquals(redactor.redact("ab x test"), "ab x test");
});

await t.step("multiple secrets are all redacted", () => {
const redactor = new SecretRedactor();
redactor.addSecret("secret1");
redactor.addSecret("secret2");
assertEquals(
redactor.redact("secret1 and secret2 in text"),
"*** and *** in text",
);
});

await t.step("longer secrets are replaced before shorter substrings", () => {
const redactor = new SecretRedactor();
redactor.addSecret("abc");
redactor.addSecret("abcdef");
assertEquals(
redactor.redact("value is abcdef here"),
"value is *** here",
);
});

await t.step("JSON-escaped variants are redacted", () => {
const redactor = new SecretRedactor();
redactor.addSecret('value with "quotes" inside');
// The JSON-escaped version should also be redacted
assertEquals(
redactor.redact('data: value with \\"quotes\\" inside end'),
"data: *** end",
);
});

await t.step(
"redact returns text unchanged when no secrets registered",
() => {
const redactor = new SecretRedactor();
assertEquals(
redactor.redact("nothing to redact here"),
"nothing to redact here",
);
},
);

await t.step("hasSecrets property reflects state", () => {
const redactor = new SecretRedactor();
assertEquals(redactor.hasSecrets, false);
redactor.addSecret("my-secret");
assertEquals(redactor.hasSecrets, true);
});

await t.step("redacts multiple occurrences of the same secret", () => {
const redactor = new SecretRedactor();
redactor.addSecret("token123");
assertEquals(
redactor.redact("token123 then token123 again"),
"*** then *** again",
);
});
});
Loading