feat(security): severity-aware scheduled scan notifications (#654)

Enrich scheduled vulnerability scan completion notifications with
per-severity CVE counts so recipients can triage from the message body
alone. Expose the scan action in the schedule creation UI, require an
explicit node_id, and harden fire-and-forget alert dispatches so a
failing webhook cannot crash the scheduler.

Notification body now reports scanned/skipped/failed counts plus
critical/high/medium totals aggregated across fresh and cached scans,
reflecting the current node posture rather than only what was newly
scanned on this run.
This commit is contained in:
Anso
2026-04-17 11:08:46 -04:00
committed by GitHub
parent f9dc86bbfd
commit 29ed0524c1
9 changed files with 280 additions and 27 deletions
+107 -7
View File
@@ -47,7 +47,12 @@ const {
mockDispatchAlert: vi.fn().mockResolvedValue(undefined),
mockGetProxyTarget: vi.fn().mockReturnValue(null),
mockIsTrivyAvailable: vi.fn().mockReturnValue(true),
mockScanAllNodeImages: vi.fn().mockResolvedValue({ scanned: 0, skipped: 0, failed: 0 }),
mockScanAllNodeImages: vi.fn().mockResolvedValue({
scanned: 0,
skipped: 0,
failed: 0,
severity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 },
}),
}));
vi.mock('../services/DatabaseService', () => ({
@@ -777,9 +782,33 @@ describe('SchedulerService - scheduled scan notifications', () => {
};
}
function scanResult(opts: {
scanned?: number;
skipped?: number;
failed?: number;
critical?: number;
high?: number;
medium?: number;
low?: number;
unknown?: number;
} = {}) {
return {
scanned: opts.scanned ?? 0,
skipped: opts.skipped ?? 0,
failed: opts.failed ?? 0,
severity: {
critical: opts.critical ?? 0,
high: opts.high ?? 0,
medium: opts.medium ?? 0,
low: opts.low ?? 0,
unknown: opts.unknown ?? 0,
},
};
}
it('dispatches info-level notification when scan completes cleanly', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask());
mockScanAllNodeImages.mockResolvedValue({ scanned: 3, skipped: 1, failed: 0 });
mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 3, skipped: 1 }));
const svc = SchedulerService.getInstance();
await svc.triggerTask(200);
@@ -798,7 +827,7 @@ describe('SchedulerService - scheduled scan notifications', () => {
it('dispatches warning-level notification when scan has failures', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 201, name: 'flaky-scan' }));
mockScanAllNodeImages.mockResolvedValue({ scanned: 5, skipped: 0, failed: 2 });
mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 5, failed: 2 }));
const svc = SchedulerService.getInstance();
await svc.triggerTask(201);
@@ -812,7 +841,7 @@ describe('SchedulerService - scheduled scan notifications', () => {
it('passes target_id to dispatchAlert when set', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 202, target_id: 'web-stack' }));
mockScanAllNodeImages.mockResolvedValue({ scanned: 1, skipped: 0, failed: 0 });
mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 1 }));
const svc = SchedulerService.getInstance();
await svc.triggerTask(202);
@@ -828,9 +857,9 @@ describe('SchedulerService - scheduled scan notifications', () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({
id: 204,
name: 'recovered-scan',
last_status: 'failure', // Previous run failed
last_status: 'failure',
}));
mockScanAllNodeImages.mockResolvedValue({ scanned: 2, skipped: 0, failed: 0 });
mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 2 }));
const svc = SchedulerService.getInstance();
await svc.triggerTask(204);
@@ -853,7 +882,7 @@ describe('SchedulerService - scheduled scan notifications', () => {
target_id: 'my-stack',
node_id: 1,
created_by: 'admin',
last_status: null, // No previous failure → no recovery notification
last_status: null,
});
mockGetContainersByStack.mockResolvedValue([{ Id: 'c1', Service: 'web' }]);
@@ -862,6 +891,77 @@ describe('SchedulerService - scheduled scan notifications', () => {
expect(mockDispatchAlert).not.toHaveBeenCalled();
});
it('dispatches error-level notification when Trivy is unavailable', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 205, target_id: 'payment-stack' }));
mockIsTrivyAvailable.mockReturnValueOnce(false);
const svc = SchedulerService.getInstance();
await svc.triggerTask(205);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'error',
expect.stringMatching(/failed.*Trivy/i),
'payment-stack',
);
});
it('includes severity counts in the notification message', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 206 }));
mockScanAllNodeImages.mockResolvedValue(
scanResult({ scanned: 3, skipped: 1, critical: 2, high: 5, medium: 10 }),
);
const svc = SchedulerService.getInstance();
await svc.triggerTask(206);
const message = mockDispatchAlert.mock.calls[0][1] as string;
expect(message).toContain('2 critical');
expect(message).toContain('5 high');
expect(message).toContain('10 medium');
});
it('reports "No images to scan" when the node has nothing to scan', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 207 }));
mockScanAllNodeImages.mockResolvedValue(scanResult());
const svc = SchedulerService.getInstance();
await svc.triggerTask(207);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
expect.stringContaining('No images to scan'),
undefined,
);
});
it('reports "All N image(s) already scanned recently" when every image was cached', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 208 }));
mockScanAllNodeImages.mockResolvedValue(scanResult({ skipped: 12 }));
const svc = SchedulerService.getInstance();
await svc.triggerTask(208);
expect(mockDispatchAlert).toHaveBeenCalledWith(
'info',
expect.stringContaining('All 12 image(s) already scanned recently'),
undefined,
);
});
it('persists the run as success even when notification dispatch throws', async () => {
mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 209 }));
mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 1 }));
mockDispatchAlert.mockRejectedValueOnce(new Error('webhook down'));
const svc = SchedulerService.getInstance();
await expect(svc.triggerTask(209)).resolves.not.toThrow();
expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(
expect.any(Number),
expect.objectContaining({ status: 'success' }),
);
});
});
// ── Cleanup ────────────────────────────────────────────────────────────
+9
View File
@@ -6249,6 +6249,9 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => {
if (action === 'scan' && target_type !== 'system') {
res.status(400).json({ error: 'Scan action requires target_type "system".' }); return;
}
if (action === 'scan' && !node_id) {
res.status(400).json({ error: 'Scan action requires node_id.' }); return;
}
if (target_type === 'stack' && (!target_id || !node_id)) {
res.status(400).json({ error: 'Stack operations require target_id and node_id.' }); return;
}
@@ -6370,6 +6373,12 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => {
if (finalAction === 'scan' && finalTargetType !== 'system') {
res.status(400).json({ error: 'Scan action requires target_type "system".' }); return;
}
if (finalAction === 'scan') {
const finalNodeId = node_id !== undefined ? node_id : existing.node_id;
if (!finalNodeId) {
res.status(400).json({ error: 'Scan action requires node_id.' }); return;
}
}
// Validate prune targets
const validPruneTargets = ['containers', 'images', 'networks', 'volumes'];
+72 -11
View File
@@ -13,6 +13,7 @@ import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
import TrivyService from './TrivyService';
import type { ScanAllNodeImagesResult } from './TrivyService';
import TrivyInstaller from './TrivyInstaller';
const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
@@ -86,7 +87,7 @@ export class SchedulerService {
try {
await installer.update();
await trivy.detectTrivy();
NotificationService.getInstance().dispatchAlert(
this.safeDispatch(
'info',
`Trivy updated from v${previous} to v${check.latest}`,
);
@@ -97,7 +98,7 @@ export class SchedulerService {
} else {
const lastNotified = settings.trivy_last_notified_version || '';
if (lastNotified === check.latest) return;
NotificationService.getInstance().dispatchAlert(
this.safeDispatch(
'info',
`Trivy update available: v${check.latest} (currently v${check.current ?? 'unknown'})`,
);
@@ -139,6 +140,16 @@ export class SchedulerService {
return expr.next().toDate().getTime();
}
/**
* Fire a notification without awaiting completion, catching any promise
* rejection so the scheduler never crashes on a failed dispatch.
*/
private safeDispatch(level: 'info' | 'warning' | 'error', message: string, stackName?: string): void {
NotificationService.getInstance()
.dispatchAlert(level, message, stackName)
.catch(err => console.error('[SchedulerService] Notification dispatch failed:', getErrorMessage(err, 'unknown error')));
}
private async tick(): Promise<void> {
if (this.isProcessing) {
console.warn('[SchedulerService] Tick skipped: previous tick still processing');
@@ -280,13 +291,19 @@ export class SchedulerService {
});
console.log(`[SchedulerService] Task "${task.name}" (id=${task.id}) completed successfully`);
if (task.action === 'scan') {
NotificationService.getInstance().dispatchAlert(
scanFailedCount > 0 ? 'warning' : 'info',
const scanLevel: 'info' | 'warning' = scanFailedCount > 0 ? 'warning' : 'info';
if (isDebugEnabled()) {
console.log(
`[SchedulerService:debug] Dispatching scan completion notification (level=${scanLevel}, stackContext=${task.target_id ?? 'none'})`,
);
}
this.safeDispatch(
scanLevel,
`Scheduled scan "${task.name}" completed: ${output}`,
task.target_id ?? undefined
);
} else if (task.last_status === 'failure') {
NotificationService.getInstance().dispatchAlert(
this.safeDispatch(
'info',
`Scheduled task "${task.name}" (${task.action}) recovered successfully`,
task.target_id ?? undefined
@@ -321,7 +338,7 @@ export class SchedulerService {
error: errMsg,
});
console.error(`[SchedulerService] Task "${task.name}" (id=${task.id}) failed:`, errMsg);
NotificationService.getInstance().dispatchAlert(
this.safeDispatch(
'error',
`Scheduled task "${task.name}" (${task.action}) failed: ${errMsg}`,
task.target_id ?? undefined
@@ -598,7 +615,7 @@ export class SchedulerService {
await compose.updateStack(stackName, undefined, true);
db.clearStackUpdateStatus(nodeId, stackName);
NotificationService.getInstance().dispatchAlert(
this.safeDispatch(
'info',
`Auto-update: stack "${stackName}" updated with new images`,
stackName
@@ -618,11 +635,55 @@ export class SchedulerService {
console.log(`[SchedulerService:debug] Scan task ${task.id}: no node_id specified, using default node ${nodeId}`);
}
const scanStart = Date.now();
if (isDebugEnabled()) console.log(`[SchedulerService:debug] executeScan start: task=${task.id} node=${nodeId}`);
const summary = await trivy.scanAllNodeImages(nodeId, 'scheduled');
const parts: string[] = [`Scanned ${summary.scanned} image(s)`];
if (summary.skipped > 0) parts.push(`${summary.skipped} skipped (cached)`);
if (summary.failed > 0) parts.push(`${summary.failed} failed`);
return { output: parts.join('; '), failed: summary.failed };
if (isDebugEnabled()) {
console.log(
`[SchedulerService:debug] executeScan summary: scanned=${summary.scanned} skipped=${summary.skipped} failed=${summary.failed} ` +
`critical=${summary.severity.critical} high=${summary.severity.high} medium=${summary.severity.medium} ` +
`low=${summary.severity.low} unknown=${summary.severity.unknown} durationMs=${Date.now() - scanStart}`,
);
}
const output = formatScanOutput(summary);
return { output, failed: summary.failed };
}
}
/**
* Build the human-readable completion message from a bulk scan summary.
* Exported for unit tests.
*/
export function formatScanOutput(summary: ScanAllNodeImagesResult): string {
const { scanned, skipped, failed, severity } = summary;
let header: string;
if (scanned === 0 && skipped === 0 && failed === 0) {
header = 'No images to scan';
} else if (scanned === 0 && skipped > 0 && failed === 0) {
header = `All ${skipped} image(s) already scanned recently (cache hit)`;
} else {
const parts: string[] = [`Scanned ${scanned} image(s)`];
if (skipped > 0) parts.push(`${skipped} skipped (cached)`);
if (failed > 0) parts.push(`${failed} failed`);
header = parts.join('; ');
}
const severityTiers: Array<[string, number]> = [
['critical', severity.critical],
['high', severity.high],
['medium', severity.medium],
];
const nonZero = severityTiers.filter(([, n]) => n > 0);
if (nonZero.length === 0) {
if (scanned === 0 && skipped === 0 && failed === 0) {
return header + '.';
}
return `${header}. No critical, high, or medium findings.`;
}
const findings = nonZero.map(([label, n]) => `${n} ${label}`).join(', ');
return `${header}. Found ${findings}.`;
}
+35 -3
View File
@@ -77,6 +77,21 @@ interface TrivyRawOutput {
Results?: TrivyRawResult[];
}
export interface ScanAllNodeImagesSeverityTotals {
critical: number;
high: number;
medium: number;
low: number;
unknown: number;
}
export interface ScanAllNodeImagesResult {
scanned: number;
skipped: number;
failed: number;
severity: ScanAllNodeImagesSeverityTotals;
}
export interface TrivyVulnerability {
vulnerabilityId: string;
pkgName: string;
@@ -874,7 +889,7 @@ class TrivyService {
async scanAllNodeImages(
nodeId: number,
triggeredBy: VulnScanTrigger = 'scheduled',
): Promise<{ scanned: number; skipped: number; failed: number }> {
): Promise<ScanAllNodeImagesResult> {
if (this.source === 'none') {
throw new Error('Trivy is not available on this host');
}
@@ -889,26 +904,43 @@ class TrivyService {
let scanned = 0;
let skipped = 0;
let failed = 0;
const severity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
const countedDigests = new Set<string>();
const addSeverity = (row: VulnerabilityScan | null): void => {
if (!row) return;
severity.critical += row.critical_count;
severity.high += row.high_count;
severity.medium += row.medium_count;
severity.low += row.low_count;
severity.unknown += row.unknown_count;
};
for (const ref of imageRefs) {
try {
const digest = await this.getImageDigest(ref, nodeId);
if (digest) {
if (countedDigests.has(digest)) continue;
const cached =
DatabaseService.getInstance().getLatestScanByDigest(digest, 'vuln');
if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) {
skipped++;
addSeverity(cached);
countedDigests.add(digest);
continue;
}
}
await this.runScanAndPersist(ref, nodeId, triggeredBy, null);
const fresh = await this.runScanAndPersist(ref, nodeId, triggeredBy, null);
addSeverity(fresh);
scanned++;
if (digest) countedDigests.add(digest);
} catch (err) {
failed++;
console.warn(`[Trivy] Failed to scan ${ref}:`, getErrorMessage(err, 'unknown error'));
}
await new Promise((r) => setTimeout(r, 300));
}
return { scanned, skipped, failed };
return { scanned, skipped, failed, severity };
}
async generateSBOM(imageRef: string, format: SbomFormat): Promise<string> {
+14 -1
View File
@@ -166,8 +166,21 @@ Recurring [vulnerability scans](/features/vulnerability-scanning) dispatch a not
- **Info** when every image scanned successfully.
- **Warning** when one or more images failed to scan during the run.
- **Error** when the run itself could not start (for example, Trivy is not installed on the target node).
The message includes the scheduled task name and a summary of how many images were scanned, cached, and failed. Failures are typically transient (registry timeouts, missing credentials) and do not stop the rest of the run from completing.
The message includes the scheduled task name, a summary of how many images were scanned, cached, and failed, and a breakdown of findings by severity. A typical clean-run message looks like:
```
Scheduled scan "nightly-scan" completed: Scanned 12 image(s); 3 skipped (cached). Found 2 critical, 5 high, 10 medium.
```
If no critical, high, or medium findings are present, the message ends with `No critical, high, or medium findings.` so the outcome is still explicit. When the target node has nothing to scan, the message reads `No images to scan.`, and when every image was already covered by a recent cached scan it reads `All N image(s) already scanned recently (cache hit).` In both cases the notification still fires so you know the run executed.
<Note>
Severity counts reflect the current security posture of the node, aggregated across both freshly scanned images and cached scan results. They are not a delta of what changed on this run.
</Note>
Failures are typically transient (registry timeouts, missing credentials) and do not stop the rest of the run from completing.
## Container crash detection
+24 -3
View File
@@ -31,8 +31,8 @@ Scheduled Operations lets you automate recurring maintenance tasks across your i
2. Click **New Schedule**.
3. Fill in the form:
- **Name**: A descriptive label (e.g. "Nightly staging restart").
- **Action**: Choose Restart Stack, Fleet Snapshot, or System Prune. The form fields below change based on your selection.
- **Node**: (Restart Stack only) Select the node where the target stack runs.
- **Action**: Choose Restart Stack, Fleet Snapshot, System Prune, or Vulnerability Scan. The form fields below change based on your selection.
- **Node**: (Restart Stack and Vulnerability Scan) Select the node to run against. For Restart Stack it determines where the target stack lives; for Vulnerability Scan it determines which node's images are scanned.
- **Stack**: (Restart Stack only) Select the stack to restart. Becomes available after choosing a node.
- **Services**: (Restart Stack only) Optionally select specific services within the stack to restart. Leave empty to restart all services.
- **Prune Targets**: (System Prune only) Select which resources to prune: containers, images, networks, volumes. All are selected by default.
@@ -52,7 +52,7 @@ The task list is displayed as a table with the following columns:
| Column | Description |
|--------|-------------|
| **Name** | The task name |
| **Action** | Task type badge: Restart Stack, Fleet Snapshot, or System Prune |
| **Action** | Task type badge: Restart Stack, Fleet Snapshot, System Prune, or Vulnerability Scan |
| **Target** | Stack name (with selected services, if any), or the target type for non-stack actions |
| **Schedule** | Human-readable description with the raw cron expression below |
| **Status** | Last run result: **Success** (green), **Failed** (red), or "Never run" |
@@ -70,6 +70,12 @@ When creating a Restart Stack schedule, you can target individual services inste
<img src="/images/scheduled-operations/per-service-restart.png" alt="Service checkboxes displayed when creating a per-service restart schedule" />
</Frame>
### Scheduled Vulnerability Scans
A Vulnerability Scan task runs Trivy against every image on the selected node and persists the results. The scan uses the same digest-based 24-hour cache as manual scans, so unchanged images are not rescanned on every run. See [Vulnerability Scanning](/features/vulnerability-scanning) for how results are surfaced in the UI and [Installing Trivy](/operations/trivy-setup) for setup on each node.
When a scheduled scan finishes, Sencho dispatches a completion notification with a summary of what was scanned and a breakdown of findings by severity. The full message format is documented in [Alerts & Notifications → Scheduled scan completion](/features/alerts-notifications#scheduled-scan-completion).
### Prune Label Filter
When creating a System Prune schedule, you can scope the prune to resources matching a specific Docker label. This lets you target resources from a particular stack or project without affecting unrelated containers, images, or volumes.
@@ -130,6 +136,8 @@ When a scheduled task fails, Sencho automatically dispatches an **error-level al
When a previously failing task succeeds again, Sencho sends an **info-level recovery notification** to confirm the issue is resolved. This recovery-only approach avoids notification noise from tasks that succeed on every run.
Vulnerability Scan tasks always send a completion notification, even on a clean run, because the message carries severity counts you may want to react to. See [Alerts & Notifications → Scheduled scan completion](/features/alerts-notifications#scheduled-scan-completion) for the full message format.
To configure notification channels, go to **Settings > Notifications**.
<Frame>
@@ -183,3 +191,16 @@ If a task's cron expression becomes invalid after creation (for example, due to
### Run shows "Server restarted during execution"
This means Sencho was restarted (or crashed) while this task was mid-execution. The run was marked as failed automatically on startup. The task itself is still enabled and will run at its next scheduled time. If you want to re-run it immediately, use the **Run Now** button.
### Scheduled scan completed but no notification arrived
The scan notification shares the same delivery path as every other Sencho alert. Check, in order:
1. At least one channel is enabled in **Settings > Notifications** and the **Test** button succeeds for it.
2. If the stack the scan is associated with has notification routes defined, make sure at least one matching route is enabled; the routing layer takes priority over the global channels.
3. Open the notification bell. The in-app bell receives every notification regardless of channel configuration, and a failed external delivery is logged there as an error entry you can inspect.
4. On a remote node, notification channels must be configured on the remote instance itself; channel settings are per-node.
### Scan task fails with "Trivy binary is not available"
Trivy must be installed on the node that runs the scan, not just on the primary instance. Follow [Installing Trivy](/operations/trivy-setup) on the target node, then trigger **Run Now** from the schedule to confirm the scan succeeds before waiting for the next cron tick.
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -22,6 +22,7 @@ const ACTION_OPTIONS = [
{ value: 'restart', label: 'Restart Stack', targetType: 'stack' as const },
{ value: 'snapshot', label: 'Fleet Snapshot', targetType: 'fleet' as const },
{ value: 'prune', label: 'System Prune', targetType: 'system' as const },
{ value: 'scan', label: 'Vulnerability Scan', targetType: 'system' as const },
];
interface ScheduledOperationsViewProps {
@@ -194,6 +195,9 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
body.target_id = formTargetId;
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
}
if (formAction === 'scan') {
body.node_id = formNodeId ? parseInt(formNodeId, 10) : null;
}
if (formAction === 'prune' && formPruneTargets.length > 0) {
body.prune_targets = formPruneTargets;
}
@@ -480,6 +484,19 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
</>
)}
{formAction === 'scan' && (
<div className="space-y-2">
<Label>Node</Label>
<Combobox
options={nodes.map(n => ({ value: String(n.id), label: n.name }))}
value={formNodeId}
onValueChange={setFormNodeId}
placeholder="Select node..."
/>
<p className="text-xs text-muted-foreground">Every image on the selected node will be scanned.</p>
</div>
)}
{formAction === 'prune' && (
<>
<div className="space-y-2">
@@ -531,7 +548,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter }:
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleSave} disabled={saving || !formName || !formCron || (targetType === 'stack' && (!formTargetId || !formNodeId)) || (formAction === 'prune' && formPruneTargets.length === 0)}>
<Button onClick={handleSave} disabled={saving || !formName || !formCron || (targetType === 'stack' && (!formTargetId || !formNodeId)) || (formAction === 'scan' && !formNodeId) || (formAction === 'prune' && formPruneTargets.length === 0)}>
{saving ? 'Saving...' : editingTask ? 'Update' : 'Create'}
</Button>
</DialogFooter>
+1 -1
View File
@@ -4,7 +4,7 @@ export interface ScheduledTask {
target_type: 'stack' | 'fleet' | 'system';
target_id: string | null;
node_id: number | null;
action: 'restart' | 'snapshot' | 'prune' | 'update';
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan';
cron_expression: string;
enabled: number;
created_by: string;