Skip to content
Open
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
9 changes: 9 additions & 0 deletions backend/secuscan/redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,14 @@
"stripe_key",
re.compile(r"(sk_(?:live|test)_[A-Za-z0-9]{24,})", re.IGNORECASE),
),
# Vault references (e.g. vault:secret_name or vault://secret_name)
(
"vault_ref",
re.compile(
r"((?:vault://|vault\s*:\s*)\s*)([A-Za-z0-9_\-\./]+)",
re.IGNORECASE,
),
),
# Generic long hex secrets often used as tokens (≥ 32 hex chars after label)
(
"hex_secret",
Expand All @@ -152,6 +160,7 @@
),
]


# ── Public API ────────────────────────────────────────────────────────────────


Expand Down
34 changes: 34 additions & 0 deletions backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,40 @@ async def get_plugin_schema(plugin_id: str):
raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}")


@router.post("/plugin/{plugin_id}/preview")
async def get_plugin_preview(plugin_id: str, payload: Dict[str, Any] = Body(...)):
"""Generate a preview of the command to be executed by a plugin, with sensitive values redacted."""
plugin_manager = await get_plugin_manager_for_request()
plugin = plugin_manager.get_plugin(plugin_id)
if not plugin:
raise HTTPException(status_code=404, detail=f"Plugin not found: {plugin_id}")

inputs = payload.get("inputs", {})

try:
# Check missing required fields
missing_fields = []
for field in plugin.fields:
if field.required:
val = inputs.get(field.id)
if val is None or val == "":
missing_fields.append(field.label)

if missing_fields:
raise ValueError(f"Missing required fields: {', '.join(missing_fields)}")

command_args = plugin_manager.build_command(plugin_id, inputs)
if not command_args:
raise ValueError("Failed to build command")

redacted_args = [redact(arg) for arg in command_args]
return {
"command": redacted_args
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))


@router.get("/presets", dependencies=[Depends(read_heavy_limiter)])
async def get_all_presets():
"""Get all plugin presets"""
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,17 @@ export function getTaskDiff(taskId: string): Promise<ScanDiff> {
return request<ScanDiff>(`/task/${taskId}/diff`)
}

export function previewCommand(
plugin_id: string,
inputs: Record<string, unknown>,
): Promise<{ command: string[] }> {
return request<{ command: string[] }>(`/plugin/${plugin_id}/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ inputs }),
})
}

export function startTask(
plugin_id: string,
inputs: Record<string, unknown>,
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/pages/ToolConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
PluginListItem,
PluginSchemaResponse,
startTask,
previewCommand,
} from '../api'
import { useToast } from '../components/ToastContext'
import { routePath, routes } from '../routes'
Expand Down Expand Up @@ -80,6 +81,8 @@ export default function ToolConfig() {
evidence_level: 'standard',
})
const fieldRefs = useRef<Record<string, HTMLElement | null>>({})
const [preview, setPreview] = useState<string>('')
const [previewError, setPreviewError] = useState<string>('')

useEffect(() => {
let cancelled = false
Expand Down Expand Up @@ -162,6 +165,36 @@ export default function ToolConfig() {
const missingBinaries = availability?.missing_binaries ?? []
const hasMissingBinaries = missingBinaries.length > 0

useEffect(() => {
if (!toolId || !plugin || !schema) return
if (hasValidationErrors) {
setPreview('')
setPreviewError('Fix highlighted validation errors to preview command.')
return
}

let active = true
async function updatePreview() {
try {
const response = await previewCommand(toolId!, inputs)
if (active) {
setPreview(response.command.join(' '))
setPreviewError('')
}
} catch (error: any) {
if (active) {
setPreview('')
setPreviewError(error?.message || 'Failed to generate command preview.')
}
}
}

updatePreview()
return () => {
active = false
}
}, [toolId, inputs, hasValidationErrors, plugin, schema])

const handleFieldChange = (field: PluginFieldSchema, value: unknown) => {
setInputs((prev) => ({ ...prev, [field.id]: value }))
}
Expand Down Expand Up @@ -468,6 +501,24 @@ export default function ToolConfig() {
})}
</div>
</section>

<section className="bg-charcoal border-4 border-black p-8 shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] space-y-4">
<h3 className="text-xs font-black text-silver-bright uppercase tracking-[0.4em] italic">Command_Preview</h3>
{previewError ? (
<p className="text-[10px] text-rag-amber uppercase tracking-widest font-black" role="alert">
⚠️ {previewError}
</p>
) : (
<div className="space-y-4">
<div className="bg-charcoal-dark border-4 border-black p-4 font-mono text-xs text-rag-blue break-all select-all">
{preview}
</div>
<p className="text-[9px] text-silver/40 uppercase tracking-widest leading-relaxed">
Note: This preview is generated locally/sanitized and does not guarantee copy-paste execution if runtime normalization changes values.
</p>
</div>
)}
</section>
</div>

<aside className="xl:col-span-1">
Expand Down
45 changes: 44 additions & 1 deletion frontend/testing/unit/pages/ToolConfigDynamic.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import ToolConfig from '../../../src/pages/ToolConfig'
import { getPluginSchema, listPlugins, startTask, getSettings, listTargetPolicies, listCredentialProfiles, listSessionProfiles } from '../../../src/api'
import { getPluginSchema, listPlugins, startTask, getSettings, listTargetPolicies, listCredentialProfiles, listSessionProfiles, previewCommand } from '../../../src/api'
import { routes } from '../../../src/routes'

const addToast = vi.fn()
Expand All @@ -19,10 +19,12 @@ vi.mock('../../../src/api', () => ({
listTargetPolicies: vi.fn(),
listCredentialProfiles: vi.fn(),
listSessionProfiles: vi.fn(),
previewCommand: vi.fn(),
}))

describe('ToolConfig dynamic schema flow', () => {
beforeEach(() => {
vi.mocked(previewCommand).mockResolvedValue({ command: ['subfinder', '-d', 'example.com'] })
addToast.mockReset()
vi.mocked(listPlugins).mockResolvedValue({
total: 1,
Expand Down Expand Up @@ -309,4 +311,45 @@ describe('ToolConfig dynamic schema flow', () => {
expect(screen.getByText('Must be a valid URL')).toBeInTheDocument()
expect(targetInput).toHaveAttribute('aria-describedby', 'help-target error-target')
})

it('renders command preview and updates on input changes', async () => {
const user = userEvent.setup()
vi.mocked(previewCommand).mockResolvedValue({
command: ['subfinder', '-d', 'example.com'],
})

render(
<MemoryRouter initialEntries={['/toolkit/subdomain_discovery']}>
<Routes>
<Route path={routes.scanTool} element={<ToolConfig />} />
</Routes>
</MemoryRouter>,
)

const targetInput = await screen.findByPlaceholderText('example.com')
await user.type(targetInput, 'example.com')

await waitFor(() => {
expect(previewCommand).toHaveBeenCalledWith(
'subdomain_discovery',
expect.objectContaining({ target: 'example.com' }),
)
})

expect(await screen.findByText('subfinder -d example.com')).toBeInTheDocument()
expect(screen.getByText(/Note: This preview is generated locally\/sanitized/i)).toBeInTheDocument()
})

it('shows validation error warning in preview panel if inputs are invalid', async () => {
render(
<MemoryRouter initialEntries={['/toolkit/subdomain_discovery']}>
<Routes>
<Route path={routes.scanTool} element={<ToolConfig />} />
</Routes>
</MemoryRouter>,
)

await screen.findByPlaceholderText('example.com')
expect(await screen.findByText(/Fix highlighted validation errors to preview command./i)).toBeInTheDocument()
})
})
17 changes: 17 additions & 0 deletions testing/backend/unit/test_redaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,3 +565,20 @@ def test_multiple_secrets_in_long_string(self):
assert REDACTED in result
assert "abcdefgh12345678" not in result
assert "ghijklmnopqrstuvwx" not in result


class TestVaultReferenceRedaction:
def test_vault_reference_colon(self):
text = "vault:my_secret_name"
result = redact(text)
assert result == "vault:[REDACTED]"

def test_vault_reference_slash(self):
text = "vault://my_other_secret"
result = redact(text)
assert result == "vault://[REDACTED]"

def test_vault_reference_case_insensitive(self):
text = "VAULT:secret-1"
result = redact(text)
assert result.upper() == "VAULT:[REDACTED]"
46 changes: 46 additions & 0 deletions testing/backend/unit/test_routes_preview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import pytest
from fastapi.testclient import TestClient
from backend.secuscan.main import app
from backend.secuscan.config import settings
from backend.secuscan.database import init_db
from backend.secuscan.plugins import init_plugins
from backend.secuscan import auth as auth_module
import asyncio

@pytest.fixture()
def client_with_key(setup_test_environment):
asyncio.run(init_db(settings.database_path))
asyncio.run(init_plugins(settings.plugins_dir))
api_key = auth_module.init_api_key(settings.data_dir)
with TestClient(app) as c:
yield c, api_key

def test_preview_command_endpoint(client_with_key):
client, api_key = client_with_key
headers = {"X-API-Key": api_key}

# Try with a valid plugin (http_inspector) and vault reference
response = client.post(
"/api/v1/plugin/http_inspector/preview",
json={"inputs": {"url": "http://127.0.0.1/?token=vault:my-secret-agent"}},
headers=headers
)
assert response.status_code == 200
data = response.json()
assert "command" in data
cmd_str = " ".join(data["command"])
assert "vault:[REDACTED]" in cmd_str
assert "http://127.0.0.1/?token=vault:[REDACTED]" in cmd_str

def test_preview_command_missing_required(client_with_key):
client, api_key = client_with_key
headers = {"X-API-Key": api_key}

# Missing 'url' field which is required for http_inspector
response = client.post(
"/api/v1/plugin/http_inspector/preview",
json={"inputs": {"timeout": 15}},
headers=headers
)
assert response.status_code == 400
assert "Missing required fields" in response.json()["detail"]
Loading