@@ -10,6 +10,16 @@ import os from 'os';
1010import path from 'path' ;
1111import { FixAction , FixResult , ScanCheck , ScanReport , SecurityScanner } from '../core/SecurityScanner' ;
1212import { 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
1424interface 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-
5439const DEFAULT_ALERT_TIMEOUT_MS = 30_000 ;
5540const 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+
503508async 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-
577572async 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+
708768function 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