Skip to content

Commit 9e39585

Browse files
zmaj1231zamaj123
andauthored
Add watchtower scan artifact upload (#56)
* WIP watchtower scan upload * Address scan artifact upload review * Use remote repo display name in scan artifacts --------- Co-authored-by: majumderzain <majumderzain@gmail.com>
1 parent 3084570 commit 9e39585

2 files changed

Lines changed: 380 additions & 37 deletions

File tree

src/cli/scan.ts

Lines changed: 102 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ import os from 'os';
1010
import path from 'path';
1111
import { FixAction, FixResult, ScanCheck, ScanReport, SecurityScanner } from '../core/SecurityScanner';
1212
import { logger } from '../core/Logger';
13+
import {
14+
ConfigDiffEntry,
15+
DriftComparison,
16+
JsonValue,
17+
getConfigBaselinePath,
18+
getOpenclawConfigPath,
19+
getScanStatePath,
20+
WatchtowerScanArtifact,
21+
writeWatchtowerArtifact,
22+
} from './watchtower-artifact';
1323

1424
interface ScanCommandOptions {
1525
alertCommand?: string;
@@ -26,31 +36,6 @@ interface ScanMonitorState {
2636
report: ScanReport;
2737
}
2838

29-
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
30-
31-
interface ConfigDiffEntry {
32-
path: string;
33-
kind: 'added' | 'removed' | 'changed';
34-
previousValue?: JsonValue;
35-
currentValue?: JsonValue;
36-
}
37-
38-
interface DriftComparison {
39-
baselineCreated: boolean;
40-
baselineReportUnhealthy: boolean;
41-
configBaselineCreated: boolean;
42-
corruptedStateRecovered: boolean;
43-
configChanges: ConfigDiffEntry[];
44-
verdictWorsened: boolean;
45-
worsenedChecks: Array<{
46-
id: string;
47-
previousStatus: ScanCheck['status'] | 'NEW';
48-
currentStatus: ScanCheck['status'];
49-
message: string;
50-
}>;
51-
previousTimestamp?: string;
52-
}
53-
5439
const DEFAULT_ALERT_TIMEOUT_MS = 30_000;
5540
const STATUS_STYLES = {
5641
PASS: { icon: '✅', color: chalk.green },
@@ -79,8 +64,15 @@ export async function scanCommand(options: ScanCommandOptions): Promise<void> {
7964
const scanner = new SecurityScanner();
8065
let report = await scanner.run();
8166
let monitorComparison: DriftComparison | null = null;
67+
const command = renderScanCommand();
8268

8369
if (options.json) {
70+
const { artifact } = await writeWatchtowerArtifact({
71+
command,
72+
report,
73+
monitorComparison,
74+
});
75+
await maybeUploadWatchtowerArtifact(artifact, true);
8476
console.log(JSON.stringify(report, null, 2));
8577
process.exitCode = exitCodeFor(report);
8678
return;
@@ -114,6 +106,15 @@ export async function scanCommand(options: ScanCommandOptions): Promise<void> {
114106
renderMonitorSummary(monitorComparison, report);
115107
await maybeSendMonitorAlert(options.alertCommand, monitorComparison, report, reportPath);
116108
}
109+
110+
const { artifact, artifactPath } = await writeWatchtowerArtifact({
111+
command,
112+
report,
113+
monitorComparison,
114+
});
115+
renderWatchtowerArtifactSummary(artifactPath);
116+
await maybeUploadWatchtowerArtifact(artifact, false);
117+
117118
if (options.html) {
118119
openHtmlReport(reportPath);
119120
}
@@ -253,6 +254,7 @@ async function updateMonitorState(report: ScanReport, resetBaseline: boolean): P
253254

254255
const currentSnapshot = await loadCurrentConfigSnapshot();
255256
const comparison = compareReports(previousState?.report, report, corruptedStateRecovered);
257+
comparison.baselineReset = resetBaseline;
256258
comparison.configBaselineCreated = previousSnapshot === null || resetBaseline;
257259
comparison.configChanges = compareConfigs(previousSnapshot, currentSnapshot);
258260

@@ -369,6 +371,7 @@ function compareReports(
369371
if (!previous) {
370372
return {
371373
baselineCreated: true,
374+
baselineReset: false,
372375
baselineReportUnhealthy: current.verdict !== 'SECURE',
373376
configBaselineCreated: false,
374377
corruptedStateRecovered,
@@ -406,6 +409,7 @@ function compareReports(
406409

407410
return {
408411
baselineCreated: false,
412+
baselineReset: false,
409413
baselineReportUnhealthy: false,
410414
configBaselineCreated: false,
411415
corruptedStateRecovered,
@@ -500,6 +504,7 @@ function getAlertTimeoutMs(): number {
500504
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
501505
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_ALERT_TIMEOUT_MS;
502506
}
507+
503508
async function confirmFix(): Promise<boolean> {
504509
if (!process.stdin.isTTY) {
505510
throw new Error('Fix mode requires --yes when stdin is not interactive');
@@ -564,19 +569,8 @@ async function writeHtmlReport(report: ScanReport): Promise<string> {
564569
return outputPath;
565570
}
566571

567-
function getScanStatePath(): string {
568-
const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
569-
return path.join(openclawHome, 'clawreins', 'scan-state.json');
570-
}
571-
572-
function getConfigBaselinePath(): string {
573-
const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
574-
return path.join(openclawHome, 'clawreins', 'config-base.json');
575-
}
576-
577572
async function loadCurrentConfigSnapshot(): Promise<JsonValue> {
578-
const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
579-
const openclawConfigPath = process.env.OPENCLAW_CONFIG || path.join(openclawHome, 'openclaw.json');
573+
const openclawConfigPath = getOpenclawConfigPath();
580574

581575
if (!(await fs.pathExists(openclawConfigPath))) {
582576
return {};
@@ -705,6 +699,72 @@ function renderHtmlReportSummary(reportPath: string, autoOpenRequested: boolean)
705699
}
706700
}
707701

702+
function renderWatchtowerArtifactSummary(artifactPath: string): void {
703+
console.log(chalk.bold('Watchtower Artifact:'));
704+
console.log(` ${chalk.dim(`Saved to: ${artifactPath}`)}`);
705+
}
706+
707+
async function maybeUploadWatchtowerArtifact(artifact: WatchtowerScanArtifact, quiet: boolean): Promise<void> {
708+
const baseUrl = process.env.CLAWREINS_WATCHTOWER_BASE_URL?.trim();
709+
const apiKey = process.env.CLAWREINS_WATCHTOWER_API_KEY?.trim();
710+
711+
if (!baseUrl || !apiKey) {
712+
if (!quiet) {
713+
console.log(chalk.bold('Watchtower Upload:'));
714+
console.log(
715+
` ${chalk.dim('Upload skipped because CLAWREINS_WATCHTOWER_BASE_URL or CLAWREINS_WATCHTOWER_API_KEY is not configured.')}`
716+
);
717+
}
718+
return;
719+
}
720+
721+
let ingestUrl: string;
722+
try {
723+
const parsedBaseUrl = new URL(baseUrl);
724+
const host = parsedBaseUrl.hostname.toLowerCase();
725+
const isLoopbackHttp = parsedBaseUrl.protocol === 'http:' && ['localhost', '127.0.0.1', '::1', '[::1]'].includes(host);
726+
727+
if (parsedBaseUrl.protocol !== 'https:' && !isLoopbackHttp) {
728+
console.error(chalk.red('Watchtower upload skipped. CLAWREINS_WATCHTOWER_BASE_URL must use HTTPS unless it targets localhost, 127.0.0.1, or ::1.'));
729+
return;
730+
}
731+
732+
parsedBaseUrl.pathname = `${parsedBaseUrl.pathname.replace(/\/+$/, '')}/api/scan-artifacts/ingest`;
733+
ingestUrl = parsedBaseUrl.toString();
734+
} catch {
735+
console.error(chalk.red('Watchtower upload skipped. CLAWREINS_WATCHTOWER_BASE_URL is not a valid URL.'));
736+
return;
737+
}
738+
739+
try {
740+
const response = await fetch(ingestUrl, {
741+
method: 'POST',
742+
headers: {
743+
'content-type': 'application/json',
744+
'x-api-key': apiKey,
745+
},
746+
body: JSON.stringify(artifact),
747+
});
748+
749+
if (!response.ok) {
750+
const body = (await response.text()).trim();
751+
const message = `Watchtower upload failed. ${response.status} ${response.statusText}${body ? `: ${body}` : ''}`;
752+
console.error(chalk.red(message));
753+
logger.warn(message, { ingestUrl });
754+
return;
755+
}
756+
757+
if (!quiet) {
758+
console.log(chalk.bold('Watchtower Upload:'));
759+
console.log(` ${chalk.green(`Uploaded to ${ingestUrl}.`)}`);
760+
}
761+
} catch (error) {
762+
const message = `Watchtower upload failed. ${error instanceof Error ? error.message : String(error)}`;
763+
console.error(chalk.red(message));
764+
logger.warn(message, { ingestUrl });
765+
}
766+
}
767+
708768
function buildHtmlReport(report: ScanReport): string {
709769
const checks = report.checks.map((check) => renderHtmlCheck(check)).join('\n');
710770
const scoreBar = Array.from({ length: report.total }, (_, index) =>
@@ -809,3 +869,8 @@ function exitCodeFor(report: ScanReport): 0 | 1 | 2 {
809869
}
810870
return 2;
811871
}
872+
873+
function renderScanCommand(): string {
874+
const argv = process.argv.slice(2);
875+
return argv.length > 0 ? `clawreins ${argv.join(' ')}` : 'clawreins scan';
876+
}

0 commit comments

Comments
 (0)