diff --git a/README.md b/README.md index c37dc95..95a7a6b 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,35 @@ # ndrift +[![Tests](https://img.shields.io/github/actions/workflow/status/bradthx/ndrift/ci.yml?branch=main&label=tests)](https://github.com/bradthx/ndrift/actions/workflows/ci.yml) +[![Release](https://img.shields.io/github/v/release/bradthx/ndrift?label=release)](https://github.com/bradthx/ndrift/releases) + Copyright (c) 2026 Brad Boegler Licensed under the MIT License. See [LICENSE](LICENSE). ndrift is a lightweight Linux file integrity monitor. -It builds a trusted baseline of file metadata and content hashes, then compares future scans to detect drift such as modified files, added files, deleted files, permission changes, ownership changes, timestamp only changes, and filesystem attribute changes. +File Integrity Monitoring is a crucial part of any application deployment. If your app files +are changing, and you are not aware of it, that's a huge problem. I have seen hundreds of +examples of sites being compromized for various reasons, and site owners not being aware +of it for weeks or even months while in the meantime, client data and pii is being stolen. + +FIM is part of just about every modern CNAPP solution, and usually readily available for +applications deployed on the cloud. For many sites that are hosted on more traditional +hosting deployments such as shared, Dedicated, or VPS environments, FIM is not always easy +or implied to already exist by the hosting provider. + +I developed ndrift to be a super simple, but effective FIM utility that can be easily +deployed to any linux hosted web application and provide a robust FIM solution. + +ndrift builds a trusted baseline of file metadata and content hashes, then compares future scans to detect drift such as modified files, added files, deleted files, permission changes, ownership changes, timestamp only changes, and filesystem attribute changes. ## What ndrift does 1. Recursively scans one or more monitored directories. 2. Applies include rules and exclude rules to control scan scope. 3. Stores a signed baseline in local JSON files. -4. Detects drift on each scan with both console output and JSON output. +4. Detects file drift on each scan with both console output and JSON output. 5. Writes logs with rotation. 6. Supports periodic scan mode with an interval loop. 7. Supports deployment approval workflow for safe baseline updates. @@ -32,7 +48,7 @@ On init or baseline update, ndrift records per file data: 5. mode bits 6. uid and gid 7. owner and group names -8. lsattr flags when available +8. lsattr flags The baseline file includes additional integrity data: @@ -53,6 +69,16 @@ ndrift signs the canonical baseline JSON payload with HMAC SHA256 and writes a d On scan, signature verification runs before drift detection. If verification fails, ndrift returns exit code 2. +### 2a. State signing and verification + +ndrift also signs deployment state and audit data with a detached HMAC signature. + +1. State file path default: /var/lib/ndrift/ndrift-state.json +2. State signature path default: /var/lib/ndrift/ndrift-state.json.sig +3. Signature key path default: /var/lib/ndrift/ndrift-baseline.key + +State signature verification runs before approval and deployment trust decisions. If verification fails, ndrift returns exit code 2. + ### 3. Scan process Each scan executes these phases: @@ -130,8 +156,10 @@ sudo install -o root -g root -m 0600 ./ndrift-config.conf /etc/ndrift/ndrift.con 4. signature: /var/lib/ndrift/ndrift-baseline.json.sig 5. signing key: /var/lib/ndrift/ndrift-baseline.key 6. state: /var/lib/ndrift/ndrift-state.json -7. last report: /var/lib/ndrift/ndrift-report.json -8. log: /var/log/ndrift/ndrift.log +7. state signature: /var/lib/ndrift/ndrift-state.json.sig +8. lock file: /var/lib/ndrift/ndrift.lock +9. last report: /var/lib/ndrift/ndrift-report.json +10. log: /var/log/ndrift/ndrift.log ## Quick start @@ -190,8 +218,10 @@ Template in this repository: [ndrift-config.conf](ndrift-config.conf). 3. signature_path: detached signature path 4. signature_key_path: HMAC key path 5. state_path: deployment and audit state path -6. last_report_path: latest scan report path -7. log_path: rotating log file path +6. state_signature_path: detached signature path for state integrity +7. lock_path: process lock file path for overlap protection +8. last_report_path: latest scan report path +9. log_path: rotating log file path ### Scan scope and behavior settings @@ -210,6 +240,8 @@ Template in this repository: [ndrift-config.conf](ndrift-config.conf). 4. slack_webhook: webhook URL 5. s3_bucket: object storage destination for report upload +Default cron schedule is */30 * * * * which runs every 30 minutes. + ### Error policy 1. allow_read_errors false: treat file read failures as exit code 2 @@ -224,7 +256,7 @@ Template in this repository: [ndrift-config.conf](ndrift-config.conf). 1. 0: scan completed with no findings 2. 1: scan completed and findings were detected -3. 2: runtime error, integrity verification failure, or strict read error condition +3. 2: runtime error, integrity verification failure for baseline or state, or strict read error condition ## Report format @@ -274,14 +306,15 @@ Then place the output into the desired cron context. ### Built in safeguards 1. baseline signature verification before scan comparison -2. baseline write operations use atomic replace -3. baseline, signature, and key files are written with mode 600 -4. baseline location warning if placed inside monitored roots -5. symlink following disabled by default -6. configuration file hash drift detection -7. program hash drift detection -8. install posture warnings if program is not root owned or writable by group or others -9. strict exit codes for automation and alert pipelines +2. state signature verification before deployment approval checks and state load +3. advisory lock file guard prevents overlapping runs from racing state and baseline updates +4. baseline and state write operations use atomic replace +5. baseline, state, signature, and key files are written with mode 600 +6. baseline location warning if placed inside monitored roots +7. symlink following disabled by default +8. configuration file hash drift detection +9. program hash drift detection +10. strict exit codes for automation and alert pipelines ### Recommended hardening for operators @@ -298,7 +331,8 @@ Then place the output into the desired cron context. 1. Baseline signing currently uses a local symmetric key. If key material is compromised, signature trust is compromised. 2. Program hash checks can detect drift, but do not replace package signature verification. 3. Email, Slack, and object storage integrations depend on network and external credential security. -4. scan_updates_baseline true reduces repeated alerts, but also reduces strict persistence of change evidence across scans. +4. scan_updates_baseline true reduces repeated alerts, but also reduces strict persistence of change evidence across scans. This option is useful for very specific deployments and +integrations. Only enable if you understand this and have a very specific use case. ## Project files diff --git a/ndrift b/ndrift index 5d73d3e..3090bef 100755 --- a/ndrift +++ b/ndrift @@ -6,6 +6,7 @@ import argparse import configparser import datetime as dt +import fcntl import fnmatch import getpass import grp @@ -25,6 +26,7 @@ import sys import time import urllib.error import urllib.request +from contextlib import contextmanager from dataclasses import dataclass from typing import Any, Dict, Iterable, List, Optional, Tuple @@ -55,6 +57,8 @@ class NdriftConfig: signature_path: str signature_key_path: str state_path: str + state_signature_path: str + lock_path: str last_report_path: str log_path: str log_max_bytes: int @@ -180,6 +184,8 @@ def default_config_values(config_path: str) -> Dict[str, str]: "signature_path": "/var/lib/ndrift/ndrift-baseline.json.sig", "signature_key_path": "/var/lib/ndrift/ndrift-baseline.key", "state_path": "/var/lib/ndrift/ndrift-state.json", + "state_signature_path": "/var/lib/ndrift/ndrift-state.json.sig", + "lock_path": "/var/lib/ndrift/ndrift.lock", "last_report_path": "/var/lib/ndrift/ndrift-report.json", "log_path": "/var/log/ndrift/ndrift.log", "log_max_bytes": "1048576", @@ -188,7 +194,7 @@ def default_config_values(config_path: str) -> Dict[str, str]: "max_file_size_mb": "50", "hash_on_metadata_change_only": "true", "throttle_ms": "0", - "cron_schedule": "*/5 * * * *", + "cron_schedule": "*/30 * * * *", "email_to": "", "smtp_host": "localhost", "slack_webhook": "", @@ -244,6 +250,8 @@ def load_config(config_path: str) -> NdriftConfig: signature_path = resolve_config_relative_path(config_path, get_value("signature_path")) signature_key_path = resolve_config_relative_path(config_path, get_value("signature_key_path")) state_path = resolve_config_relative_path(config_path, get_value("state_path")) + state_signature_path = resolve_config_relative_path(config_path, get_value("state_signature_path")) + lock_path = resolve_config_relative_path(config_path, get_value("lock_path")) last_report_path = resolve_config_relative_path(config_path, get_value("last_report_path")) log_path = resolve_config_relative_path(config_path, get_value("log_path")) @@ -256,6 +264,8 @@ def load_config(config_path: str) -> NdriftConfig: signature_path=signature_path, signature_key_path=signature_key_path, state_path=state_path, + state_signature_path=state_signature_path, + lock_path=lock_path, last_report_path=last_report_path, log_path=log_path, log_max_bytes=int(get_value("log_max_bytes")), @@ -579,6 +589,11 @@ def baseline_to_bytes(baseline_data: Dict[str, Any]) -> bytes: return json.dumps(baseline_data, sort_keys=True, separators=(",", ":")).encode("utf-8") +def state_to_bytes(state_data: Dict[str, Any]) -> bytes: + """Serializes state data in stable JSON form.""" + return json.dumps(state_data, sort_keys=True, separators=(",", ":")).encode("utf-8") + + def write_baseline(config: NdriftConfig, baseline_data: Dict[str, Any]) -> None: """Writes baseline file and detached signature file.""" key = load_or_create_key(config.signature_key_path) @@ -625,31 +640,124 @@ def default_state() -> Dict[str, Any]: "deploy": {"active": False, "started_at": "", "started_by": ""}, "approvals": [], "audit": [], + "meta": {"revision": 0, "previous_hash": ""}, } -def load_state(config: NdriftConfig) -> Dict[str, Any]: - """Loads state JSON or creates defaults.""" - if not os.path.exists(config.state_path): - return default_state() - with open(config.state_path, "r", encoding="utf-8") as handle: - data = json.load(handle) - if not isinstance(data, dict): - raise NdriftError("State file is invalid") +def normalize_state_data(state_data: Dict[str, Any]) -> Dict[str, Any]: + """Normalizes state data and enforces expected field types.""" merged = default_state() - merged.update(data) + merged.update(state_data) if not isinstance(merged.get("deploy"), dict): merged["deploy"] = default_state()["deploy"] if not isinstance(merged.get("approvals"), list): merged["approvals"] = [] if not isinstance(merged.get("audit"), list): merged["audit"] = [] + if not isinstance(merged.get("meta"), dict): + merged["meta"] = default_state()["meta"] + + meta = merged["meta"] + revision_value = meta.get("revision", 0) + try: + revision = int(revision_value) + except (TypeError, ValueError): + revision = 0 + meta["revision"] = max(revision, 0) + prev_hash = meta.get("previous_hash", "") + meta["previous_hash"] = str(prev_hash) return merged +@contextmanager +def process_lock(config: NdriftConfig): + """Acquires an advisory lock to prevent overlapping ndrift processes.""" + ensure_parent_dir(config.lock_path) + lock_fd = os.open(config.lock_path, os.O_RDWR | os.O_CREAT, 0o600) + try: + os.fchmod(lock_fd, 0o600) + except OSError: + pass + + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + os.close(lock_fd) + raise NdriftError( + f"Another ndrift process is already running, lock: {config.lock_path}" + ) from exc + + try: + os.lseek(lock_fd, 0, os.SEEK_SET) + os.ftruncate(lock_fd, 0) + os.write(lock_fd, f"pid={os.getpid()}\n".encode("utf-8")) + yield + finally: + try: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + finally: + os.close(lock_fd) + + +def load_state_signed_payload(config: NdriftConfig) -> Dict[str, Any]: + """Loads state payload and verifies detached signature.""" + if not os.path.exists(config.state_path): + raise NdriftError(f"Cannot read state file: {config.state_path}") + if not os.path.exists(config.state_signature_path): + raise NdriftError( + f"Cannot verify state signature, file is missing: {config.state_signature_path}" + ) + + with open(config.state_path, "r", encoding="utf-8") as handle: + payload_data = json.load(handle) + if not isinstance(payload_data, dict): + raise NdriftError("State file is invalid") + + with open(config.state_signature_path, "r", encoding="utf-8") as sig_handle: + state_signature = sig_handle.read().strip() + if not state_signature: + raise NdriftError("Cannot verify state signature, signature file is empty") + + key = load_or_create_key(config.signature_key_path) + payload_bytes = state_to_bytes(payload_data) + if not verify_payload_signature(payload_bytes, key, state_signature): + raise NdriftError("Cannot verify state signature, state integrity failed") + return payload_data + + +def load_state(config: NdriftConfig) -> Dict[str, Any]: + """Loads state JSON or creates defaults.""" + if not os.path.exists(config.state_path): + return default_state() + data = load_state_signed_payload(config) + return normalize_state_data(data) + + def save_state(config: NdriftConfig, state_data: Dict[str, Any]) -> None: """Writes deployment state JSON to disk.""" - atomic_write_text(config.state_path, json.dumps(state_data, indent=2, sort_keys=True) + "\n", mode=0o600) + previous_state: Optional[Dict[str, Any]] = None + if os.path.exists(config.state_path): + previous_state = normalize_state_data(load_state_signed_payload(config)) + + normalized = normalize_state_data(state_data) + previous_hash = "" + previous_revision = 0 + if previous_state is not None: + previous_hash = data_sha256(state_to_bytes(previous_state)) + previous_revision = int(previous_state.get("meta", {}).get("revision", 0)) + + normalized["meta"]["previous_hash"] = previous_hash + normalized["meta"]["revision"] = previous_revision + 1 + + key = load_or_create_key(config.signature_key_path) + signature = sign_payload(state_to_bytes(normalized), key) + + atomic_write_text( + config.state_path, + json.dumps(normalized, indent=2, sort_keys=True) + "\n", + mode=0o600, + ) + atomic_write_text(config.state_signature_path, signature + "\n", mode=0o600) def add_audit_event(state_data: Dict[str, Any], event: str, details: Dict[str, Any]) -> None: @@ -860,29 +968,30 @@ def run_init(args: argparse.Namespace) -> int: config = load_config(config_path) logger = setup_logger(config) - directories = resolve_scan_dirs(config, args.directories) - assert_scan_directories(directories) + with process_lock(config): + directories = resolve_scan_dirs(config, args.directories) + assert_scan_directories(directories) - warnings = maybe_warn_baseline_location(config, directories) - for warning in warnings: - logger.warning(warning) + warnings = maybe_warn_baseline_location(config, directories) + for warning in warnings: + logger.warning(warning) - files, snapshot_warnings, errors, stats = snapshot_dirs(directories, config, previous_entries=None, logger=logger) - warnings.extend(snapshot_warnings) - if errors and not config.allow_read_errors: - raise NdriftError(f"Hashing failed for one or more files, count: {len(errors)}") + files, snapshot_warnings, errors, stats = snapshot_dirs(directories, config, previous_entries=None, logger=logger) + warnings.extend(snapshot_warnings) + if errors and not config.allow_read_errors: + raise NdriftError(f"Hashing failed for one or more files, count: {len(errors)}") - baseline_data = build_baseline_data(directories, config, files, reason="initial baseline") - write_baseline(config, baseline_data) - state_data = load_state(config) - add_audit_event(state_data, "init", {"directories": directories, "stats": stats}) - save_state(config, state_data) + baseline_data = build_baseline_data(directories, config, files, reason="initial baseline") + write_baseline(config, baseline_data) + state_data = load_state(config) + add_audit_event(state_data, "init", {"directories": directories, "stats": stats}) + save_state(config, state_data) - logger.info("Baseline created with %s files", len(files)) - for warning in warnings: - print(f"[WARN] {warning}") - print(f"Baseline created: {config.baseline_path}") - print(f"Signature created: {config.signature_path}") + logger.info("Baseline created with %s files", len(files)) + for warning in warnings: + print(f"[WARN] {warning}") + print(f"Baseline created: {config.baseline_path}") + print(f"Signature created: {config.signature_path}") return EXIT_OK @@ -925,66 +1034,67 @@ def run_scan_once( logger: logging.Logger, ) -> int: """Executes a single scan and report cycle.""" - directories = resolve_scan_dirs(config, cli_dirs) - assert_scan_directories(directories) - baseline_data = load_baseline(config) - baseline_entries = baseline_data.get("files", {}) - if not isinstance(baseline_entries, dict): - raise NdriftError("Baseline files section is invalid") - - warnings = maybe_warn_baseline_location(config, directories) - integrity_findings, integrity_warnings = build_integrity_findings(config, baseline_data) - warnings.extend(integrity_warnings) - - current_entries, snapshot_warnings, errors, stats = snapshot_dirs( - directories, - config, - previous_entries=baseline_entries, - logger=logger, - ) - warnings.extend(snapshot_warnings) + with process_lock(config): + directories = resolve_scan_dirs(config, cli_dirs) + assert_scan_directories(directories) + baseline_data = load_baseline(config) + baseline_entries = baseline_data.get("files", {}) + if not isinstance(baseline_entries, dict): + raise NdriftError("Baseline files section is invalid") + + warnings = maybe_warn_baseline_location(config, directories) + integrity_findings, integrity_warnings = build_integrity_findings(config, baseline_data) + warnings.extend(integrity_warnings) + + current_entries, snapshot_warnings, errors, stats = snapshot_dirs( + directories, + config, + previous_entries=baseline_entries, + logger=logger, + ) + warnings.extend(snapshot_warnings) - findings = integrity_findings + compare_snapshots(baseline_entries, current_entries) - if stats.get("size_limited", 0) > 0: - warnings.append(f"Files above max size limit: {stats.get('size_limited', 0)}") + findings = integrity_findings + compare_snapshots(baseline_entries, current_entries) + if stats.get("size_limited", 0) > 0: + warnings.append(f"Files above max size limit: {stats.get('size_limited', 0)}") - report = { - "time": utc_now(), - "directories": directories, - "summary": summarize_findings(findings), - "findings": findings, - "warnings": warnings, - "errors": errors, - "stats": stats, - } + report = { + "time": utc_now(), + "directories": directories, + "summary": summarize_findings(findings), + "findings": findings, + "warnings": warnings, + "errors": errors, + "stats": stats, + } - if config.scan_updates_baseline: - if errors and not config.allow_read_errors: - warnings.append( - "Baseline auto update skipped because strict read error policy is enabled" - ) - else: - baseline_data = build_baseline_data( - directories, - config, - current_entries, - reason="auto baseline refresh from scan", - ) - write_baseline(config, baseline_data) - logger.info("Baseline auto updated after scan") + if config.scan_updates_baseline: + if errors and not config.allow_read_errors: + warnings.append( + "Baseline auto update skipped because strict read error policy is enabled" + ) + else: + baseline_data = build_baseline_data( + directories, + config, + current_entries, + reason="auto baseline refresh from scan", + ) + write_baseline(config, baseline_data) + logger.info("Baseline auto updated after scan") - write_report(config, report) - maybe_send_alerts(config, report, logger) + write_report(config, report) + maybe_send_alerts(config, report, logger) - if json_output: - print(json.dumps(report, indent=2, sort_keys=True)) - else: - print_report_console(report) + if json_output: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + print_report_console(report) - if errors and not config.allow_read_errors: - return EXIT_ERROR - if findings: - return EXIT_CHANGES + if errors and not config.allow_read_errors: + return EXIT_ERROR + if findings: + return EXIT_CHANGES return EXIT_OK @@ -1025,51 +1135,52 @@ def run_update_baseline(args: argparse.Namespace) -> int: """Handles baseline update command with audit tracking.""" config = load_config(args.config) logger = setup_logger(config) - directories = resolve_scan_dirs(config, args.directories) - assert_scan_directories(directories) - - if args.require_signature: - verify_baseline_signature(config) - - state_data = load_state(config) - validate_deployment_approval(state_data) - - reason = args.reason.strip() - if not reason: - raise NdriftError("Baseline update requires a reason") - - previous_entries: Optional[Dict[str, Dict[str, Any]]] = None - if os.path.exists(config.baseline_path): - try: - baseline_data = load_baseline(config) - candidate = baseline_data.get("files", {}) - if isinstance(candidate, dict): - previous_entries = candidate - except Exception: - previous_entries = None - - files, warnings, errors, stats = snapshot_dirs(directories, config, previous_entries, logger) - if warnings: - for warning in warnings: - logger.warning(warning) - if errors and not config.allow_read_errors: - raise NdriftError(f"Hashing failed for one or more files, count: {len(errors)}") + with process_lock(config): + directories = resolve_scan_dirs(config, args.directories) + assert_scan_directories(directories) + + if args.require_signature: + verify_baseline_signature(config) + + state_data = load_state(config) + validate_deployment_approval(state_data) + + reason = args.reason.strip() + if not reason: + raise NdriftError("Baseline update requires a reason") + + previous_entries: Optional[Dict[str, Dict[str, Any]]] = None + if os.path.exists(config.baseline_path): + try: + baseline_data = load_baseline(config) + candidate = baseline_data.get("files", {}) + if isinstance(candidate, dict): + previous_entries = candidate + except Exception: + previous_entries = None + + files, warnings, errors, stats = snapshot_dirs(directories, config, previous_entries, logger) + if warnings: + for warning in warnings: + logger.warning(warning) + if errors and not config.allow_read_errors: + raise NdriftError(f"Hashing failed for one or more files, count: {len(errors)}") - baseline_data = build_baseline_data(directories, config, files, reason=reason) - write_baseline(config, baseline_data) + baseline_data = build_baseline_data(directories, config, files, reason=reason) + write_baseline(config, baseline_data) - add_audit_event( - state_data, - "update-baseline", - { - "reason": reason, - "directories": directories, - "stats": stats, - }, - ) - save_state(config, state_data) - logger.info("Baseline updated with %s files", len(files)) - print(f"Baseline updated: {config.baseline_path}") + add_audit_event( + state_data, + "update-baseline", + { + "reason": reason, + "directories": directories, + "stats": stats, + }, + ) + save_state(config, state_data) + logger.info("Baseline updated with %s files", len(files)) + print(f"Baseline updated: {config.baseline_path}") return EXIT_OK @@ -1090,15 +1201,16 @@ def run_report(args: argparse.Namespace) -> int: def run_deploy_start(args: argparse.Namespace) -> int: """Marks deployment window start in state file.""" config = load_config(args.config) - state_data = load_state(config) - deploy = state_data.setdefault("deploy", {}) - if deploy.get("active"): - raise NdriftError("Deployment mode is already active") - deploy["active"] = True - deploy["started_at"] = utc_now() - deploy["started_by"] = getpass.getuser() - add_audit_event(state_data, "deploy-start", {"started_at": deploy["started_at"]}) - save_state(config, state_data) + with process_lock(config): + state_data = load_state(config) + deploy = state_data.setdefault("deploy", {}) + if deploy.get("active"): + raise NdriftError("Deployment mode is already active") + deploy["active"] = True + deploy["started_at"] = utc_now() + deploy["started_by"] = getpass.getuser() + add_audit_event(state_data, "deploy-start", {"started_at": deploy["started_at"]}) + save_state(config, state_data) print("Deployment mode started") return EXIT_OK @@ -1106,19 +1218,20 @@ def run_deploy_start(args: argparse.Namespace) -> int: def run_approve(args: argparse.Namespace) -> int: """Stores deployment approval with reason for audit.""" config = load_config(args.config) - state_data = load_state(config) - reason = args.reason.strip() - if not reason: - raise NdriftError("Approval reason is required") - approval = { - "time": utc_now(), - "user": getpass.getuser(), - "reason": reason, - "deploy_active": bool(state_data.get("deploy", {}).get("active")), - } - state_data.setdefault("approvals", []).append(approval) - add_audit_event(state_data, "approve", approval) - save_state(config, state_data) + with process_lock(config): + state_data = load_state(config) + reason = args.reason.strip() + if not reason: + raise NdriftError("Approval reason is required") + approval = { + "time": utc_now(), + "user": getpass.getuser(), + "reason": reason, + "deploy_active": bool(state_data.get("deploy", {}).get("active")), + } + state_data.setdefault("approvals", []).append(approval) + add_audit_event(state_data, "approve", approval) + save_state(config, state_data) print("Approval recorded") return EXIT_OK @@ -1126,15 +1239,16 @@ def run_approve(args: argparse.Namespace) -> int: def run_deploy_end(args: argparse.Namespace) -> int: """Marks deployment window end in state file.""" config = load_config(args.config) - state_data = load_state(config) - deploy = state_data.setdefault("deploy", {}) - if not deploy.get("active"): - raise NdriftError("Deployment mode is not active") - ended_at = utc_now() - deploy["active"] = False - deploy["ended_at"] = ended_at - add_audit_event(state_data, "deploy-end", {"ended_at": ended_at}) - save_state(config, state_data) + with process_lock(config): + state_data = load_state(config) + deploy = state_data.setdefault("deploy", {}) + if not deploy.get("active"): + raise NdriftError("Deployment mode is not active") + ended_at = utc_now() + deploy["active"] = False + deploy["ended_at"] = ended_at + add_audit_event(state_data, "deploy-end", {"ended_at": ended_at}) + save_state(config, state_data) print("Deployment mode ended") return EXIT_OK diff --git a/ndrift-config.conf b/ndrift-config.conf index 89fc508..a01dcb9 100644 --- a/ndrift-config.conf +++ b/ndrift-config.conf @@ -35,6 +35,12 @@ signature_key_path = /var/lib/ndrift/ndrift-baseline.key # Keep writable only by the account that runs ndrift. # Default path: /var/lib/ndrift/ndrift-state.json state_path = /var/lib/ndrift/ndrift-state.json +# Detached signature path for state integrity verification. +# Default path: /var/lib/ndrift/ndrift-state.json.sig +state_signature_path = /var/lib/ndrift/ndrift-state.json.sig +# Lock file path used to prevent overlapping ndrift runs. +# Default path: /var/lib/ndrift/ndrift.lock +lock_path = /var/lib/ndrift/ndrift.lock # Last scan report path used by the report command and automation jobs. # This file is replaced each run and can be shipped to external systems. # Default path: /var/lib/ndrift/ndrift-report.json @@ -68,8 +74,8 @@ throttle_ms = 0 # Scheduler settings # Cron expression used by the cron command helper output. -# Example shown runs every 5 minutes. Update to your desired schedule. -cron_schedule = */5 * * * * +# Example shown runs every 30 minutes. Update to your desired schedule. +cron_schedule = */30 * * * * # Alert integration settings # Destination mailbox for alert notifications. diff --git a/tests/test_ndrift.py b/tests/test_ndrift.py index 3fb05ab..1e88b5c 100644 --- a/tests/test_ndrift.py +++ b/tests/test_ndrift.py @@ -1,6 +1,7 @@ # Copyright (c) 2026 Brad Boegler # Licensed under the MIT License. See LICENSE. import configparser +import fcntl import importlib.util import json import os @@ -50,6 +51,8 @@ def build_config_file(config_path: Path, site_dir: Path, overrides=None): "signature_path": str(state_dir / "ndrift-baseline.json.sig"), "signature_key_path": str(state_dir / "ndrift-baseline.key"), "state_path": str(state_dir / "ndrift-state.json"), + "state_signature_path": str(state_dir / "ndrift-state.json.sig"), + "lock_path": str(state_dir / "ndrift.lock"), "last_report_path": str(state_dir / "ndrift-report.json"), "log_path": str(log_dir / "ndrift.log"), "log_max_bytes": "1048576", @@ -58,7 +61,7 @@ def build_config_file(config_path: Path, site_dir: Path, overrides=None): "max_file_size_mb": "50", "hash_on_metadata_change_only": "true", "throttle_ms": "0", - "cron_schedule": "*/5 * * * *", + "cron_schedule": "*/30 * * * *", "email_to": "", "smtp_host": "localhost", "slack_webhook": "", @@ -144,9 +147,59 @@ def test_init_creates_expected_artifacts(self): "signature_path", "signature_key_path", "state_path", + "state_signature_path", + "lock_path", ]: self.assertTrue(Path(self.config_values[key]).exists(), msg=f"missing {key}") + def test_scan_fails_when_lock_is_held(self): + self._write("index.php", "