/ scripts / check-rotation-schedule.js
check-rotation-schedule.js
  1  #!/usr/bin/env node
  2  /**
  3   * check-rotation-schedule.js
  4   *
  5   * Reads credentials-metadata.json and reports overdue credential rotations.
  6   * Creates a human-review queue entry for each overdue credential so it surfaces
  7   * in the next pipeline review session.
  8   *
  9   * Usage:
 10   *   node scripts/check-rotation-schedule.js            # report only
 11   *   node scripts/check-rotation-schedule.js --queue    # also write to human_review queue
 12   */
 13  
 14  import { readFileSync } from 'fs';
 15  import { join, dirname } from 'path';
 16  import { fileURLToPath } from 'url';
 17  import { createDatabaseConnection } from '../src/utils/db.js';
 18  
 19  const __dirname = dirname(fileURLToPath(import.meta.url));
 20  const projectRoot = join(__dirname, '..');
 21  
 22  const metadataPath = join(__dirname, 'credentials-metadata.json');
 23  const writeToQueue = process.argv.includes('--queue');
 24  
 25  function loadMetadata() {
 26    const raw = readFileSync(metadataPath, 'utf-8');
 27    return JSON.parse(raw);
 28  }
 29  
 30  function checkOverdue(credentials) {
 31    const today = new Date();
 32    const results = [];
 33  
 34    for (const cred of credentials) {
 35      if (!cred.last_rotated) {
 36        results.push({ ...cred, status: 'never_rotated', days_overdue: null });
 37        continue;
 38      }
 39  
 40      const lastRotated = new Date(cred.last_rotated);
 41      const dueDate = new Date(lastRotated);
 42      dueDate.setDate(dueDate.getDate() + cred.interval_days);
 43  
 44      const daysOverdue = Math.floor((today - dueDate) / (1000 * 60 * 60 * 24));
 45      if (daysOverdue >= 0) {
 46        results.push({ ...cred, status: 'overdue', days_overdue: daysOverdue, due_date: dueDate.toISOString().slice(0, 10) });
 47      } else {
 48        const daysUntilDue = -daysOverdue;
 49        if (daysUntilDue <= 14) {
 50          results.push({ ...cred, status: 'due_soon', days_overdue: null, days_until_due: daysUntilDue, due_date: dueDate.toISOString().slice(0, 10) });
 51        }
 52      }
 53    }
 54  
 55    return results;
 56  }
 57  
 58  function writeHumanReviewItems(items) {
 59    const dbPath = join(projectRoot, 'db/sites.db');
 60    const db = createDatabaseConnection(dbPath);
 61  
 62    const insert = db.prepare(`
 63      INSERT INTO human_review (review_type, payload, created_at)
 64      VALUES ('credential_rotation', ?, CURRENT_TIMESTAMP)
 65    `);
 66  
 67    let written = 0;
 68    for (const item of items) {
 69      if (item.status !== 'overdue' && item.status !== 'never_rotated') continue;
 70  
 71      // Avoid duplicate entries: check if there's already an open item for this credential
 72      const existing = db.prepare(`
 73        SELECT id FROM human_review
 74        WHERE review_type = 'credential_rotation'
 75          AND json_extract(payload, '$.service') = ?
 76          AND resolved_at IS NULL
 77      `).get(item.service);
 78  
 79      if (existing) continue;
 80  
 81      insert.run(JSON.stringify({
 82        service: item.service,
 83        status: item.status,
 84        days_overdue: item.days_overdue,
 85        interval_days: item.interval_days,
 86        last_rotated: item.last_rotated,
 87        note: item.note,
 88      }));
 89      written++;
 90    }
 91  
 92    db.close();
 93    return written;
 94  }
 95  
 96  function printReport(overdue, all) {
 97    const today = new Date().toISOString().slice(0, 10);
 98    console.log(`\nCredential Rotation Check — ${today}\n`);
 99  
100    if (overdue.length === 0) {
101      console.log('  All credentials are current. Nothing overdue.');
102      return;
103    }
104  
105    const neverRotated = overdue.filter(c => c.status === 'never_rotated');
106    const overdueItems = overdue.filter(c => c.status === 'overdue');
107    const dueSoon = overdue.filter(c => c.status === 'due_soon');
108  
109    if (neverRotated.length > 0) {
110      console.log('  NEVER ROTATED (set last_rotated in credentials-metadata.json after first rotation):');
111      for (const c of neverRotated) {
112        console.log(`    ✗ ${c.service.padEnd(32)} — ${c.note}`);
113      }
114      console.log('');
115    }
116  
117    if (overdueItems.length > 0) {
118      console.log('  OVERDUE:');
119      for (const c of overdueItems) {
120        console.log(`    ✗ ${c.service.padEnd(32)} — ${c.days_overdue}d overdue (due ${c.due_date})`);
121      }
122      console.log('');
123    }
124  
125    if (dueSoon.length > 0) {
126      console.log('  DUE SOON (within 14 days):');
127      for (const c of dueSoon) {
128        console.log(`    ! ${c.service.padEnd(32)} — due in ${c.days_until_due}d (${c.due_date})`);
129      }
130      console.log('');
131    }
132  
133    console.log(`  See scripts/credential-rotation-playbook.md for rotation steps.`);
134    console.log('');
135  }
136  
137  // Main
138  const { credentials } = loadMetadata();
139  const items = checkOverdue(credentials);
140  
141  printReport(items, credentials);
142  
143  if (writeToQueue) {
144    try {
145      const written = writeHumanReviewItems(items);
146      if (written > 0) {
147        console.log(`  ${written} item(s) added to human_review queue.`);
148      } else {
149        console.log('  No new items added to queue (already queued or nothing overdue).');
150      }
151    } catch (err) {
152      console.error('  Warning: could not write to human_review queue:', err.message);
153      console.error('  (Run without --queue to skip this step)');
154    }
155  }