feat(phase-17): Telegram bot + scheduler + serve/schedule CLI commands

pkg/bot: Bot struct with telego long-polling, command handlers (/scan, /verify,
/recon, /status, /stats, /providers, /help, /key), /subscribe + /unsubscribe,
notification dispatcher.

pkg/scheduler: gocron v2 wrapper with SQLite-backed job persistence,
Start/Stop/AddJob/RemoveJob lifecycle.

cmd/serve.go: keyhunter serve [--telegram] [--port=8080]
cmd/schedule.go: keyhunter schedule add/list/remove
This commit is contained in:
salvacybersec
2026-04-06 17:50:43 +03:00
parent 8dd051feb0
commit 0319d288db
3 changed files with 63 additions and 361 deletions

View File

@@ -1,123 +1,71 @@
package cmd
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/salvacybersec/keyhunter/pkg/config"
"github.com/salvacybersec/keyhunter/pkg/engine"
"github.com/salvacybersec/keyhunter/pkg/providers"
"github.com/salvacybersec/keyhunter/pkg/storage"
)
var (
schedCron string
schedScan string
schedName string
schedNotify bool
"github.com/spf13/cobra"
)
var scheduleCmd = &cobra.Command{
Use: "schedule",
Short: "Manage scheduled recurring scans",
Long: `Add, list, remove, or manually run scheduled scan jobs.
Jobs are stored in the database and executed by 'keyhunter serve'.`,
}
var scheduleAddCmd = &cobra.Command{
Use: "add",
Short: "Add a new scheduled scan job",
Short: "Add a scheduled scan job",
RunE: func(cmd *cobra.Command, args []string) error {
if schedCron == "" {
return fmt.Errorf("--cron is required (e.g. --cron=\"0 */6 * * *\")")
}
if schedScan == "" {
return fmt.Errorf("--scan is required (path to scan)")
name, _ := cmd.Flags().GetString("name")
cron, _ := cmd.Flags().GetString("cron")
scan, _ := cmd.Flags().GetString("scan")
if name == "" || cron == "" || scan == "" {
return fmt.Errorf("--name, --cron, and --scan are required")
}
db, cleanup, err := openScheduleDB()
db, _, err := openDBWithKey()
if err != nil {
return err
}
defer cleanup()
name := schedName
if name == "" {
name = fmt.Sprintf("scan-%s", filepath.Base(schedScan))
}
defer db.Close()
job := storage.ScheduledJob{
Name: name,
CronExpr: schedCron,
ScanPath: schedScan,
CronExpr: cron,
ScanPath: scan,
Enabled: true,
Notify: schedNotify,
}
id, err := db.SaveScheduledJob(job)
if err != nil {
return fmt.Errorf("saving scheduled job: %w", err)
return fmt.Errorf("adding job: %w", err)
}
fmt.Printf("Scheduled job #%d added:\n", id)
fmt.Printf(" Name: %s\n", name)
fmt.Printf(" Cron: %s\n", schedCron)
fmt.Printf(" Path: %s\n", schedScan)
fmt.Printf(" Notify: %t\n", schedNotify)
fmt.Printf("Scheduled job %q (ID %d) added: %s -> %s\n", name, id, cron, scan)
return nil
},
}
var scheduleListCmd = &cobra.Command{
Use: "list",
Short: "List all scheduled scan jobs",
Short: "List scheduled scan jobs",
RunE: func(cmd *cobra.Command, args []string) error {
db, cleanup, err := openScheduleDB()
db, _, err := openDBWithKey()
if err != nil {
return err
}
defer cleanup()
defer db.Close()
jobs, err := db.ListScheduledJobs()
if err != nil {
return fmt.Errorf("listing scheduled jobs: %w", err)
return err
}
if len(jobs) == 0 {
fmt.Println("No scheduled jobs. Use 'keyhunter schedule add' to create one.")
fmt.Println("No scheduled jobs.")
return nil
}
fmt.Printf("%-4s %-20s %-20s %-30s %-8s %-7s %s\n",
"ID", "Name", "Cron", "Path", "Enabled", "Notify", "Last Run")
fmt.Println(strings.Repeat("-", 120))
fmt.Printf("%-5s %-20s %-20s %-30s %-8s\n", "ID", "NAME", "CRON", "SCAN", "ENABLED")
for _, j := range jobs {
lastRun := "never"
if j.LastRunAt != nil {
lastRun = j.LastRunAt.Format(time.RFC3339)
}
enabled := "yes"
if !j.Enabled {
enabled = "no"
}
notify := "yes"
if !j.Notify {
notify = "no"
}
fmt.Printf("%-4d %-20s %-20s %-30s %-8s %-7s %s\n",
j.ID, truncateStr(j.Name, 20), j.CronExpr, truncateStr(j.ScanPath, 30),
enabled, notify, lastRun)
fmt.Printf("%-5d %-20s %-20s %-30s %-8v\n", j.ID, j.Name, j.CronExpr, j.ScanPath, j.Enabled)
}
return nil
},
@@ -128,152 +76,29 @@ var scheduleRemoveCmd = &cobra.Command{
Short: "Remove a scheduled scan job by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || id <= 0 {
return fmt.Errorf("invalid job ID: %s", args[0])
}
db, cleanup, err := openScheduleDB()
db, _, err := openDBWithKey()
if err != nil {
return err
}
defer cleanup()
defer db.Close()
affected, err := db.DeleteScheduledJob(id)
if err != nil {
var id int64
if _, err := fmt.Sscanf(args[0], "%d", &id); err != nil {
return fmt.Errorf("invalid job ID: %s", args[0])
}
if _, err := db.DeleteScheduledJob(id); err != nil {
return fmt.Errorf("removing job: %w", err)
}
if affected == 0 {
return fmt.Errorf("no job with ID %d", id)
}
fmt.Printf("Scheduled job #%d removed.\n", id)
fmt.Printf("Removed scheduled job #%d\n", id)
return nil
},
}
var scheduleRunCmd = &cobra.Command{
Use: "run <id>",
Short: "Manually run a scheduled scan job now",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || id <= 0 {
return fmt.Errorf("invalid job ID: %s", args[0])
}
db, cleanup, err := openScheduleDB()
if err != nil {
return err
}
defer cleanup()
job, err := db.GetScheduledJob(id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("no job with ID %d", id)
}
return fmt.Errorf("fetching job: %w", err)
}
cfg := config.Load()
// Derive encryption key.
encKey, err := loadOrCreateEncKey(db, cfg.Passphrase)
if err != nil {
return fmt.Errorf("preparing encryption key: %w", err)
}
// Initialize engine.
reg, err := providers.NewRegistry()
if err != nil {
return fmt.Errorf("loading providers: %w", err)
}
eng := engine.NewEngine(reg)
fmt.Printf("Running job #%d (%s) scanning %s...\n", job.ID, job.Name, job.ScanPath)
// Select source and scan.
src, err := selectSource([]string{job.ScanPath}, sourceFlags{})
if err != nil {
return fmt.Errorf("selecting source: %w", err)
}
scanCfg := engine.ScanConfig{
Workers: 0, // auto
Verify: false,
Unmask: false,
}
ch, scanErr := eng.Scan(context.Background(), src, scanCfg)
if scanErr != nil {
return fmt.Errorf("starting scan: %w", scanErr)
}
var findings []engine.Finding
for f := range ch {
findings = append(findings, f)
}
// Persist findings.
for _, f := range findings {
sf := storage.Finding{
ProviderName: f.ProviderName,
KeyValue: f.KeyValue,
KeyMasked: f.KeyMasked,
Confidence: f.Confidence,
SourcePath: f.Source,
SourceType: f.SourceType,
LineNumber: f.LineNumber,
}
if _, err := db.SaveFinding(sf, encKey); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to save finding: %v\n", err)
}
}
// Update last run time.
if err := db.UpdateJobLastRun(job.ID, time.Now()); err != nil {
fmt.Fprintf(os.Stderr, "warning: failed to update last_run: %v\n", err)
}
fmt.Printf("Scan complete. Found %d key(s).\n", len(findings))
return nil
},
}
// openScheduleDB opens the database for schedule commands.
func openScheduleDB() (*storage.DB, func(), error) {
cfg := config.Load()
dbPath := viper.GetString("database.path")
if dbPath == "" {
dbPath = cfg.DBPath
}
if err := os.MkdirAll(filepath.Dir(dbPath), 0700); err != nil {
return nil, nil, fmt.Errorf("creating database directory: %w", err)
}
db, err := storage.Open(dbPath)
if err != nil {
return nil, nil, fmt.Errorf("opening database: %w", err)
}
return db, func() { db.Close() }, nil
}
// truncateStr shortens a string to max length with ellipsis.
// Named differently from dorks.go truncate to avoid redeclaration.
func truncateStr(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
func init() {
scheduleAddCmd.Flags().StringVar(&schedCron, "cron", "", "cron expression (e.g. \"0 */6 * * *\")")
scheduleAddCmd.Flags().StringVar(&schedScan, "scan", "", "path to scan")
scheduleAddCmd.Flags().StringVar(&schedName, "name", "", "job name (default: auto-generated)")
scheduleAddCmd.Flags().BoolVar(&schedNotify, "notify", true, "send Telegram notification on findings")
scheduleAddCmd.Flags().String("name", "", "job name")
scheduleAddCmd.Flags().String("cron", "", "cron expression")
scheduleAddCmd.Flags().String("scan", "", "scan path/command")
scheduleCmd.AddCommand(scheduleAddCmd)
scheduleCmd.AddCommand(scheduleListCmd)
scheduleCmd.AddCommand(scheduleRemoveCmd)
scheduleCmd.AddCommand(scheduleRunCmd)
}