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
2 changes: 2 additions & 0 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,5 @@ python-multipart>=0.0.9
xhtml2pdf>=0.2.17
aiosqlite>=0.20.0
python-whois>=0.9.4
httpx>=0.28.1
croniter>=2.0.0
16 changes: 14 additions & 2 deletions backend/secuscan/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ async def _create_schema(self):
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
schedule_seconds INTEGER,
cron_expression TEXT,
enabled BOOLEAN NOT NULL DEFAULT 1,
steps_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
Expand Down Expand Up @@ -251,9 +252,20 @@ async def _create_schema(self):
if col_name not in existing_finding_cols:
try:
await self.execute(f"ALTER TABLE findings ADD COLUMN {col_name} {col_type}")
print(f"Added missing column {col_name} to findings table.")
print(f"Added missing column '{col_name}' to findings table.")
except Exception as e:
print(f"Failed to add column {col_name}: {e}")
print(f"Failed to add '{col_name}' to findings: {e}")

# Workflows table migration
workflows_columns = await self.fetchall("PRAGMA table_info(workflows)")
existing_wf_cols = {col["name"] for col in workflows_columns}
if "cron_expression" not in existing_wf_cols:
try:
await self.execute("ALTER TABLE workflows ADD COLUMN cron_expression TEXT")
print("Added missing column 'cron_expression' to workflows table.")
except Exception as e:
print(f"Failed to add 'cron_expression' to workflows: {e}")


await self._backfill_risk_scores()

Expand Down
2 changes: 1 addition & 1 deletion backend/secuscan/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ async def load_plugins(self) -> int:

async def _load_plugin_metadata(self, metadata_file: Path) -> PluginMetadata:
"""Load and parse plugin metadata JSON"""
with open(metadata_file, 'r') as f:
with open(metadata_file, 'r', encoding='utf-8') as f:
data = json.load(f)

return PluginMetadata(**data)
Expand Down
13 changes: 11 additions & 2 deletions backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ def _serialize_workflow(row: Dict[str, Any], queued_task_ids: Optional[List[str]
"id": row["id"],
"name": row["name"],
"schedule_seconds": row.get("schedule_seconds"),
"cron_expression": row.get("cron_expression"),
"enabled": bool(row.get("enabled")),
"steps": _parse_workflow_steps(row.get("steps_json")),
"created_at": row.get("created_at"),
Expand Down Expand Up @@ -1032,17 +1033,21 @@ async def create_workflow(payload: Dict[str, Any]):

workflow_id = str(uuid.uuid4())
schedule_seconds = payload.get("schedule_seconds")
cron_expression = payload.get("cron_expression")
if cron_expression:
cron_expression = str(cron_expression).strip()
enabled = bool(payload.get("enabled", True))
db = await get_db()
await db.execute(
"""
INSERT INTO workflows (id, name, schedule_seconds, enabled, steps_json)
VALUES (?, ?, ?, ?, ?)
INSERT INTO workflows (id, name, schedule_seconds, cron_expression, enabled, steps_json)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
workflow_id,
name,
int(schedule_seconds) if schedule_seconds else None,
cron_expression if cron_expression else None,
1 if enabled else 0,
json.dumps(steps),
),
Expand Down Expand Up @@ -1095,6 +1100,10 @@ async def update_workflow(workflow_id: str, payload: Dict[str, Any]):
val = payload["schedule_seconds"]
updates.append("schedule_seconds = ?")
params.append(int(val) if val else None)
if "cron_expression" in payload:
cval = payload["cron_expression"]
updates.append("cron_expression = ?")
params.append(str(cval).strip() if cval else None)
if "enabled" in payload:
updates.append("enabled = ?")
params.append(1 if payload["enabled"] else 0)
Expand Down
34 changes: 27 additions & 7 deletions backend/secuscan/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import json
import logging
from datetime import datetime, timezone
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from croniter import croniter
from .database import get_db
from .executor import executor
logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -39,23 +40,24 @@ async def _run_loop(self):
await asyncio.sleep(5)
async def tick(self):
db = await get_db()
# Fetch workflows that have EITHER a schedule_seconds OR a cron_expression
rows = await db.fetchall(
"""
SELECT id, name, schedule_seconds, last_run_at, steps_json
SELECT id, name, schedule_seconds, cron_expression, last_run_at, steps_json
FROM workflows
WHERE enabled = 1 AND schedule_seconds IS NOT NULL AND schedule_seconds > 0
WHERE enabled = 1 AND ((schedule_seconds IS NOT NULL AND schedule_seconds > 0) OR cron_expression IS NOT NULL)
"""
)
now = datetime.now(timezone.utc)
for row in rows:
if not self._should_run(now, row.get("last_run_at"), int(row["schedule_seconds"])):
if not self._should_run(now, row.get("last_run_at"), row.get("schedule_seconds"), row.get("cron_expression")):
continue
await self._run_workflow(row["id"], json.loads(row.get("steps_json") or "[]"))
await db.execute(
"UPDATE workflows SET last_run_at = datetime('now') WHERE id = ?",
(row["id"],),
)
def _should_run(self, now: datetime, last_run_at: str | None, schedule_seconds: int) -> bool:
def _should_run(self, now: datetime, last_run_at: str | None, schedule_seconds: Optional[int], cron_expression: Optional[str]) -> bool:
if not last_run_at:
return True
last = datetime.fromisoformat(last_run_at.replace("Z", "+00:00"))
Expand All @@ -65,8 +67,26 @@ def _should_run(self, now: datetime, last_run_at: str | None, schedule_seconds:
# Treat any naive timestamp from the DB as UTC.
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
elapsed = (now - last).total_seconds()
return elapsed >= schedule_seconds

# 1. Cron logic (takes precedence if valid)
if cron_expression:
try:
# croniter computes the next valid time after the last_run_at time
cron = croniter(cron_expression, last)
next_run = cron.get_next(datetime)
if next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=timezone.utc)
return now >= next_run
except Exception as e:
logger.error(f"Invalid cron expression '{cron_expression}': {e}")
# Fallback to schedule_seconds if cron is invalid, else return False

# 2. Interval logic
if schedule_seconds and schedule_seconds > 0:
elapsed = (now - last).total_seconds()
return elapsed >= schedule_seconds

return False
async def _run_workflow(self, workflow_id: str, steps: List[Dict[str, Any]]):
logger.info("Running workflow %s with %d step(s)", workflow_id, len(steps))
for step in steps:
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ export interface Workflow {
id: string
name: string
schedule_seconds: number | null
cron_expression?: string | null
enabled: boolean
steps: WorkflowStep[]
last_run_at?: string | null
Expand All @@ -198,13 +199,15 @@ export interface Workflow {
export interface WorkflowCreatePayload {
name: string
schedule_seconds?: number | null
cron_expression?: string | null
enabled: boolean
steps: WorkflowStep[]
}

export interface WorkflowUpdatePayload {
name?: string
schedule_seconds?: number | null
cron_expression?: string | null
enabled?: boolean
steps?: WorkflowStep[]
}
Expand Down Expand Up @@ -237,6 +240,7 @@ function normalizeWorkflow(raw: any): Workflow {
id: String(raw.id),
name: String(raw.name ?? ''),
schedule_seconds: parseScheduleSeconds(raw.schedule_seconds),
cron_expression: raw.cron_expression ?? null,
enabled: Boolean(raw.enabled),
steps: parseWorkflowSteps(raw.steps ?? raw.steps_json),
last_run_at: raw.last_run_at ?? null,
Expand Down
86 changes: 70 additions & 16 deletions frontend/src/pages/Workflows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ function timeAgo(iso?: string | null) {
return `${Math.floor(hrs / 24)}d ago`
}

function formatSchedule(scheduleSeconds?: number | null) {
function formatSchedule(scheduleSeconds?: number | null, cronExpression?: string | null) {
if (cronExpression) return `Cron: ${cronExpression}`
if (!scheduleSeconds || scheduleSeconds <= 0) return 'Manual'
if (scheduleSeconds < 60) return `Every ${scheduleSeconds}s`

Expand Down Expand Up @@ -101,7 +102,9 @@ interface CreateSheetProps {

function CreateSheet({ onClose, onCreated }: CreateSheetProps) {
const [name, setName] = useState('')
const [scheduleType, setScheduleType] = useState<'interval' | 'cron'>('interval')
const [scheduleSeconds, setScheduleSeconds] = useState('3600')
const [cronExpression, setCronExpression] = useState('0 0 * * *')
const [enabled, setEnabled] = useState(true)
const [stepsJson, setStepsJson] = useState(JSON.stringify(emptySteps, null, 2))
const [jsonError, setJsonError] = useState<string | null>(null)
Expand All @@ -123,17 +126,26 @@ function CreateSheet({ onClose, onCreated }: CreateSheetProps) {

const trimmedSchedule = scheduleSeconds.trim()
const parsedSchedule = trimmedSchedule === '' ? null : Number(trimmedSchedule)
if (
parsedSchedule !== null &&
(!Number.isInteger(parsedSchedule) || parsedSchedule <= 0)
) {
if (scheduleType === 'interval' && parsedSchedule !== null && (!Number.isInteger(parsedSchedule) || parsedSchedule <= 0)) {
setError('Schedule must be a positive whole number of seconds')
return
}

const trimmedCron = cronExpression.trim()
if (scheduleType === 'cron' && trimmedCron === '') {
setError('Cron expression cannot be empty')
return
}

setLoading(true)
try {
const payload: WorkflowCreatePayload = { name, schedule_seconds: parsedSchedule, enabled, steps }
const payload: WorkflowCreatePayload = {
name,
schedule_seconds: scheduleType === 'interval' ? parsedSchedule : null,
cron_expression: scheduleType === 'cron' ? trimmedCron : null,
enabled,
steps
}
const created = await createWorkflow(payload)
onCreated(created)
} catch {
Expand Down Expand Up @@ -165,15 +177,57 @@ function CreateSheet({ onClose, onCreated }: CreateSheetProps) {
/>
</div>

<div className="space-y-2">
<label className="text-[10px] font-black text-silver-bright uppercase tracking-[0.2em]">Schedule (seconds)</label>
<input
value={scheduleSeconds}
onChange={e => setScheduleSeconds(e.target.value)}
placeholder="3600"
inputMode="numeric"
className="w-full bg-charcoal-dark border-4 border-black px-4 py-3 text-sm text-silver-bright font-mono placeholder:text-silver/30 focus:outline-none focus:border-rag-red transition-colors"
/>
<div className="space-y-4 border-t-4 border-black/10 pt-6">
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="scheduleType"
value="interval"
checked={scheduleType === 'interval'}
onChange={() => setScheduleType('interval')}
className="accent-rag-red"
/>
<span className="text-[10px] font-black text-silver-bright uppercase tracking-[0.2em]">Interval</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="scheduleType"
value="cron"
checked={scheduleType === 'cron'}
onChange={() => setScheduleType('cron')}
className="accent-rag-red"
/>
<span className="text-[10px] font-black text-silver-bright uppercase tracking-[0.2em]">Cron Expression</span>
</label>
</div>

{scheduleType === 'interval' ? (
<div className="space-y-2">
<label className="text-[10px] font-black text-silver-bright uppercase tracking-[0.2em]">Schedule (seconds)</label>
<input
value={scheduleSeconds}
onChange={e => setScheduleSeconds(e.target.value)}
placeholder="3600"
inputMode="numeric"
className="w-full bg-charcoal-dark border-4 border-black px-4 py-3 text-sm text-silver-bright font-mono placeholder:text-silver/30 focus:outline-none focus:border-rag-red transition-colors"
/>
</div>
) : (
<div className="space-y-2">
<label className="text-[10px] font-black text-silver-bright uppercase tracking-[0.2em]">Cron Expression</label>
<input
value={cronExpression}
onChange={e => setCronExpression(e.target.value)}
placeholder="0 0 * * *"
className="w-full bg-charcoal-dark border-4 border-black px-4 py-3 text-sm text-silver-bright font-mono placeholder:text-silver/30 focus:outline-none focus:border-rag-red transition-colors"
/>
<p className="text-[9px] font-mono text-silver/40 uppercase tracking-widest mt-1">
e.g., "0 0 * * *" (daily at midnight), "0 * * * *" (hourly)
</p>
</div>
)}
</div>

<div className="flex items-center justify-between">
Expand Down Expand Up @@ -243,7 +297,7 @@ function WorkflowCard({ workflow, onToggle, onRun, onDelete, running, toggling }
<div className="flex items-start justify-between gap-4">
<div className="space-y-1 min-w-0">
<h3 className="text-xl font-black text-silver-bright uppercase tracking-tight truncate">{workflow.name}</h3>
<p className="text-[10px] font-mono text-silver/40 uppercase tracking-widest">{formatSchedule(workflow.schedule_seconds)}</p>
<p className="text-[10px] font-mono text-silver/40 uppercase tracking-widest">{formatSchedule(workflow.schedule_seconds, workflow.cron_expression)}</p>
</div>
<span className={`shrink-0 px-2 py-1 text-[9px] font-black uppercase tracking-widest border-2 border-black ${workflow.enabled ? 'bg-rag-green text-black' : 'bg-charcoal-dark text-silver/40'}`}>
{workflow.enabled ? 'Enabled' : 'Disabled'}
Expand Down
1 change: 1 addition & 0 deletions frontend/testing/unit/api.workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe('workflow API helpers', () => {
id: 'wf-001',
name: 'Nightly Scan',
schedule_seconds: 3600,
cron_expression: null,
enabled: true,
steps: [{ plugin_id: 'nmap', inputs: {} }],
last_run_at: null,
Expand Down
1 change: 1 addition & 0 deletions frontend/testing/unit/pages/Workflows.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ describe('Workflows — create action', () => {
expect(vi.mocked(createWorkflow)).toHaveBeenCalledWith({
name: 'Nightly Scan',
schedule_seconds: 7200,
cron_expression: null,
enabled: true,
steps: [{ plugin_id: '', inputs: {} }],
})
Expand Down
Loading
Loading