fix(docker-watcher): phase 8 security fixes

Remove webhook secret from logs and API response.
Add auth-pending note to router. Fix decrypt fallback that
would use ciphertext as auth token on decrypt failure.
This commit is contained in:
2026-03-27 22:10:00 +03:00
parent 97d4243cfe
commit 757c72eea1
22 changed files with 1312 additions and 10 deletions
+320
View File
@@ -0,0 +1,320 @@
<script lang="ts">
import { api } from '$lib/api';
import type { InspectResult, QuickDeployRequest } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
let imageUrl = $state('');
let inspecting = $state(false);
let deploying = $state(false);
let inspected = $state(false);
let inspectResult: InspectResult | null = $state(null);
// Form fields populated after inspect
let projectName = $state('');
let port = $state('');
let healthcheck = $state('');
let stage = $state('dev');
let subdomain = $state('');
let envVars = $state('');
// Validation errors
let errors = $state<Record<string, string>>({});
function validateImageUrl(url: string): string {
if (!url.trim()) return 'Image URL is required';
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
return 'Invalid image URL format (e.g., registry.example.com/org/app:tag)';
}
return '';
}
function validatePort(value: string): string {
if (!value.trim()) return 'Port is required';
const num = parseInt(value, 10);
if (isNaN(num) || num < 1 || num > 65535) return 'Port must be between 1 and 65535';
return '';
}
function validateProjectName(value: string): string {
if (!value.trim()) return 'Project name is required';
if (!/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim()) && value.trim().length > 1) {
return 'Must be lowercase alphanumeric with hyphens (e.g., my-app)';
}
return '';
}
function validateAll(): boolean {
const newErrors: Record<string, string> = {};
const nameErr = validateProjectName(projectName);
if (nameErr) newErrors.projectName = nameErr;
const portErr = validatePort(port);
if (portErr) newErrors.port = portErr;
errors = newErrors;
return Object.keys(newErrors).length === 0;
}
async function handleInspect() {
const urlError = validateImageUrl(imageUrl);
if (urlError) {
errors = { imageUrl: urlError };
return;
}
errors = {};
inspecting = true;
try {
const result = await api.post<InspectResult>('/api/deploy/inspect', {
image: imageUrl.trim()
});
inspectResult = result;
// Auto-fill form with inspection results
projectName = result.project_name ?? '';
port = result.port?.toString() ?? '';
healthcheck = result.healthcheck ?? '';
stage = 'dev';
subdomain = result.suggested_subdomain ?? '';
envVars = result.env_vars ? result.env_vars.map((e) => `${e.key}=${e.value}`).join('\n') : '';
inspected = true;
toasts.success('Image inspected successfully');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to inspect image';
toasts.error(message);
} finally {
inspecting = false;
}
}
async function handleDeploy() {
if (!validateAll()) return;
deploying = true;
try {
const envMap: Record<string, string> = {};
if (envVars.trim()) {
for (const line of envVars.split('\n')) {
const eqIndex = line.indexOf('=');
if (eqIndex > 0) {
envMap[line.substring(0, eqIndex).trim()] = line.substring(eqIndex + 1).trim();
}
}
}
const payload: QuickDeployRequest = {
image: imageUrl.trim(),
project_name: projectName.trim(),
port: parseInt(port, 10),
healthcheck: healthcheck.trim() || undefined,
stage: stage,
subdomain: subdomain.trim() || undefined,
env: Object.keys(envMap).length > 0 ? envMap : undefined
};
await api.post('/api/deploy/quick', payload);
toasts.success(`Deployed ${projectName} successfully!`);
// Reset form
imageUrl = '';
inspected = false;
inspectResult = null;
projectName = '';
port = '';
healthcheck = '';
stage = 'dev';
subdomain = '';
envVars = '';
} catch (err) {
const message = err instanceof Error ? err.message : 'Deployment failed';
toasts.error(message);
} finally {
deploying = false;
}
}
</script>
<svelte:head>
<title>Quick Deploy - Docker Watcher</title>
</svelte:head>
<div class="mx-auto max-w-2xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-gray-900">Quick Deploy</h1>
<p class="mt-1 text-sm text-gray-500">
Deploy a container image with zero configuration. Paste an image URL, review the defaults,
and deploy.
</p>
</div>
<!-- Step 1: Image URL input -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">1. Enter Image URL</h2>
<div class="flex gap-3">
<div class="flex-1">
<FormField
label="Image URL"
name="imageUrl"
bind:value={imageUrl}
placeholder="registry.example.com/org/app:tag"
required
error={errors.imageUrl ?? ''}
helpText="Full image URL including tag (e.g., git.example.com/user/app:dev-abc123)"
disabled={inspecting}
/>
</div>
<div class="flex items-end">
<button
onclick={handleInspect}
disabled={inspecting || !imageUrl.trim()}
class="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if inspecting}
<span class="inline-flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
Inspecting...
</span>
{:else}
Inspect
{/if}
</button>
</div>
</div>
</div>
<!-- Step 2: Review and configure (shown after inspect) -->
{#if inspected}
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">2. Review Configuration</h2>
<p class="mb-4 text-sm text-gray-500">
These defaults were detected from the image. Adjust as needed before deploying.
</p>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
label="Project Name"
name="projectName"
bind:value={projectName}
placeholder="my-app"
required
error={errors.projectName ?? ''}
helpText="Lowercase with hyphens"
/>
<FormField
label="Port"
name="port"
type="number"
bind:value={port}
placeholder="3000"
required
error={errors.port ?? ''}
helpText="Container port to expose (1-65535)"
/>
<FormField
label="Health Check Path"
name="healthcheck"
bind:value={healthcheck}
placeholder="/api/health"
helpText="Optional HTTP path for health verification"
/>
<div class="flex flex-col gap-1">
<label for="stage" class="text-sm font-medium text-gray-700">Stage</label>
<select
id="stage"
bind:value={stage}
class="rounded-md border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="dev">Development</option>
<option value="rel">Release</option>
<option value="prod">Production</option>
</select>
<p class="text-xs text-gray-500">Deployment stage for this image</p>
</div>
<FormField
label="Subdomain Override"
name="subdomain"
bind:value={subdomain}
placeholder="auto-generated"
helpText="Leave empty to use the default subdomain pattern"
/>
</div>
<div class="mt-4">
<FormField
label="Environment Variables"
name="envVars"
type="textarea"
bind:value={envVars}
placeholder="KEY=value&#10;ANOTHER_KEY=another_value"
helpText="One per line, KEY=VALUE format"
/>
</div>
</div>
<!-- Step 3: Deploy -->
<div class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
<h2 class="mb-4 text-lg font-semibold text-gray-800">3. Deploy</h2>
<p class="mb-4 text-sm text-gray-500">
A new project will be created and the container will be deployed immediately.
</p>
<div class="flex gap-3">
<button
onclick={handleDeploy}
disabled={deploying}
class="rounded-md bg-green-600 px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{#if deploying}
<span class="inline-flex items-center gap-2">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
></path>
</svg>
Deploying...
</span>
{:else}
Deploy
{/if}
</button>
<button
onclick={() => {
inspected = false;
inspectResult = null;
}}
disabled={deploying}
class="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
{/if}
</div>
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/stores';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
const navItems = [
{ href: '/settings', label: 'General' },
{ href: '/settings/registries', label: 'Registries' },
{ href: '/settings/credentials', label: 'Credentials' }
];
let currentPath = $derived($page.url.pathname);
function isActive(href: string): boolean {
if (href === '/settings') {
return currentPath === '/settings';
}
return currentPath.startsWith(href);
}
</script>
<div class="mx-auto max-w-4xl">
<h1 class="mb-6 text-2xl font-bold text-gray-900">Settings</h1>
<div class="flex gap-6">
<!-- Sub-navigation -->
<nav class="w-48 flex-shrink-0">
<ul class="space-y-1">
{#each navItems as item}
<li>
<a
href={item.href}
class="block rounded-md px-3 py-2 text-sm font-medium transition-colors
{isActive(item.href)
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}"
>
{item.label}
</a>
</li>
{/each}
</ul>
</nav>
<!-- Settings content -->
<div class="flex-1">
{@render children()}
</div>
</div>
</div>