diff --git a/packages/opencode/src/altimate/native/altimate-core.ts b/packages/opencode/src/altimate/native/altimate-core.ts index 780f4ce21..84217553b 100644 --- a/packages/opencode/src/altimate/native/altimate-core.ts +++ b/packages/opencode/src/altimate/native/altimate-core.ts @@ -114,7 +114,7 @@ export function registerAll(): void { register("altimate_core.safety", async (params) => { try { const raw = core.scanSql(params.sql) - const data = toData(raw) + const data = EngineCoerce.redactScan(toData(raw)) return ok(true, data) } catch (e) { return fail(e) @@ -229,6 +229,10 @@ export function registerAll(): void { // Unscannable base — keep the full head scan (fail open to MORE findings). } } + // Redact AFTER diff-scoping: subtraction must compare raw engine + // matched_patterns on both sides (redacting first would rewrite head + // multi_statement keys to "" and never match the base). + safety = EngineCoerce.redactScan(safety) // PII exposure for the composite check — the tool has always rendered a // PII section; previously nothing populated it. Additive: a PII failure // must not fail the whole composite. @@ -470,7 +474,13 @@ export function registerAll(): void { try { const schema = schemaOrEmpty(params.schema_path, params.schema_context) const raw = await core.evaluate(params.sql, schema) - return ok(true, toData(raw)) + const data = toData(raw) + // EvalResult embeds a full safety scan — redact its threat echoes too + // (the CLI grade check renders nested threat messages). + if (data.safety && typeof data.safety === "object") { + data.safety = EngineCoerce.redactScan(data.safety as Record) + } + return ok(true, data) } catch (e) { return fail(e) } diff --git a/packages/opencode/src/altimate/native/engine-coerce.ts b/packages/opencode/src/altimate/native/engine-coerce.ts index b72ffae04..62f300ff3 100644 --- a/packages/opencode/src/altimate/native/engine-coerce.ts +++ b/packages/opencode/src/altimate/native/engine-coerce.ts @@ -59,4 +59,67 @@ export function piiColumnsFromReport(piiData: unknown): Array c && c.classification !== "None") } +/** + * Redact raw input echoed inside engine threat messages. + * + * The engine's `multi_statement` rule quotes the offending "statement type" + * verbatim — for non-SQL input (e.g. `altimate check /etc/passwd`) that + * reflects arbitrary file content back into CLI/tool output. A token is kept + * only when it BOTH looks like a short keyword phrase AND starts with a known + * SQL statement keyword — shape alone would pass content like + * "TOP SECRET PASSWORD" or "AKIAIOSFODNN7EXAMPLE". + */ +const REDACTED = "" +const SQL_TOKEN_SHAPE = /^[A-Za-z_][A-Za-z0-9_$ ]{0,31}$/ +const SQL_STATEMENT_KEYWORDS = new Set([ + "SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE", + "GRANT", "REVOKE", "MERGE", "WITH", "BEGIN", "COMMIT", "ROLLBACK", "SET", + "USE", "CALL", "EXPLAIN", "ANALYZE", "VACUUM", "COPY", "SHOW", "DESCRIBE", + "DESC", "EXECUTE", "EXEC", "REPLACE", "VALUES", "LOCK", "UNLOCK", "COMMENT", + "RENAME", "START", "SAVEPOINT", "RELEASE", "PREPARE", "DEALLOCATE", +]) +// The engine emits SINGLE-WORD statement types only — probed against the +// live 0.7.0 binary across DDL/DML/DCL/TCL second statements, the full +// observed set is {ALTER, BEGIN, CALL, CREATE, DELETE, DROP, EXPLAIN, GRANT, +// INSERT, SET, TRUNCATE, UPDATE}. Exact single-keyword matching is therefore +// both engine-faithful and the strictest rule: multi-word input can only be +// non-engine content (kills keyword-permutation leaks like +// "SELECT DELETE UPDATE" without redacting anything the engine produces). +function isSqlStatementType(token: string): boolean { + const t = token.trim() + if (!SQL_TOKEN_SHAPE.test(t)) return false + return SQL_STATEMENT_KEYWORDS.has(t.toUpperCase()) +} +export function redactThreatText(text: string): string { + return ( + text + .replace(/(Disallowed statement type: )(.+)$/i, (_m, prefix: string, tok: string) => + isSqlStatementType(tok) ? `${prefix}${tok}` : `${prefix}${REDACTED}`, + ) + // GREEDY quoted match — an attacker apostrophe inside the token would + // otherwise close the match early and leak the remainder + // (e.g. "Statement type 'DROP'ROOT:X:0' is not…"). Greedy capture spans + // to the LAST quote; embedded quotes fail the shape check and redact. + .replace(/(Statement type ')(.*)(')/i, (_m, a: string, tok: string, c: string) => + isSqlStatementType(tok) ? `${a}${tok}${c}` : `${a}${REDACTED}${c}`, + ) + ) +} + +/** + * Redact raw-input echoes from a scan result's threats. For `multi_statement` + * the matched pattern IS the raw input line, so it is always redacted; + * injection rules keep their SQL-shaped patterns. Pure record manipulation — + * safe to use from any consumer without loading the NAPI binding. + */ +export function redactScan(scan: Record): Record { + const threats = (scan.threats as any[] | undefined)?.map((t: any) => ({ + ...t, + message: typeof t.message === "string" ? redactThreatText(t.message) : t.message, + detail: typeof t.detail === "string" ? redactThreatText(t.detail) : t.detail, + matched_pattern: t.rule === "multi_statement" ? "" : t.matched_pattern, + })) + return threats ? { ...scan, threats } : scan +} + export * as EngineCoerce from "./engine-coerce" diff --git a/packages/opencode/src/altimate/native/sql/register.ts b/packages/opencode/src/altimate/native/sql/register.ts index 443d54ddf..0d3a76c24 100644 --- a/packages/opencode/src/altimate/native/sql/register.ts +++ b/packages/opencode/src/altimate/native/sql/register.ts @@ -38,7 +38,8 @@ export function registerAllSql(): void { const lint = JSON.parse(JSON.stringify(lintRaw)) const semantics = JSON.parse(JSON.stringify(semanticsRaw)) - const safety = JSON.parse(JSON.stringify(safetyRaw)) + // Redact raw-input echoes (multi_statement quotes the input verbatim). + const safety = EngineCoerce.redactScan(JSON.parse(JSON.stringify(safetyRaw))) as any const issues: SqlAnalyzeIssue[] = [] diff --git a/packages/opencode/test/altimate/threat-redaction.test.ts b/packages/opencode/test/altimate/threat-redaction.test.ts new file mode 100644 index 000000000..cc977a295 --- /dev/null +++ b/packages/opencode/test/altimate/threat-redaction.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test, beforeAll, afterAll } from "bun:test" +import { EngineCoerce } from "../../src/altimate/native/engine-coerce" + +let coreAvailable = false +try { + require.resolve("@altimateai/altimate-core") + coreAvailable = true +} catch {} +const describeIf = coreAvailable ? describe : describe.skip + +describe("redactThreatText", () => { + test("keeps single SQL-statement-keyword types (the only shape the engine emits)", () => { + expect(EngineCoerce.redactThreatText("Disallowed statement type: DROP")).toBe("Disallowed statement type: DROP") + expect(EngineCoerce.redactThreatText("Statement type 'ALTER' is not in the allowed list")).toBe( + "Statement type 'ALTER' is not in the allowed list", + ) + }) + + test("redacts non-SQL content (e.g. /etc/passwd lines)", () => { + const msg = "Disallowed statement type: ROOT:X:0:0:ROOT:/ROOT:/BIN/BASH" + expect(EngineCoerce.redactThreatText(msg)).toBe("Disallowed statement type: ") + const detail = "Statement type 'ROOT:X:0:0:ROOT:/ROOT:/BIN/BASH' is not in the allowed list: [\"SELECT\"]" + expect(EngineCoerce.redactThreatText(detail)).toContain("''") + expect(EngineCoerce.redactThreatText(detail)).not.toContain("ROOT:X:0") + }) + + test("redacts keyword-shaped but non-SQL content (allowlist, not shape)", () => { + // Shape alone would pass these — the first word must be a SQL statement keyword. + expect(EngineCoerce.redactThreatText("Disallowed statement type: TOP SECRET PASSWORD")).toBe( + "Disallowed statement type: ", + ) + expect(EngineCoerce.redactThreatText("Disallowed statement type: AKIAIOSFODNN7EXAMPLE")).toBe( + "Disallowed statement type: ", + ) + expect(EngineCoerce.redactThreatText("Statement type 'PRIVATE NOTE' is not in the allowed list")).toBe( + "Statement type '' is not in the allowed list", + ) + // ANY multi-word phrase redacts — the engine only emits single-word + // statement types (live-probed), so multi-word input is never engine + // vocabulary: keyword-prefixed tails and keyword permutations both die. + expect(EngineCoerce.redactThreatText("Disallowed statement type: SET SECRET PASSWORD")).toBe( + "Disallowed statement type: ", + ) + expect(EngineCoerce.redactThreatText("Disallowed statement type: DROP DEAD FRED")).toBe( + "Disallowed statement type: ", + ) + expect(EngineCoerce.redactThreatText("Disallowed statement type: SELECT DELETE UPDATE")).toBe( + "Disallowed statement type: ", + ) + }) + + test("embedded apostrophes cannot close the quoted match early", () => { + // Greedy capture spans to the LAST quote; the embedded quote fails the + // shape check and everything inside is redacted. + const attack = "Statement type 'DROP'ROOT:X:0:0' is not in the allowed list" + const out = EngineCoerce.redactThreatText(attack) + expect(out).not.toContain("ROOT:X:0") + expect(out).toContain("") + }) + + test("leaves unrelated messages untouched", () => { + expect(EngineCoerce.redactThreatText("Tautology attack detected (numeric constant comparison)")).toBe( + "Tautology attack detected (numeric constant comparison)", + ) + }) +}) + +// Sanity security test [6/15] regression: `altimate check /etc/passwd` must not +// reflect file contents in threat messages (found by the main-only Verdaccio +// sanity job after PR #1090 surfaced real threat messages). +describeIf("safety consumers redact raw-input echoes (real engine)", () => { + let D: typeof import("../../src/altimate/native/dispatcher") + let priorTelemetry: string | undefined + + beforeAll(async () => { + priorTelemetry = process.env.ALTIMATE_TELEMETRY_DISABLED + process.env.ALTIMATE_TELEMETRY_DISABLED = "true" + const { registerAll } = await import("../../src/altimate/native/altimate-core") + const { registerAllSql } = await import("../../src/altimate/native/sql/register") + registerAll() + registerAllSql() + D = await import("../../src/altimate/native/dispatcher") + }) + + afterAll(async () => { + if (priorTelemetry === undefined) delete process.env.ALTIMATE_TELEMETRY_DISABLED + else process.env.ALTIMATE_TELEMETRY_DISABLED = priorTelemetry + // registerAll mutates shared Dispatcher state — reset for test isolation. + ;(await import("../../src/altimate/native/dispatcher")).reset() + }) + + const PASSWD = "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n" + + test("altimate_core.safety does not echo non-SQL file content", async () => { + const r = await D.call("altimate_core.safety", { sql: PASSWD }) + expect(r.success).toBe(true) + const threats = ((r.data as any).threats ?? []) as any[] + expect(threats.length).toBeGreaterThan(0) + const text = JSON.stringify(threats) + expect(text.toLowerCase()).not.toContain("root:x:0") + expect(text).toContain("redacted") + }) + + test("composite check does not echo non-SQL file content in threats", async () => { + const r = await D.call("altimate_core.check", { sql: PASSWD }) + expect(r.success).toBe(true) + // The sanitizer must actually be exercised — an empty threat list would + // make the not-contains assertion vacuous. + const threats = (((r.data as any).safety?.threats ?? []) as any[]) + expect(threats.length).toBeGreaterThan(0) + const text = JSON.stringify(threats) + expect(text.toLowerCase()).not.toContain("root:x:0") + expect(text).toContain("redacted") + }) + + test("sql.analyze does not echo non-SQL file content in issues", async () => { + const r = await D.call("sql.analyze", { sql: PASSWD }) + const safetyIssues = (((r as any).issues ?? []) as any[]).filter((i) => i.type === "safety") + // The sanitizer must actually be exercised — no safety issues would make + // the not-contains assertion vacuous. + expect(safetyIssues.length).toBeGreaterThan(0) + const text = JSON.stringify(safetyIssues) + expect(text.toLowerCase()).not.toContain("root:x:0") + expect(text).toContain("redacted") + }) + + test("altimate_core.grade result does not echo non-SQL file content", async () => { + // EvalResult's safety section is { method, score } — no threat scan to + // echo (the handler's redactScan is defense-in-depth if that changes). + // Assert the whole result is echo-free, minus the `sql` field, which by + // contract carries back the caller's own input. + const r = await D.call("altimate_core.grade", { sql: PASSWD }) + expect(r.success).toBe(true) + // `sql` (EvalResult) and `lint.sql` (LintResult) carry back the caller's + // own input by contract and are never rendered by the CLI grade check. + const { sql: _echoedInput, ...rest } = r.data as any + if (rest.lint) rest.lint = { ...rest.lint, sql: undefined } + expect(JSON.stringify(rest).toLowerCase()).not.toContain("root:x:0") + }) + + test("diff-scoping still filters pre-existing multi_statement threats despite redaction", async () => { + // Regression (cursor review on #1111): redacting BEFORE base subtraction + // rewrote head matched_patterns to "" so base keys never matched + // and pre-existing threats resurfaced as introduced. Redaction now runs + // after subtraction: identical base/head must yield zero threats. + const r = await D.call("altimate_core.check", { sql: PASSWD, base_sql: PASSWD }) + expect((((r.data as any).safety?.threats ?? []) as any[]).length).toBe(0) + expect((r.data as any).safety.safe).toBe(true) + }) + + test("real SQL statement types are preserved in messages", async () => { + const r = await D.call("altimate_core.safety", { sql: "SELECT 1; DROP TABLE users;" }) + const threats = ((r.data as any).threats ?? []) as any[] + const messages = threats.map((t) => `${t.message} ${t.detail}`).join(" ") + // SQL-keyword statement types stay useful in messages/details + // (matched_pattern is always redacted for multi_statement by design). + expect(messages).toContain("DROP") + expect(messages).not.toContain("redacted") + }) +})