Skip to content
This repository was archived by the owner on Jun 9, 2026. It is now read-only.
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
17 changes: 17 additions & 0 deletions .opencode/plugins/adapters/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,23 @@ export const DANGEROUS_PATTERNS = [
/cat.*\.ssh\/id_/,
/cat.*\.aws\/credentials/,
/cat.*\.env/,

// === WP-B: Additional modern attack vectors ===
// Obfuscated RCE via base64 decode
/eval\s*\$\(\s*(echo|printf|cat)\s+.*\|\s*base64\s+-d/,
/\$\(curl\s+.*\)\s*\|\s*(ba)?sh/, // command substitution + pipe

// Environment variable exfiltration
/printenv\s*.*\|\s*(curl|wget|nc)/, // env dump + exfiltration
/env\s*\|?\s*grep\s+.*KEY.*\|\s*(curl|wget)/, // API key theft via grep

// Python/Node RCE one-liners
/python[23]?\s+-c\s+["'].*__import__.*os.*system/, // python -c "import os; os.system()"
/node\s+-e\s+["'].*require.*child_process/, // node -e "require('child_process')"

// SSH key theft / identity compromise
/cat\s+~\/\.ssh\/known_hosts/,
/ssh-keyscan/,
] as const;

/**
Expand Down
235 changes: 206 additions & 29 deletions .opencode/plugins/handlers/security-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,111 @@
* Validates tool executions for security threats.
* Equivalent to PAI's security-validator.ts hook.
*
* Enhanced in WP-B:
* - Comprehensive injection pattern detection (7 categories)
* - Input sanitization before pattern matching
* - Security audit logging to security-audit.jsonl
* - Multi-field scanning (not just args.content)
* - Fire-and-forget audit logging (non-blocking)
* - Secrets redaction in audit logs
*
* @module security-validator
*/

import * as fs from "node:fs";
import * as path from "node:path";
import type {
PermissionInput,
SecurityResult,
ToolInput,
} from "../adapters/types";
import { DANGEROUS_PATTERNS, WARNING_PATTERNS } from "../adapters/types";
import { fileLog, fileLogError } from "../lib/file-logger";
import {
detectInjections,
type InjectionCategory,
} from "../lib/injection-patterns";
import { getStateDir } from "../lib/paths";
import {
INJECTION_SCAN_FIELDS,
sanitizeForSecurityCheck,
} from "../lib/sanitizer";

/**
* Security audit log entry
*/
interface SecurityAuditEntry {
timestamp: string;
tool: string;
action: "blocked" | "confirmed" | "allowed";
reason: string;
pattern?: string;
category?: InjectionCategory;
commandPreview?: string; // First 100 chars, sanitized
}

/**
* Append to security audit log (non-blocking, fire-and-forget)
*
* @param entry - The audit entry to log
*/
function logSecurityEvent(entry: SecurityAuditEntry): void {
// Fire-and-forget: don't await, don't block the decision path
Promise.resolve()
.then(async () => {
const stateDir = getStateDir();
const auditPath = path.join(stateDir, "security-audit.jsonl");

// Ensure directory exists
await fs.promises.mkdir(stateDir, { recursive: true });

const line = `${JSON.stringify(entry)}\n`;
await fs.promises.appendFile(auditPath, line, "utf-8");
})
.catch(() => {
// Silent fail - audit logging should never block execution
fileLog("Failed to write security audit entry", "warn");
});
}

/**
* Redact sensitive values from command text
* Masks API keys, tokens, and credentials
*
* @param command - The command to redact
* @returns Redacted command
*/
function redactSecrets(command: string): string {
// API Keys and tokens
const redacted = command
// Anthropic API keys
.replace(/sk-ant-[A-Za-z0-9\-_]{20,}/g, "sk-ant-[REDACTED]")
// OpenAI API keys
.replace(/sk-[a-zA-Z0-9]{32,}/g, "sk-[REDACTED]")
// GitHub PATs
.replace(/gh[pousr]_[a-zA-Z0-9]{36,}/g, "gh[REDACTED]")
// AWS Access Keys
.replace(/\b(AKIA|ABIA|ACCA|ASIA)[0-9A-Z]{16}\b/g, "$1[REDACTED]")
// Groq API keys
.replace(/gsk_[a-zA-Z0-9]{52}/g, "gsk-[REDACTED]")
// HuggingFace tokens
.replace(/hf_[a-zA-Z0-9]{34,}/g, "hf-[REDACTED]")
// PEM private keys (redact content between headers)
.replace(
/(-----BEGIN\s+(?:[A-Z0-9]+\s+)?PRIVATE\s+KEY-----)[\s\S]*?(-----END\s+(?:[A-Z0-9]+\s+)?PRIVATE\s+KEY-----)/g,
"$1\n[REDACTED]\n$2",
)
// Generic high-entropy tokens ( heuristic: 40+ alphanumeric chars)
.replace(/\b[a-zA-Z0-9_-]{40,}\b/g, "[REDACTED]");

return redacted;
}

/**
* Check if a command matches any dangerous pattern
*
* @param command - The command to check
* @returns The matching pattern or null
*/
function matchesDangerousPattern(command: string): RegExp | null {
for (const pattern of DANGEROUS_PATTERNS) {
Expand All @@ -29,6 +121,9 @@ function matchesDangerousPattern(command: string): RegExp | null {

/**
* Check if a command matches any warning pattern
*
* @param command - The command to check
* @returns The matching pattern or null
*/
function matchesWarningPattern(command: string): RegExp | null {
for (const pattern of WARNING_PATTERNS) {
Expand All @@ -41,6 +136,9 @@ function matchesWarningPattern(command: string): RegExp | null {

/**
* Extract command from tool input
*
* @param input - The tool or permission input
* @returns The extracted command or null
*/
function extractCommand(input: PermissionInput | ToolInput): string | null {
// Normalize tool name to lowercase for comparison
Expand All @@ -66,24 +164,36 @@ function extractCommand(input: PermissionInput | ToolInput): string | null {
}

/**
* Check for prompt injection patterns in content
* Check all text fields in args for prompt injection patterns
*
* Scans all fields listed in INJECTION_SCAN_FIELDS, not just args.content.
* Sanitizes input before pattern matching to catch obfuscated attacks.
*
* @param args - The tool arguments to check
* @returns Match info if injection detected, null otherwise
*/
function checkPromptInjection(content: string): boolean {
const injectionPatterns = [
/ignore\s+(all\s+)?previous\s+instructions/i,
/you\s+are\s+now\s+/i,
/system\s*:\s*you\s+are/i,
/override\s+security/i,
/disable\s+safety/i,
];

for (const pattern of injectionPatterns) {
if (pattern.test(content)) {
return true;
function checkAllFieldsForInjection(args: Record<string, unknown>): {
field: string;
matches: ReturnType<typeof detectInjections>;
} | null {
for (const field of INJECTION_SCAN_FIELDS) {
const value = args[field];
if (typeof value !== "string") continue;

// Sanitize before pattern matching (catches obfuscated attacks)
const sanitized = sanitizeForSecurityCheck(value);

// Check original and sanitized versions
const matches = detectInjections(value);
const sanitizedMatches =
sanitized !== value ? detectInjections(sanitized) : [];

const allMatches = [...matches, ...sanitizedMatches];
if (allMatches.length > 0) {
return { field, matches: allMatches };
}
}

return false;
return null;
}

/**
Expand All @@ -104,9 +214,52 @@ export async function validateSecurity(

const command = extractCommand(input);

// Check for prompt injection in ALL text fields FIRST (even if no command)
const injectionResult = input.args
? checkAllFieldsForInjection(input.args)
: null;

if (injectionResult) {
const firstMatch = injectionResult.matches[0];
fileLog(
`BLOCKED: Prompt injection detected in field '${injectionResult.field}'`,
"error",
);
fileLog(
`Category: ${firstMatch.category}, Pattern: ${firstMatch.pattern}`,
"error",
);
logSecurityEvent({
timestamp: new Date().toISOString(),
tool: input.tool,
action: "blocked",
reason: `Prompt injection in ${injectionResult.field}`,
category: firstMatch.category,
pattern: firstMatch.pattern.toString(),
commandPreview: command
? redactSecrets(command).slice(0, 100)
: `${injectionResult.field}:${input.args?.[injectionResult.field]}`.slice(
0,
100,
),
});
return {
action: "block",
reason: `Potential prompt injection detected in field '${injectionResult.field}'`,
message:
"Content appears to contain prompt injection patterns and has been blocked.",
};
}

if (!command) {
fileLog(`No command extracted from input`, "warn");
// No command to validate - allow by default
// No command to validate - allow by default (injection check already passed)
logSecurityEvent({
timestamp: new Date().toISOString(),
tool: input.tool,
action: "allowed",
reason: "No command extracted",
});
return {
action: "allow",
reason: "No command to validate",
Expand All @@ -119,6 +272,14 @@ export async function validateSecurity(
const dangerousMatch = matchesDangerousPattern(command);
if (dangerousMatch) {
fileLog(`BLOCKED: Dangerous pattern matched: ${dangerousMatch}`, "error");
logSecurityEvent({
timestamp: new Date().toISOString(),
tool: input.tool,
action: "blocked",
reason: `Dangerous pattern: ${dangerousMatch}`,
pattern: dangerousMatch.toString(),
commandPreview: redactSecrets(command).slice(0, 100),
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return {
action: "block",
reason: `Dangerous command pattern detected: ${dangerousMatch}`,
Expand All @@ -127,23 +288,18 @@ export async function validateSecurity(
};
}

// Check for prompt injection in content
if (input.args?.content && typeof input.args.content === "string") {
if (checkPromptInjection(input.args.content)) {
fileLog("BLOCKED: Prompt injection detected", "error");
return {
action: "block",
reason: "Potential prompt injection detected in content",
message:
"Content appears to contain prompt injection patterns and has been blocked.",
};
}
}

// Check for warning patterns (CONFIRM)
const warningMatch = matchesWarningPattern(command);
if (warningMatch) {
fileLog(`CONFIRM: Warning pattern matched: ${warningMatch}`, "warn");
logSecurityEvent({
timestamp: new Date().toISOString(),
tool: input.tool,
action: "confirmed",
reason: `Warning pattern: ${warningMatch}`,
pattern: warningMatch.toString(),
commandPreview: redactSecrets(command).slice(0, 100),
});
return {
action: "confirm",
reason: `Potentially dangerous command: ${warningMatch}`,
Expand All @@ -168,6 +324,14 @@ export async function validateSecurity(
for (const pattern of sensitivePaths) {
if (pattern.test(filePath)) {
fileLog(`CONFIRM: Sensitive file write: ${filePath}`, "warn");
logSecurityEvent({
timestamp: new Date().toISOString(),
tool: input.tool,
action: "confirmed",
reason: `Sensitive file write: ${filePath}`,
pattern: pattern.toString(),
commandPreview: `write:${filePath}`.slice(0, 100),
});
return {
action: "confirm",
reason: `Writing to sensitive path: ${filePath}`,
Expand All @@ -180,6 +344,13 @@ export async function validateSecurity(

// All checks passed - allow
fileLog("Security check passed", "debug");
logSecurityEvent({
timestamp: new Date().toISOString(),
tool: input.tool,
action: "allowed",
reason: "All security checks passed",
commandPreview: redactSecrets(command).slice(0, 100),
});
return {
action: "allow",
reason: "All security checks passed",
Expand All @@ -188,6 +359,12 @@ export async function validateSecurity(
fileLogError("Security validation error", error);
// Fail-open: on error, allow the operation
// This is a design decision - fail-closed would be safer but more disruptive
logSecurityEvent({
timestamp: new Date().toISOString(),
tool: input.tool,
action: "allowed",
reason: "Security check error - fail-open",
});
return {
action: "allow",
reason: "Security check error - allowing by default",
Expand Down
Loading