Restore

O Operator
Restore

Restore file to captured state

Every captured change carries its SHA-256-addressed content in BLOB_STORE. Restoring writes that content back to the original path — current file gets backed up to <path>.drift_restore_backup_<epoch> before overwrite.

What will change
Preview -- nothing has been written yet.
Target file/var/www/vhosts/3dshawn.com/site1/_agent_scan.pl
SiteDriftSense self-monitor on local
Kindlocal
Captured at2026-07-11 23:15:11
Captured SHAee7754b6fe45312799e8051dffefeb358e9c71d59def44affdbdc2e2d1e239c5
Current state on disk
Live check just now. If SHAs match, the restore is a no-op.
File presentyes
Size16577 bytes
Current SHAbf5b44533145
Same as captured?no -- restore will change it
What restore will change — live diff
Left column shows current on-disk content; right shows what restore will write. +2 additions, -18 deletions, 436 unchanged context lines.
11#!/usr/bin/perl
22#======================================================================
33# DriftSense -- SSH agent scanner (Stealth Flavor 2)
44#
55# Cron entry point (NOT a CGI). For each active SERVERS row with
66# kind='ssh_agent', SSHes into the monitored host using the configured
77# restricted key and walks the paths in that server's FILE_MONITOR_SETTINGS.
88#
99# Monitored host runs zero DriftSense code -- only a single command-
1010# restricted authorized_keys entry. The wrapper on the remote side
1111# only allows 3 commands:
1212#
1313# drift-find <path> -> path\tsize\tmtime\n per file
1414# drift-hash <files> -> sha256sum output
1515# drift-read <file> -> raw bytes
1616#
1717# We drive it via SSH_ORIGINAL_COMMAND. If the wrapper isn't installed,
1818# we fall back to plain `find`, `sha256sum`, `cat` on the assumption
1919# that the SSH user has a normal shell (test-mode).
2020#
2121# Config: /etc/drift_sense/drift_sense.conf (via MODS::Config).
2222# Log: /var/log/drift_sense/agent_scan.log (via cron redirect).
2323#======================================================================
2424use strict;
2525use warnings;
2626use lib '/var/www/vhosts/3dshawn.com/site1';
2727use POSIX ();
2828use Digest::SHA qw(sha256_hex);
2929use Compress::Zlib qw(compress);
3030use IPC::Open3;
3131use Symbol 'gensym';
3232use MODS::Config;
3333use MODS::DBConnect;
3434use MODS::Crypto;
3535use Fcntl ();
3636
3737$| = 1;
3838my $ts = POSIX::strftime('%Y-%m-%d %H:%M:%S', localtime);
3939
4040my $cfg = MODS::Config->new;
4141my $db = MODS::DBConnect->new;
4242my $dbh = $db->db_connect or die "[$ts] cannot connect to DriftSense storage DB\n";
4343
4444my $default_max_size = int($cfg->settings('max_file_size_bytes') || 10_485_760);
4545my $DEFAULT_TIMEOUT = 30;
46
47# ---- Global scan settings (SCAN_GLOBALS singleton) -----------------
48my $g_row = $db->db_readwrite($dbh, q~
49 SELECT global_ignore_list, global_file_type_filter, global_max_file_size_bytes
50 FROM SCAN_GLOBALS WHERE id = 1 LIMIT 1
51~, __FILE__, __LINE__);
52my $global_ignore = ($g_row && $g_row->{global_ignore_list}) // '';
53my $global_ftypes = ($g_row && $g_row->{global_file_type_filter}) // '';
54my $global_max_size = ($g_row && $g_row->{global_max_file_size_bytes}) || 0;
55$default_max_size = $global_max_size if $global_max_size;
5646
5747# ---- Fetch ssh_agent servers ----------------------------------------
5848my @servers = $db->db_readwrite_multiple($dbh, q~
5949 SELECT server_id, server_name, ssh_host, ssh_user, ssh_port,
6050 ssh_key_path, ssh_options, ssh_key_blob, ssh_key_enc
6151 FROM SERVERS
6252 WHERE kind = 'ssh_agent'
6353 AND status = 'active'
6454~, __FILE__, __LINE__);
6555
6656unless (@servers) {
6757 print "[$ts] no active ssh_agent servers, exiting\n";
6858 $db->db_disconnect($dbh);
6959 exit 0;
7060}
7161
7262my $total_added = 0;
7363my $total_modified = 0;
7464my $total_deleted = 0;
7565
7666foreach my $server (@servers) {
7767 my $sid = $server->{server_id};
7868 my $host = $server->{ssh_host} || next;
7969 my $user = $server->{ssh_user} || 'root';
8070 my $port = $server->{ssh_port} || 22;
8171 my $opts = $server->{ssh_options} || '';
8272
8373 # Resolve key: prefer encrypted blob (decrypt to a mode-600 temp
8474 # file for the SSH call), else fall back to on-disk ssh_key_path.
8575 my $key_path = $server->{ssh_key_path} || '';
8676 my $tmp_key;
8777 if ($server->{ssh_key_blob}) {
8878 my $pem = eval { MODS::Crypto::decrypt($server->{ssh_key_blob}) };
8979 if ($pem) {
9080 $tmp_key = _materialize_key($pem);
9181 $key_path = $tmp_key if $tmp_key;
9282 } else {
9383 print "[$ts] server $server->{server_name}: key decrypt failed ($@)\n";
9484 }
9585 }
9686
9787 my @ssh_base = _ssh_argv($user, $host, $port, $key_path, $opts);
9888
9989 # Fetch this server's active file monitors
10090 my @scans = $db->db_readwrite_multiple($dbh, qq~
10191 SELECT file_monitor_list_id, scan_path, ignore_list, file_type_filter,
10292 max_file_size_bytes, scan_name
10393 FROM FILE_MONITOR_SETTINGS
10494 WHERE status = 1
10595 AND server_id = $sid
10696 ~, __FILE__, __LINE__);
10797
10898 unless (@scans) {
10999 print "[$ts] server '$server->{server_name}' has no active monitors\n";
110100 next;
111101 }
112102
113103 my $server_err;
114104
115105 foreach my $scan (@scans) {
116106 my $path = $scan->{scan_path} or next;
117107 my $max_size = $scan->{max_file_size_bytes} || $default_max_size;
118108
119 # UNION global + per-monitor ignore lists
120 my $combined_ignore = join(',', grep { length } ($global_ignore, ($scan->{ignore_list} // '')));
121 my %ignore_pats = _build_ignore_patterns($combined_ignore);
122 # Per-monitor filter wins if set; else global
123 my $eff_ftypes = length($scan->{file_type_filter} // '')
124 ? $scan->{file_type_filter}
125 : $global_ftypes;
126 my %type_filter = _build_type_filter($eff_ftypes);
109 my %ignore_pats = _build_ignore_patterns($scan->{ignore_list});
110 my %type_filter = _build_type_filter($scan->{file_type_filter});
127111
128112 # ---- Remote listing -----------------------------------------
129113 my $remote_cmd = qq{drift-find $path};
130114 my ($rc, $out, $err) = _ssh_run(\@ssh_base, $remote_cmd);
131115 if ($rc != 0) {
132116 # Fallback: no wrapper -> plain shell
133117 my $shell = qq{find } . _sh_quote($path) . qq{ -type f -not -path '*/.*' -printf '%p\\t%s\\t%T@\\n' 2>/dev/null};
134118 ($rc, $out, $err) = _ssh_run(\@ssh_base, $shell);
135119 }
136120 if ($rc != 0) {
137121 $server_err = "list failed: $err";
138122 print "[$ts] server '$server->{server_name}' scan '$scan->{scan_name}': $server_err\n";
139123 next;
140124 }
141125
142126 # Parse listing: path\tsize\tmtime_epoch
143127 my %remote;
144128 foreach my $line (split /\n/, $out) {
145129 chomp $line;
146130 next unless length $line;
147131 my ($p, $sz, $mt) = split /\t/, $line, 3;
148132 next unless defined $p && defined $sz && defined $mt;
149133 $sz = int($sz);
150134 $mt = int($mt); # printf gives float mtime, truncate
151135
152136 # Ignore-glob filter (local side)
153137 my $skip = 0;
154138 for my $pat (keys %ignore_pats) {
155139 my $re = $ignore_pats{$pat};
156140 if ($p =~ $re) { $skip = 1; last; }
157141 }
158142 next if $skip;
159143
160144 # File-type filter (local side)
161145 if (%type_filter) {
162146 my $ext_ok = 0;
163147 for my $e (keys %type_filter) {
164148 if (lc(substr($p, -length($e))) eq $e) { $ext_ok = 1; last; }
165149 }
166150 next unless $ext_ok;
167151 }
168152
169153 next if $sz > $max_size;
170154 $remote{$p} = { size => $sz, mtime => $mt };
171155 }
172156
173157 # ---- Previous state ----------------------------------------
174158 my $prev_sth = $dbh->prepare(qq{
175159 SELECT fc.file_name, fc.blob_sha, fc.status,
176160 UNIX_TIMESTAMP(fc.date_time) AS mt_epoch
177161 FROM FILE_CHANGES fc
178162 WHERE fc.file_monitor_list_id = $scan->{file_monitor_list_id}
179163 AND fc.server_id = $sid
180164 AND fc.file_changes_id IN (
181165 SELECT MAX(inner_fc.file_changes_id)
182166 FROM FILE_CHANGES inner_fc
183167 WHERE inner_fc.file_monitor_list_id = $scan->{file_monitor_list_id}
184168 AND inner_fc.server_id = $sid
185169 GROUP BY inner_fc.file_name
186170 )
187171 });
188172 my %prev;
189173 if ($prev_sth && $prev_sth->execute) {
190174 while (my $r = $prev_sth->fetchrow_hashref) {
191175 $prev{$r->{file_name}} = $r;
192176 }
193177 $prev_sth->finish;
194178 }
195179
196180 # ---- Decide who needs a fresh hash --------------------------
197181 my @needs_hash;
198182 foreach my $p (keys %remote) {
199183 my $prev_row = $prev{$p};
200184 if (!$prev_row || ($prev_row->{status} // '') eq 'deleted') {
201185 push @needs_hash, $p;
202186 next;
203187 }
204188 if (($prev_row->{mt_epoch} // 0) != $remote{$p}{mtime}) {
205189 push @needs_hash, $p;
206190 }
207191 }
208192
209193 # ---- Batch hash over SSH -----------------------------------
210194 # `drift-hash <files>` returns sha256sum output: "<sha> <path>"
211195 my %fresh_sha;
212196 if (@needs_hash) {
213197 my $q_list = join(' ', map { _sh_quote($_) } @needs_hash);
214198 my $cmd = "drift-hash $q_list";
215199 my ($h_rc, $h_out, $h_err) = _ssh_run(\@ssh_base, $cmd);
216200 if ($h_rc != 0) {
217201 # Fallback: plain sha256sum
218202 $cmd = "sha256sum $q_list 2>/dev/null";
219203 ($h_rc, $h_out, $h_err) = _ssh_run(\@ssh_base, $cmd);
220204 }
221205 if ($h_rc == 0) {
222206 foreach my $line (split /\n/, $h_out) {
223207 chomp $line;
224208 if ($line =~ /^([0-9a-f]{64})\s+(.+)$/) {
225209 $fresh_sha{$2} = $1;
226210 }
227211 }
228212 }
229213 }
230214
231215 # ---- Process each remote file ------------------------------
232216 foreach my $p (keys %remote) {
233217 my $sha = $fresh_sha{$p};
234218 my $prev_row = $prev{$p};
235219
236220 # If we didn't rehash this file, previous SHA still valid
237221 if (!defined $sha && $prev_row && ($prev_row->{status} // '') ne 'deleted') {
238222 # Nothing changed since last tick -- move on
239223 next;
240224 }
241225
242226 # Content-addressable dedup: if we already have this SHA in
243227 # BLOB_STORE, don't re-fetch content over SSH.
244228 my $have = $db->db_readwrite($dbh,
245229 "SELECT blob_sha FROM BLOB_STORE WHERE blob_sha = " . $dbh->quote($sha) . " LIMIT 1",
246230 __FILE__, __LINE__);
247231
248232 my $status;
249233 if (!$prev_row) {
250234 $status = 'added';
251235 } else {
252236 $status = 'modified';
253237 if ($prev_row->{blob_sha} && $prev_row->{blob_sha} eq $sha) {
254238 # Same bytes, just an mtime touch -- flag ts-only
255239 $status = 'modified';
256240 }
257241 }
258242
259243 # Fetch + store blob only if we don't already have it
260244 if (!($have && $have->{blob_sha})) {
261245 my $cmd = 'drift-read ' . _sh_quote($p);
262246 my ($c_rc, $c_out, $c_err) = _ssh_run(\@ssh_base, $cmd);
263247 if ($c_rc != 0) {
264248 $cmd = 'cat ' . _sh_quote($p);
265249 ($c_rc, $c_out, $c_err) = _ssh_run(\@ssh_base, $cmd);
266250 }
267251 if ($c_rc != 0) {
268252 print "[$ts] fetch $p on $server->{server_name}: $c_err\n";
269253 next;
270254 }
271255 # Verify the fetched content matches the reported hash
272256 my $verify_sha = sha256_hex($c_out);
273257 if ($verify_sha ne $sha) {
274258 print "[$ts] $p: hash mismatch (list=$sha vs read=$verify_sha), using read\n";
275259 $sha = $verify_sha;
276260 }
277261 _store_blob($db, $dbh, $sha, $c_out);
278262 }
279263
280264 # Determine timestamp-only via prev SHA compare
281265 my $is_ts_only = 0;
282266 if ($prev_row && $prev_row->{blob_sha} && $prev_row->{blob_sha} eq $sha) {
283267 $is_ts_only = 1;
284268 }
285269
286270 # Insert FILE_CHANGES row keyed to this server
287271 my $q_file = $dbh->quote($p);
288272 my $q_sha = $dbh->quote($sha);
289273 my $q_stat = $dbh->quote($status);
290274 my $mt = $remote{$p}{mtime};
291275 $db->db_readwrite($dbh, qq{
292276 INSERT INTO FILE_CHANGES
293277 (file_monitor_list_id, server_id, container_target_id,
294278 file_name, blob_sha, is_ts_only, date_time, status)
295279 VALUES
296280 ($scan->{file_monitor_list_id}, $sid, NULL,
297281 $q_file, $q_sha, $is_ts_only,
298282 FROM_UNIXTIME($mt), $q_stat)
299283 }, __FILE__, __LINE__);
300284
301285 if ($is_ts_only) { }
302286 elsif ($status eq 'added') { $total_added++; }
303287 else { $total_modified++; }
304288 }
305289
306290 # ---- Delete detection --------------------------------------
307291 foreach my $p (keys %prev) {
308292 next if $remote{$p};
309293 next if ($prev{$p}{status} // '') eq 'deleted';
310294 my $q_file = $dbh->quote($p);
311295 $db->db_readwrite($dbh, qq{
312296 INSERT INTO FILE_CHANGES
313297 (file_monitor_list_id, server_id, file_name, status, date_time)
314298 VALUES
315299 ($scan->{file_monitor_list_id}, $sid, $q_file, 'deleted', NOW())
316300 }, __FILE__, __LINE__);
317301 $total_deleted++;
318302 }
319303
320304 $db->db_readwrite($dbh, qq{
321305 UPDATE FILE_MONITOR_SETTINGS
322306 SET last_scanned = NOW()
323307 WHERE file_monitor_list_id = $scan->{file_monitor_list_id}
324308 }, __FILE__, __LINE__);
325309 }
326310
327311 # Server-level bookkeeping
328312 my $q_err = $dbh->quote($server_err || '');
329313 $db->db_readwrite($dbh, qq{
330314 UPDATE SERVERS
331315 SET last_agent_scan_at = NOW(),
332316 last_agent_error = $q_err,
333317 last_heartbeat_at = NOW()
334318 WHERE server_id = $sid
335319 }, __FILE__, __LINE__);
336320
337321 # Clean up decrypted-key temp file
338322 unlink $tmp_key if $tmp_key && $tmp_key =~ m!^/tmp/\.ds_agent_key_!;
339323}
340324
341325$db->db_disconnect($dbh);
342326print "[$ts] agent scan complete: +$total_added added, ~$total_modified modified, -$total_deleted deleted\n"
343327 if ($total_added + $total_modified + $total_deleted) > 0;
344328exit 0;
345329
346330#---------------------------------------------------------------------
347331sub _ssh_argv {
348332 my ($user, $host, $port, $key, $opts) = @_;
349333 my @a = ('/usr/bin/ssh',
350334 '-o', 'BatchMode=yes',
351335 '-o', 'StrictHostKeyChecking=no',
352336 '-o', 'UserKnownHostsFile=/dev/null',
353337 '-o', 'LogLevel=ERROR',
354338 '-o', 'ConnectTimeout=8',
355339 '-p', $port);
356340 if ($key && -r $key) {
357341 push @a, '-i', $key;
358342 }
359343 if ($opts) {
360344 # Space-separated -o key=value pairs
361345 foreach my $o (split /\s+/, $opts) {
362346 push @a, '-o', $o if length $o;
363347 }
364348 }
365349 push @a, "$user\@$host";
366350 return @a;
367351}
368352
369353sub _ssh_run {
370354 my ($base, $cmd) = @_;
371355 my @argv = (@$base, $cmd);
372356 my ($wtr, $rdr, $err_fh);
373357 $err_fh = gensym;
374358 my $pid = eval { open3($wtr, $rdr, $err_fh, @argv) };
375359 return (-1, '', $@) if $@ || !$pid;
376360 close $wtr;
377361 binmode $rdr;
378362 my $out = do { local $/; <$rdr> // '' };
379363 my $err = do { local $/; <$err_fh> // '' };
380364 close $rdr; close $err_fh;
381365 waitpid $pid, 0;
382366 my $rc = $? >> 8;
383367 return ($rc, $out, $err);
384368}
385369
386370sub _materialize_key {
387371 my ($pem) = @_;
388372 return undef unless defined $pem && length $pem;
389373 my $t = POSIX::strftime('%s', localtime);
390374 my $path = "/tmp/.ds_agent_key_${t}_$$_" . int(rand(1_000_000));
391375 sysopen(my $fh, $path,
392376 Fcntl::O_WRONLY() | Fcntl::O_CREAT() | Fcntl::O_EXCL(),
393377 0600) or return undef;
394378 print $fh $pem;
395379 close $fh;
396380 return $path;
397381}
398382
399383sub _sh_quote {
400384 my $s = shift; $s //= '';
401385 # Single-quote and escape internal single quotes
402386 $s =~ s/'/'\\''/g;
403387 return "'$s'";
404388}
405389
406390sub _build_ignore_patterns {
407391 my $list = shift;
408392 my %out;
409393 return %out unless $list;
410394 for my $ig (split /[,\n]+/, $list) {
411395 $ig =~ s/^\s+|\s+$//g;
412396 next unless length $ig;
413397 my $re = quotemeta $ig;
414398 $re =~ s/\\\*/.*/g;
415399 $re =~ s/\\\?/./g;
416400 $out{$ig} = qr/$re/;
417401 }
418402 return %out;
419403}
420404
421405sub _build_type_filter {
422406 my $list = shift;
423407 my %out;
424408 return %out unless $list && length $list;
425409 for my $ext (split /,/, $list) {
426410 $ext =~ s/^\s+|\s+$//g;
427411 next unless length $ext;
428412 $ext = ".$ext" unless $ext =~ /^\./;
429413 $out{lc $ext} = 1;
430414 }
431415 return %out;
432416}
433417
434418sub _store_blob {
435419 my ($db, $dbh, $sha, $content) = @_;
436420 my $existing = $db->db_readwrite($dbh,
437421 "SELECT blob_sha FROM BLOB_STORE WHERE blob_sha = " . $dbh->quote($sha) . " LIMIT 1",
438422 __FILE__, __LINE__);
439423 if ($existing && $existing->{blob_sha}) {
440424 $db->db_readwrite($dbh,
441425 "UPDATE BLOB_STORE SET ref_count = ref_count + 1 WHERE blob_sha = " . $dbh->quote($sha),
442426 __FILE__, __LINE__);
443427 return;
444428 }
445429 my $gz = compress($content) // '';
446430 my $sth = $dbh->prepare(qq{
447431 INSERT INTO BLOB_STORE (blob_sha, content_gz, size_uncompressed, size_compressed, ref_count, first_seen_at)
448432 VALUES (?, ?, ?, ?, 1, NOW())
449433 });
450434 if ($sth) {
451435 $sth->execute($sha, $gz, length($content), length($gz));
452436 $sth->finish;
453437 }
454438}
Confirm restore
Backs current content to <target>.drift_restore_backup_<epoch>, writes the captured content, verifies SHA post-write.
Cancel Logged to RESTORE_LOG regardless of outcome.