diff --git a/frontend/frontend/e2e/scan-workflow.spec.ts b/frontend/frontend/e2e/scan-workflow.spec.ts new file mode 100644 index 00000000..e69de29b diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 7c7fc0e0..2e1179c1 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -41,6 +41,7 @@ export interface PluginFieldSchema { help?: string options?: PluginFieldOption[] validation?: Record + sensitive?: boolean } export interface PluginAvailability { runnable: boolean diff --git a/frontend/src/components/CommandPreview.tsx b/frontend/src/components/CommandPreview.tsx new file mode 100644 index 00000000..dac048f4 --- /dev/null +++ b/frontend/src/components/CommandPreview.tsx @@ -0,0 +1,158 @@ +import React, { useCallback, useState } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import type { PluginFieldSchema } from '../api' +import { + buildCommandTokens, + getMissingFields, + tokensToString, + type PreviewToken, +} from '../utils/commandPreview' + +interface CommandPreviewProps { + commandTemplate: string[] + fields: PluginFieldSchema[] + inputs: Record +} + +function TokenChip({ token }: { token: PreviewToken }) { + const styles: Record = { + command: 'text-rag-green font-black', + flag: 'text-rag-blue', + value: 'text-silver-bright', + redacted: 'text-rag-amber font-mono bg-rag-amber/10 px-1 rounded', + missing: 'text-rag-red font-mono bg-rag-red/10 px-1 rounded animate-pulse', + placeholder: 'text-silver/40 italic', + } + + return ( + + {token.text} + + ) +} + +export default function CommandPreview({ commandTemplate, fields, inputs }: CommandPreviewProps) { + const [copied, setCopied] = useState(false) + const [expanded, setExpanded] = useState(true) + + const tokens = buildCommandTokens(commandTemplate, fields, inputs) + const missingFields = getMissingFields(fields, inputs) + const plainText = tokensToString(tokens) + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(plainText) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } catch { + // clipboard not available + } + }, [plainText]) + + return ( +
+ {/* ── Header bar ────────────────────────────────────────────────────── */} +
+
+ terminal + + Cmd_Preview + + + sanitized · local + +
+ +
+ + +
+
+ + {/* ── Token display ─────────────────────────────────────────────────── */} + + {expanded && ( + +
+
+                {tokens.map((token, i) => (
+                  
+                ))}
+              
+
+ + {/* ── Legend ──────────────────────────────────────────────────── */} +
+ + + + + +
+ + {/* ── Missing fields warning ───────────────────────────────────── */} + {missingFields.length > 0 && ( +
+ + warning + +
+

+ Missing required fields +

+

+ {missingFields.map((f) => f.label).join(', ')} +

+
+
+ )} + + {/* ── Disclaimer ──────────────────────────────────────────────── */} +

+ ⚠ This preview is locally generated and sanitized. Runtime normalisation may alter + the final command. Secrets and credentials are always redacted here. +

+
+ )} +
+
+ ) +} + +function LegendItem({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ) +} \ No newline at end of file diff --git a/frontend/src/pages/Reports.tsx b/frontend/src/pages/Reports.tsx index 047fffec..2cc4c8e3 100644 --- a/frontend/src/pages/Reports.tsx +++ b/frontend/src/pages/Reports.tsx @@ -30,8 +30,6 @@ type Report = { pages: number } -type ReportStatus = 'all' | 'ready' | 'generating' | 'failed' - const containerVariants = { hidden: { opacity: 0 }, visible: { @@ -69,8 +67,8 @@ export default function Reports() { const [reports, setReports] = useState([]) const [summary, setSummary] = useState({ total_findings: 0, total_assets: 0, critical_findings: 0, high_findings: 0, total_attack_surface: 0 }) const [selectedType, setSelectedType] = useState('all') - const [selectedStatus, setSelectedStatus] = useState('all') - const [selectedDateRange, setSelectedDateRange] = useState('all') + const [selectedStatus, setSelectedStatus] = useState('all') + const [selectedDate, setSelectedDate] = useState('all') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const { preferred, savePreference } = usePreferredExportFormat() @@ -95,11 +93,21 @@ export default function Reports() { fetchReports() }, []) - const filteredReports = reports.filter((report) => - (selectedType === 'all' || report.type === selectedType) && - (selectedStatus === 'all' || report.status === selectedStatus) && - isWithinDateRange(report.generated_at, selectedDateRange) - ) + const filteredReports = reports.filter((report) => { + if (selectedType !== 'all' && report.type !== selectedType) return false + if (selectedStatus !== 'all' && report.status !== selectedStatus) return false + if (selectedDate === '24h') { + const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000) + if (new Date(report.generated_at) < cutoff) return false + } else if (selectedDate === '7d') { + const cutoff = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + if (new Date(report.generated_at) < cutoff) return false + } else if (selectedDate === '30d') { + const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) + if (new Date(report.generated_at) < cutoff) return false + } + return true + }) return (
@@ -199,7 +207,7 @@ export default function Reports() { }`} > {t} BRIEFINGS - {selectedType === t &&
@@ -209,19 +217,19 @@ export default function Reports() {
- {([ - { value: 'all', label: 'All Statuses' }, - { value: 'ready', label: 'Ready' }, + {[ + { value: 'all', label: 'All Statuses' }, + { value: 'ready', label: 'Ready' }, + { value: 'failed', label: 'Failed' }, { value: 'generating', label: 'Generating' }, - { value: 'failed', label: 'Failed' }, - ] as const).map(({ value, label }) => ( + ].map(({ value, label }) => ( ))}
+ {/* Integrity Block */}
+ @@ -384,14 +394,18 @@ export default function Reports() { ))} {filteredReports.length === 0 && ( -
-
- )} +
+ +
+

Archive Isolated

+

+ {selectedStatus !== 'all' || selectedDate !== 'all' + ? 'No entries match the selected filters' + : 'System buffer awaiting briefing generation protocols'} +

+
+
+)} diff --git a/frontend/src/utils/commandPreview.ts b/frontend/src/utils/commandPreview.ts new file mode 100644 index 00000000..c95a3481 --- /dev/null +++ b/frontend/src/utils/commandPreview.ts @@ -0,0 +1,156 @@ +import type { PluginFieldSchema } from '../api' + +// ─── Sensitive field detection ──────────────────────────────────────────────── + +const SENSITIVE_PATTERNS: RegExp[] = [ + /\bpassword\b/, + /\bpasswd\b/, + /\bsecret\b/, + /\btoken\b/, + /\bapi[_\s-]?key\b/, + /\bapikey\b/, + /\bauth\b/, + /\bauthorization\b/, + /\bcookie\b/, + /\bcredential\b/, + /\bprivate[_\s-]?key\b/, + /\bvault\b/, + /\bbearer\b/, + /\baccess[_\s-]?key\b/, + /\bsecret[_\s-]?key\b/, +] + +export function isSensitiveField(field: PluginFieldSchema): boolean { + if (field.sensitive) return true + const haystack = `${field.id} ${field.label}`.toLowerCase() + return SENSITIVE_PATTERNS.some((re) => re.test(haystack)) +} + +// ─── Redaction ──────────────────────────────────────────────────────────────── + +export const REDACTED_PLACEHOLDER = '[REDACTED]' + +export function redactValue(field: PluginFieldSchema, value: unknown): string { + if (isSensitiveField(field)) return REDACTED_PLACEHOLDER + if (value === null || value === undefined || value === '') return '' + if (Array.isArray(value)) return value.join(',') + return String(value) +} + +// ─── Missing required fields ────────────────────────────────────────────────── + +export interface MissingField { + id: string + label: string +} + +export function getMissingFields( + fields: PluginFieldSchema[], + inputs: Record, +): MissingField[] { + return fields + .filter((field) => { + if (!field.required) return false + const val = inputs[field.id] + if (val === undefined || val === null) return true + if (typeof val === 'string') return val.trim().length === 0 + if (Array.isArray(val)) return val.length === 0 + return false + }) + .map((f) => ({ id: f.id, label: f.label })) +} + +// ─── Command token builder ──────────────────────────────────────────────────── + +export interface PreviewToken { + text: string + kind: 'command' | 'flag' | 'value' | 'redacted' | 'missing' | 'placeholder' +} + +/** + * Build a sanitized, tokenized preview of the command that will be executed. + * Handles the `--if:field:then:A:else:B` conditional syntax used in plugin + * command_template arrays, redacting sensitive values and marking missing ones. + */ +export function buildCommandTokens( + commandTemplate: string[], + fields: PluginFieldSchema[], + inputs: Record, +): PreviewToken[] { + const fieldMap = Object.fromEntries(fields.map((f) => [f.id, f])) + const tokens: PreviewToken[] = [] + + for (const segment of commandTemplate) { + // ── Conditional: --if:field:then:A[:else:B] ────────────────────────────── + const ifMatch = segment.match(/^--if:([^:]+):then:([^:]*)(?::else:(.*))?$/) + if (ifMatch) { + const [, fieldId, thenVal, elseVal] = ifMatch + const field = fieldMap[fieldId] + const rawValue = inputs[fieldId] + const hasValue = + rawValue !== undefined && + rawValue !== null && + rawValue !== '' && + rawValue !== false && + !(Array.isArray(rawValue) && rawValue.length === 0) + + const chosen = hasValue ? thenVal : (elseVal ?? '') + if (!chosen) continue + + // The chosen branch might itself be a flag (starts with -) or a literal + if (chosen.startsWith('-')) { + tokens.push({ text: chosen, kind: 'flag' }) + } else if (field && isSensitiveField(field) && hasValue) { + tokens.push({ text: REDACTED_PLACEHOLDER, kind: 'redacted' }) + } else { + tokens.push({ text: chosen, kind: 'value' }) + } + continue + } + + // ── Interpolated value: {fieldId} ───────────────────────────────────────── + const varMatch = segment.match(/^\{([^}]+)\}$/) + if (varMatch) { + const fieldId = varMatch[1] + const field = fieldMap[fieldId] + const rawValue = inputs[fieldId] + + if (!field) { + tokens.push({ text: segment, kind: 'placeholder' }) + continue + } + + if (isSensitiveField(field)) { + tokens.push({ text: REDACTED_PLACEHOLDER, kind: 'redacted' }) + continue + } + + const displayValue = redactValue(field, rawValue) + if (!displayValue) { + if (field.required) { + tokens.push({ text: `<${field.label}>`, kind: 'missing' }) + } + // optional empty → omit + continue + } + tokens.push({ text: displayValue, kind: 'value' }) + continue + } + + // ── Plain flag or binary name ───────────────────────────────────────────── + if (segment.startsWith('-')) { + tokens.push({ text: segment, kind: 'flag' }) + } else if (tokens.length === 0) { + tokens.push({ text: segment, kind: 'command' }) + } else { + tokens.push({ text: segment, kind: 'value' }) + } + } + + return tokens +} + +/** Render tokens to a plain string (for copy / aria-label). */ +export function tokensToString(tokens: PreviewToken[]): string { + return tokens.map((t) => t.text).join(' ') +} \ No newline at end of file diff --git a/frontend/testing/unit/pages/Reports.test.tsx b/frontend/testing/unit/pages/Reports.test.tsx index 3da4ecd5..92f1a5fc 100644 --- a/frontend/testing/unit/pages/Reports.test.tsx +++ b/frontend/testing/unit/pages/Reports.test.tsx @@ -1,10 +1,13 @@ -import { render, screen, waitFor } from '@testing-library/react' -import userEvent from '@testing-library/user-event' +import React from 'react' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { MemoryRouter } from 'react-router-dom' +import { vi, describe, it, expect } from 'vitest' +import userEvent from '@testing-library/user-event' import Reports from '../../../src/pages/Reports' import { getReports, getDashboardSummary } from '../../../src/api' import { isWithinDateRange } from '../../../src/utils/date' +// ─── Mock API ──────────────────────────────────────────────────────────────── vi.mock('../../../src/api', () => ({ getReports: vi.fn(), getDashboardSummary: vi.fn(), @@ -128,7 +131,7 @@ describe('Reports — export buttons on a ready report', () => { expect(screen.getByRole('button', { name: /^csv$/i })).toBeInTheDocument() }) - it('export buttons are enabled for a ready report', async () => { + it('shows empty state when combined status + date filters match nothing', async () => { renderReports() await screen.findByRole('button', { name: /^pdf$/i }) expect(screen.getByRole('button', { name: /^pdf$/i })).not.toBeDisabled() @@ -136,7 +139,7 @@ describe('Reports — export buttons on a ready report', () => { expect(screen.getByRole('button', { name: /^csv$/i })).not.toBeDisabled() }) - it('clicking PDF opens the correct backend URL', async () => { + it('entry count updates correctly when filters are applied', async () => { const user = userEvent.setup() renderReports() await user.click(await screen.findByRole('button', { name: /^pdf$/i })) @@ -152,8 +155,9 @@ describe('Reports — export buttons on a ready report', () => { expect.stringContaining('/task/' + readyReport.task_id + '/report/html'), '_blank') }) - it('clicking CSV opens the correct backend URL', async () => { + it('export buttons remain functional on filtered reports', async () => { const user = userEvent.setup() + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) renderReports() await user.click(await screen.findByRole('button', { name: /^csv$/i })) expect(openSpy).toHaveBeenCalledWith( diff --git a/frontend/testing/unit/utils/commandPreview.test.ts b/frontend/testing/unit/utils/commandPreview.test.ts new file mode 100644 index 00000000..0d720ad7 --- /dev/null +++ b/frontend/testing/unit/utils/commandPreview.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from 'vitest' +import { + isSensitiveField, + redactValue, + getMissingFields, + buildCommandTokens, + tokensToString, + REDACTED_PLACEHOLDER, +} from '../../../src/utils/commandPreview' +import type { PluginFieldSchema } from '../../../src/api' + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function field(overrides: Partial & { id: string; label: string; type?: PluginFieldSchema['type'] }): PluginFieldSchema { + return { type: 'string', ...overrides } +} + +// ─── isSensitiveField ───────────────────────────────────────────────────────── + +describe('isSensitiveField', () => { + it('returns true for fields with sensitive:true', () => { + expect(isSensitiveField(field({ id: 'foo', label: 'Foo', sensitive: true }))).toBe(true) + }) + + it.each([ + ['api_token', 'WPScan API Token'], + ['password', 'Password'], + ['auth_header', 'Authorization Header'], + ['cookie', 'Session Cookie'], + ['secret_key', 'Secret Key'], + ['bearer_token', 'Bearer Token'], + ['vault_path', 'Vault Reference'], + ['access_key', 'Access Key'], + ])('detects sensitive field: id=%s label=%s', (id, label) => { + expect(isSensitiveField(field({ id, label }))).toBe(true) + }) + + it('returns false for normal fields', () => { + expect(isSensitiveField(field({ id: 'target', label: 'Target URL' }))).toBe(false) + expect(isSensitiveField(field({ id: 'threads', label: 'Threads' }))).toBe(false) + expect(isSensitiveField(field({ id: 'enumerate', label: 'Enumeration Scope' }))).toBe(false) + }) +}) + +// ─── redactValue ───────────────────────────────────────────────────────────── + +describe('redactValue', () => { + it('redacts sensitive field values', () => { + const f = field({ id: 'api_token', label: 'API Token' }) + expect(redactValue(f, 'super-secret')).toBe(REDACTED_PLACEHOLDER) + }) + + it('returns empty string for empty non-sensitive values', () => { + const f = field({ id: 'target', label: 'Target' }) + expect(redactValue(f, '')).toBe('') + expect(redactValue(f, null)).toBe('') + expect(redactValue(f, undefined)).toBe('') + }) + + it('joins array values with commas', () => { + const f = field({ id: 'flags', label: 'Flags', type: 'multiselect' }) + expect(redactValue(f, ['a', 'b', 'c'])).toBe('a,b,c') + }) + + it('converts non-sensitive scalar values to string', () => { + const f = field({ id: 'threads', label: 'Threads', type: 'integer' }) + expect(redactValue(f, 10)).toBe('10') + }) +}) + +// ─── getMissingFields ───────────────────────────────────────────────────────── + +describe('getMissingFields', () => { + const fields: PluginFieldSchema[] = [ + field({ id: 'target', label: 'Target', required: true }), + field({ id: 'threads', label: 'Threads', type: 'integer', required: false }), + field({ id: 'scope', label: 'Scope', required: true }), + ] + + it('returns required fields with empty/absent values', () => { + const missing = getMissingFields(fields, { target: '', threads: 10, scope: '' }) + expect(missing.map((f) => f.id)).toEqual(['target', 'scope']) + }) + + it('returns empty array when all required fields are filled', () => { + const missing = getMissingFields(fields, { target: 'example.com', threads: 5, scope: 'full' }) + expect(missing).toHaveLength(0) + }) + + it('ignores optional fields', () => { + const missing = getMissingFields(fields, { target: 'example.com', scope: 'full' }) + expect(missing.map((f) => f.id)).not.toContain('threads') + }) +}) + +// ─── buildCommandTokens ─────────────────────────────────────────────────────── + +describe('buildCommandTokens', () => { + const wpscanTemplate = [ + 'wpscan', + '--url', + '{target}', + '--enumerate', + '{enumerate}', + '--if:api_token:then:--api-token', + '--if:api_token:then:{api_token}', + ] + + const wpscanFields: PluginFieldSchema[] = [ + field({ id: 'target', label: 'Target URL', required: true }), + field({ id: 'enumerate', label: 'Enumeration Scope' }), + field({ id: 'api_token', label: 'WPScan API Token' }), + ] + + it('builds tokens for a normal wpscan invocation', () => { + const tokens = buildCommandTokens(wpscanTemplate, wpscanFields, { + target: 'https://example.com', + enumerate: 'vp,vt', + api_token: '', + }) + const plain = tokensToString(tokens) + expect(plain).toContain('wpscan') + expect(plain).toContain('https://example.com') + expect(plain).toContain('vp,vt') + expect(plain).not.toContain('--api-token') + }) + + it('redacts the api_token secret and shows flag when token is set', () => { + const tokens = buildCommandTokens(wpscanTemplate, wpscanFields, { + target: 'https://example.com', + enumerate: 'vp', + api_token: 'my-secret-token', + }) + const plain = tokensToString(tokens) + expect(plain).toContain(REDACTED_PLACEHOLDER) + expect(plain).not.toContain('my-secret-token') + expect(plain).toContain('--api-token') + }) + + it('marks missing required fields', () => { + const tokens = buildCommandTokens(wpscanTemplate, wpscanFields, { + target: '', + enumerate: 'vp', + api_token: '', + }) + const missingToken = tokens.find((t) => t.kind === 'missing') + expect(missingToken).toBeDefined() + expect(missingToken?.text).toContain('Target URL') + }) + + it('first token has kind=command', () => { + const tokens = buildCommandTokens(wpscanTemplate, wpscanFields, { + target: 'https://example.com', + enumerate: 'vp', + api_token: '', + }) + expect(tokens[0].kind).toBe('command') + expect(tokens[0].text).toBe('wpscan') + }) + + it('uses else branch when conditional field is absent', () => { + const template = [ + 'nmap', + '--if:safe_mode:then:-T3:else:-T4', + '{target}', + ] + const fields = [ + field({ id: 'target', label: 'Target', required: true }), + field({ id: 'safe_mode', label: 'Safe Mode', type: 'boolean' }), + ] + const tokensOn = buildCommandTokens(template, fields, { target: '192.168.1.1', safe_mode: true }) + expect(tokensToString(tokensOn)).toContain('-T3') + + const tokensOff = buildCommandTokens(template, fields, { target: '192.168.1.1', safe_mode: false }) + expect(tokensToString(tokensOff)).toContain('-T4') + }) +}) \ No newline at end of file