Schedule Management Guide
Complete guide to automated scan scheduling with JMo Security
Table of Contents
- Overview
- Quick Start
- Schedule Concepts
- Creating Schedules
- Managing Schedules
- GitLab CI Integration
- Slack Notifications
- GitHub Actions Integration
- Local Cron Integration
- Advanced Configuration
- Troubleshooting
Overview
JMo Security's schedule management system enables automated, recurring security scans with:
- Kubernetes-inspired API: Familiar patterns for DevOps teams (metadata, spec, status)
- Cron-based scheduling: Full cron syntax support with timezone awareness
- Multiple backends: GitLab CI, GitHub Actions, local cron
- Slack notifications: Success/failure alerts to team channels
- Local persistence: Schedules stored in
~/.jmo/schedules.jsonwith secure permissions
Quick Start
Basic Weekly Scan
from scripts.core.schedule_manager import (
ScheduleManager, ScanSchedule, ScheduleMetadata,
ScheduleSpec, BackendConfig, JobTemplateSpec
)
# Initialize manager
manager = ScheduleManager()
# Create schedule
schedule = ScanSchedule(
metadata=ScheduleMetadata(
name="weekly-scan",
labels={"team": "security", "environment": "production"}
),
spec=ScheduleSpec(
schedule="0 2 * * 1", # Every Monday at 2 AM UTC
timezone="UTC",
backend=BackendConfig(type="gitlab-ci"),
jobTemplate=JobTemplateSpec(
profile="balanced",
targets={"repos_dir": "/repos"},
results={"dir": "/results"},
options={"fail_on": "HIGH"},
notifications={
"enabled": True,
"channels": [
{
"type": "slack",
"url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
}
]
}
)
)
)
# Save schedule
manager.create(schedule)
print(f"✅ Created schedule: {schedule.metadata.name}")
print(f"Next run: {schedule.status.nextScheduleTime}")
Export to GitLab CI
# Export schedule as GitLab CI YAML
jmo schedule export weekly-scan > .gitlab-ci.yml
# Or programmatically
from scripts.core.workflow_generators.gitlab_ci import GitLabCIGenerator
generator = GitLabCIGenerator()
schedule = manager.get("weekly-scan")
yaml_content = generator.generate(schedule)
print(yaml_content)
Schedule Concepts
Kubernetes-Inspired Architecture
Schedules follow Kubernetes CronJob patterns for familiarity:
apiVersion: jmo.security/v1alpha1
kind: ScanSchedule
metadata:
name: nightly-scan
uid: 550e8400-e29b-41d4-a716-446655440000
labels:
team: security
environment: prod
annotations:
description: "Nightly security scan for production repos"
creationTimestamp: "2025-10-29T02:00:00Z"
generation: 1
spec:
schedule: "0 2 * * *"
timezone: "UTC"
suspend: false
concurrencyPolicy: "Forbid"
startingDeadlineSeconds: 300
successfulJobsHistoryLimit: 30
failedJobsHistoryLimit: 10
backend:
type: "gitlab-ci"
config: {}
jobTemplate:
profile: "balanced"
targets:
repos_dir: "/repos"
results:
dir: "/results"
options:
fail_on: "HIGH"
notifications:
enabled: true
channels:
- type: "slack"
url: "https://hooks.slack.com/services/..."
status:
conditions:
- type: "Ready"
status: "True"
lastTransitionTime: "2025-10-29T02:00:00Z"
reason: "Created"
message: "Schedule created successfully"
nextScheduleTime: "2025-10-30T02:00:00Z"
lastScheduleTime: null
lastSuccessfulTime: null
active: 0
succeeded: 0
failed: 0
Cron Syntax
Standard cron format with 5 fields:
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 7, Sunday = 0 or 7)
│ │ │ │ │
* * * * *
Common patterns:
# Every day at 2 AM UTC
schedule="0 2 * * *"
# Every Monday at 2 AM UTC
schedule="0 2 * * 1"
# Every 6 hours
schedule="0 */6 * * *"
# Every weekday at 9 AM UTC
schedule="0 9 * * 1-5"
# First day of month at midnight
schedule="0 0 1 * *"
# Every 15 minutes (for testing)
schedule="*/15 * * * *"
Concurrency Policies
Controls how concurrent scan jobs are handled:
Forbid(default): Skip new job if previous still runningAllow: Run multiple jobs concurrentlyReplace: Cancel running job and start new one
Creating Schedules
Basic Schedule
from scripts.core.schedule_manager import *
manager = ScheduleManager()
schedule = ScanSchedule(
metadata=ScheduleMetadata(name="basic-scan"),
spec=ScheduleSpec(
schedule="0 2 * * *",
jobTemplate=JobTemplateSpec(
profile="fast",
targets={"repo": "/path/to/repo"},
results={"dir": "/results"},
options={}
)
)
)
manager.create(schedule)
Multi-Target Schedule
Scan multiple target types in one schedule:
schedule = ScanSchedule(
metadata=ScheduleMetadata(name="comprehensive-scan"),
spec=ScheduleSpec(
schedule="0 3 * * 1", # Weekly Monday 3 AM
jobTemplate=JobTemplateSpec(
profile="balanced",
targets={
"repos_dir": "/repos",
"images": ["nginx:latest", "postgres:15"],
"urls": ["https://api.example.com"],
"k8s_context": "prod"
},
results={"dir": "/results/weekly"},
options={"fail_on": "HIGH", "threads": 8}
)
)
)
Schedule with Labels
Use labels for filtering and organization:
schedule = ScanSchedule(
metadata=ScheduleMetadata(
name="prod-backend-scan",
labels={
"team": "backend",
"environment": "production",
"priority": "critical"
},
annotations={
"owner": "security-team@example.com",
"description": "Production backend services security scan"
}
),
spec=ScheduleSpec(schedule="0 1 * * *", ...)
)
Managing Schedules
List All Schedules
manager = ScheduleManager()
# List all
schedules = manager.list()
for s in schedules:
print(f"{s.metadata.name}: {s.spec.schedule} (next: {s.status.nextScheduleTime})")
# Filter by labels
prod_schedules = manager.list(labels={"environment": "production"})
Get Specific Schedule
schedule = manager.get("weekly-scan")
if schedule:
print(f"Schedule: {schedule.metadata.name}")
print(f"Cron: {schedule.spec.schedule}")
print(f"Profile: {schedule.spec.jobTemplate.profile}")
print(f"Next run: {schedule.status.nextScheduleTime}")
else:
print("Schedule not found")
Update Schedule
# Get existing schedule
schedule = manager.get("weekly-scan")
# Modify schedule
schedule.spec.schedule = "0 3 * * *" # Change to 3 AM
schedule.spec.jobTemplate.profile = "deep" # Use deep profile
schedule.spec.suspend = True # Temporarily suspend
# Save changes
manager.update(schedule)
print(f"✅ Updated schedule (generation {schedule.metadata.generation})")
Delete Schedule
success = manager.delete("weekly-scan")
if success:
print("✅ Schedule deleted")
else:
print("❌ Schedule not found")
Suspend/Resume Schedule
# Suspend
schedule = manager.get("weekly-scan")
schedule.spec.suspend = True
manager.update(schedule)
# Resume
schedule.spec.suspend = False
manager.update(schedule)
GitLab CI Integration
Generate GitLab CI YAML
from scripts.core.workflow_generators.gitlab_ci import GitLabCIGenerator
manager = ScheduleManager()
generator = GitLabCIGenerator()
schedule = manager.get("weekly-scan")
yaml_content = generator.generate(schedule)
# Write to .gitlab-ci.yml
with open(".gitlab-ci.yml", "w") as f:
f.write(yaml_content)
Generated YAML Structure
The generator creates a complete GitLab CI pipeline:
# Auto-generated by JMo Security Schedule Manager
# Schedule: weekly-scan
# Cron: 0 2 * * 1 (Every Monday at 2 AM UTC)
# Profile: balanced
# Export command: jmo schedule export weekly-scan > .gitlab-ci.yml
variables:
JMO_PROFILE: "balanced"
JMO_FAIL_ON: "HIGH"
stages:
- scan
- notify
jmo-security-scan:
stage: scan
image: ghcr.io/jimmy058910/jmo-security:latest
script:
- jmo scan --repos-dir /repos --profile balanced --fail-on HIGH
- jmo report /results
artifacts:
paths:
- results/
reports:
sast: results/summaries/findings.sarif
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
notify-slack-success:
stage: notify
image: curlimages/curl:latest
script:
- |
curl -X POST 'https://hooks.slack.com/services/...' \
-H 'Content-Type: application/json' \
-d '{
"text": "✅ Security Scan Completed",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Security Scan: weekly-scan*\n*Status:* ✅ Success\n*Pipeline:* <'"$CI_PIPELINE_URL"'|#'"$CI_PIPELINE_ID"'>\n*Commit:* '"$CI_COMMIT_SHORT_SHA"' by '"$CI_COMMIT_AUTHOR"'\n*Duration:* '"$CI_JOB_DURATION"'s"
}
}
]
}'
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
when: on_success
notify-slack-failure:
stage: notify
image: curlimages/curl:latest
script:
- |
curl -X POST 'https://hooks.slack.com/services/...' \
-H 'Content-Type: application/json' \
-d '{
"text": "❌ Security Scan Failed",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Security Scan: weekly-scan*\n*Status:* ❌ Failed\n*Pipeline:* <'"$CI_PIPELINE_URL"'|#'"$CI_PIPELINE_ID"'>\n*Commit:* '"$CI_COMMIT_SHORT_SHA"' by '"$CI_COMMIT_AUTHOR"'\n*Error:* Check pipeline logs"
}
}
]
}'
rules:
- if: '$CI_PIPELINE_SOURCE == "schedule"'
when: on_failure
Configure GitLab Pipeline Schedule
- Navigate to CI/CD > Schedules in GitLab
- Click New schedule
- Configure:
- Description:
Weekly Security Scan - Interval Pattern: Custom (use cron syntax from schedule)
- Cron timezone:
UTC - Target branch:
main - Save schedule
- GitLab will run
.gitlab-ci.ymlon schedule
Slack Notifications
Setup Slack Webhook
- Go to Slack API: Incoming Webhooks
- Create new app or select existing app
- Enable Incoming Webhooks
- Add New Webhook to Workspace
- Select channel (e.g.,
#security-alerts) - Copy webhook URL:
https://hooks.slack.com/services/T00/B00/XXX
Configure Notifications
schedule = ScanSchedule(
metadata=ScheduleMetadata(name="prod-scan"),
spec=ScheduleSpec(
schedule="0 2 * * *",
jobTemplate=JobTemplateSpec(
profile="balanced",
targets={"repos_dir": "/repos"},
results={"dir": "/results"},
options={},
notifications={
"enabled": True,
"channels": [
{
"type": "slack",
"url": "https://hooks.slack.com/services/T00/B00/XXX"
}
]
}
)
)
)
Multiple Slack Channels
notifications={
"enabled": True,
"channels": [
{
"type": "slack",
"url": "https://hooks.slack.com/services/T00/B00/XXX", # #security
},
{
"type": "slack",
"url": "https://hooks.slack.com/services/T00/B01/YYY", # #devops
}
]
}
Notification Message Format
Success notification includes:
- ✅ Success status
- Pipeline/job URL
- Commit SHA and author
- Scan duration
- Findings summary (if available)
Failure notification includes:
- ❌ Failure status
- Pipeline/job URL
- Commit SHA and author
- Error message
- Link to logs
Security Best Practices
DO NOT hardcode webhook URLs in code:
# ❌ BAD - Hardcoded secret
notifications={
"channels": [{"type": "slack", "url": "https://hooks.slack.com/..."}]
}
# ✅ GOOD - Use environment variable
import os
notifications={
"channels": [{"type": "slack", "url": os.environ["SLACK_WEBHOOK_URL"]}]
}
# ✅ GOOD - Use GitLab CI variable
# In .gitlab-ci.yml:
# SLACK_WEBHOOK_URL is configured as masked CI/CD variable
GitHub Actions Integration
Generate GitHub Actions Workflow (Recommended)
The GitHubActionsGenerator produces a complete workflow YAML from a ScanSchedule object:
from scripts.core.schedule_manager import ScheduleManager
from scripts.core.workflow_generators.github_actions import GitHubActionsGenerator
manager = ScheduleManager()
generator = GitHubActionsGenerator()
schedule = manager.get("weekly-scan")
yaml_content = generator.generate(schedule)
# Write to .github/workflows/jmo-nightly.yml
with open(".github/workflows/jmo-nightly.yml", "w") as f:
f.write(yaml_content)
Or via the CLI export:
Generated Workflow Structure
The generator emits a complete pipeline:
- Name:
JMo Security Scan: <schedule-name> - Triggers: cron schedule (from
spec.schedule) + manualworkflow_dispatch - Permissions:
contents: read,security-events: write(required for SARIF upload) - Job: runs in the
ghcr.io/jimmy058910/jmo-securitycontainer, checks out code, runsjmo scan, uploads SARIF, uploads results as artifacts, posts Slack notifications on success/failure (if configured in the schedule'snotificationsblock)
Manual GitHub Actions Workflow (Advanced)
If you want full control over the workflow without the generator, use this template as a starting point:
# .github/workflows/security-scan.yml
name: Security Scan
on:
schedule:
- cron: '0 2 * * 1' # Every Monday at 2 AM UTC
workflow_dispatch:
jobs:
security-scan:
runs-on: ubuntu-latest
container:
image: ghcr.io/jimmy058910/jmo-security:latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Run security scan
run: |
jmo scan --repo . --profile balanced --fail-on HIGH
jmo report ./results
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results/summaries/findings.sarif
- name: Upload results
uses: actions/upload-artifact@v4
with:
name: security-results
path: results/
- name: Notify Slack on success
if: success()
uses: slackapi/slack-github-action@v1
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "✅ Security Scan Completed",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Security Scan: security-scan*\n*Status:* ✅ Success\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|#${{ github.run_number }}>\n*Commit:* ${{ github.sha }} by ${{ github.actor }}"
}
}
]
}
- name: Notify Slack on failure
if: failure()
uses: slackapi/slack-github-action@v1
with:
webhook: ${{ secrets.SLACK_WEBHOOK_URL }}
payload: |
{
"text": "❌ Security Scan Failed",
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Security Scan: security-scan*\n*Status:* ❌ Failed\n*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|#${{ github.run_number }}>\n*Commit:* ${{ github.sha }} by ${{ github.actor }}"
}
}
]
}
Local Cron Integration
Shell Script Generation
A dedicated shell-script generator is not yet implemented (only gitlab_ci.py and github_actions.py ship in scripts/core/workflow_generators/ today). For local cron, use the manual setup below — the pattern is simple enough that a generator would add minimal value.
Manual Cron Setup
- Create scan script:
#!/bin/bash
# /usr/local/bin/jmo-weekly-scan.sh
set -euo pipefail
# Configuration
REPOS_DIR="/path/to/repos"
RESULTS_DIR="/path/to/results/$(date +%Y-%m-%d)"
PROFILE="balanced"
SLACK_WEBHOOK="https://hooks.slack.com/services/..."
# Run scan
jmo scan --repos-dir "$REPOS_DIR" --results-dir "$RESULTS_DIR" --profile "$PROFILE" --fail-on HIGH
# Generate reports
jmo report "$RESULTS_DIR"
# Notify Slack on success
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\": \"✅ Security scan completed: $RESULTS_DIR\"}"
- Make executable:
- Add to crontab:
# Edit crontab
crontab -e
# Add schedule (every Monday at 2 AM)
0 2 * * 1 /usr/local/bin/jmo-weekly-scan.sh >> /var/log/jmo-scan.log 2>&1
- Verify cron job:
Advanced Configuration
History Limits
Control how many job results to retain:
spec=ScheduleSpec(
schedule="0 2 * * *",
successfulJobsHistoryLimit=30, # Keep 30 successful runs
failedJobsHistoryLimit=10 # Keep 10 failed runs
)
Starting Deadline
Set deadline for starting jobs:
spec=ScheduleSpec(
schedule="0 2 * * *",
startingDeadlineSeconds=300 # Cancel if can't start within 5 minutes
)
Backend-Specific Configuration
spec=ScheduleSpec(
schedule="0 2 * * *",
backend=BackendConfig(
type="gitlab-ci",
config={
"image": "ghcr.io/jimmy058910/jmo-security:slim",
"tags": ["docker", "linux"],
"timeout": "1h",
"retry": {"max": 2, "when": ["runner_system_failure"]}
}
)
)
Profile-Specific Options
jobTemplate=JobTemplateSpec(
profile="deep",
targets={"repos_dir": "/repos"},
results={"dir": "/results"},
options={
"fail_on": "MEDIUM",
"threads": 4,
"timeout": 1800,
"allow_missing_tools": False,
"human_logs": True
}
)
Troubleshooting
Schedule Not Running
Check schedule is not suspended:
schedule = manager.get("weekly-scan")
if schedule.spec.suspend:
print("⚠️ Schedule is suspended")
schedule.spec.suspend = False
manager.update(schedule)
Verify cron syntax:
from croniter import croniter
from datetime import datetime
try:
cron = croniter("0 2 * * *", datetime.now())
next_run = cron.get_next(datetime)
print(f"✅ Valid cron, next run: {next_run}")
except ValueError as e:
print(f"❌ Invalid cron: {e}")
Slack Notifications Not Working
Test webhook directly:
curl -X POST 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL' \
-H 'Content-Type: application/json' \
-d '{"text": "Test notification from JMo Security"}'
Check GitLab CI variable:
- Go to Settings > CI/CD > Variables
- Verify
SLACK_WEBHOOK_URLexists and is not expired - Ensure variable is not protected/masked if needed in non-protected branches
Permission Denied: schedules.json
# Fix permissions
chmod 600 ~/.jmo/schedules.json
# Verify
ls -la ~/.jmo/schedules.json
# Should show: -rw------- (owner read/write only)
Next Run Time Not Updating
from datetime import datetime, timezone
from croniter import croniter
schedule = manager.get("weekly-scan")
now = datetime.now(timezone.utc)
cron = croniter(schedule.spec.schedule, now)
schedule.status.nextScheduleTime = cron.get_next(datetime).isoformat()
manager.update(schedule)
print(f"✅ Updated next run: {schedule.status.nextScheduleTime}")
GitLab CI YAML Not Generating Correctly
# Enable debug mode in generator
generator = GitLabCIGenerator()
schedule = manager.get("weekly-scan")
# Generate with debug output
yaml_content = generator.generate(schedule)
print("=" * 80)
print("GENERATED YAML:")
print("=" * 80)
print(yaml_content)
See Also
- USER_GUIDE.md — Complete user guide with all CLI commands
- Slack Integration Examples — Real-world Slack integration patterns
- GitLab CI Examples — Example GitLab CI configurations
- ROADMAP.md — Future schedule management features
Need Help?
- Bug reports: GitHub Issues
- Questions: GitHub Discussions
- Documentation: docs.jmotools.com