From e527e714888d078ce879ebbea414efbcbb6b6d3d Mon Sep 17 00:00:00 2001 From: Marco D'Aleo Date: Tue, 23 Jun 2026 16:01:05 +0100 Subject: [PATCH] Add DEVELOPMENT.md and SECURITY.md --- DEVELOPMENT.md | 835 +++++++++++++++++++++++++++++++++++++++++++++++++ SECURITY.md | 236 ++++++++++++++ 2 files changed, 1071 insertions(+) create mode 100644 DEVELOPMENT.md create mode 100644 SECURITY.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..0505d01 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,835 @@ +# chguard Development Guide + +Interested in the internals of chguard? + +This guide describes the current `chguard` codebase for maintainers. It focuses on how the project is organised, what calls what, how filesystem metadata flows into SQLite snapshots, and which invariants matter when changing the code. + +--- + +## 1. What chguard does + +`chguard` is a safety-first command-line tool for snapshotting and restoring filesystem ownership and permission metadata. + +Its core pipeline is: + +```text +Filesystem tree + | + | chguard --save PATH --name NAME + v +SQLite snapshot + states: snapshot metadata + entries: relative path, type, mode, uid, gid + | + | chguard --restore NAME [--dry-run] [--yes] + v +Restore plan + preview owner/mode differences + apply chmod/chown only after confirmation +``` + +`chguard` deliberately does not track file contents, hashes, ACLs, extended attributes, deleted files, or new files. It only records enough state to compare and restore: + +```text +relative path +entry type: file | dir | symlink +permission bits +numeric uid +numeric gid +``` + +Wrapper mode adds one more flow: + +```text +chguard -- chown|chmod|chgrp ... PATH... + -> discover existing path arguments + -> save an automatic pre-command snapshot + -> run the wrapped command + -> exit with the wrapped command's return code +``` + +Wrapper mode is intentionally limited to `chown`, `chmod`, and `chgrp`. Other commands are rejected because chguard only protects ownership and permission metadata. + +--- + +## 2. Repository layout + +The project is a single Python package under `chguard/`. + +```text +chguard/ + __init__.py package marker + cli.py argparse CLI, user interaction, Rich output, dispatch + db.py SQLite path, schema creation, state CRUD helpers + scan.py filesystem tree scan into Entry objects + restore.py restore planning and chmod/chown application + util.py small path normalisation helper + +pyproject.toml Poetry package metadata and console script +poetry.lock locked dependency graph +README.md user-facing documentation +.pre-commit-config.yaml Black and generic pre-commit hooks +.gitea/workflows/ lint, dependency audit, SBOM and Grype workflows +dist/ built release artifacts, not source +``` + +The installed command is configured in `pyproject.toml`: + +```toml +[tool.poetry.scripts] +chguard = "chguard.cli:main" +``` + +There is no `chguard/__main__.py` at the time of writing, so `python -m chguard` is not the supported entry point. Use the installed `chguard` command or `poetry run chguard` during development. + +--- + +## 3. Main runtime flows + +### 3.1 CLI entry flow + +All user-facing behaviour enters through `chguard.cli.main()`. + +```text +chguard command + -> chguard.cli.main() + -> split wrapper command after --, if present + -> build argparse parser + -> install argcomplete hooks + -> parse arguments + -> open/init SQLite database + -> dispatch to wrapper, prune, list, delete, save, or restore branch +``` + +The top-level mutually exclusive actions are: + +```text +--save PATH snapshot a path under a required --name +--restore STATE preview and optionally apply a saved state +--list list saved states +--delete STATE delete one saved state +--prune-states delete states older than an age, or all states +wrapper mode chguard -- chown|chmod|chgrp ... +``` + +`cli.py` currently owns both orchestration and most presentation logic. The narrower modules should stay narrow: + +```text +db.py owns persistence helpers and schema setup +scan.py owns filesystem metadata scanning +restore.py owns compare/apply semantics +util.py owns shared small helpers +``` + +If a change is not about command-line parsing, confirmation, or display, prefer keeping it out of `cli.py`. + +### 3.2 Subcommand call graph + +```mermaid +flowchart TD + A[chguard.cli.main] --> B{wrapper command?} + B -->|yes| C[validate chown/chmod/chgrp] + C --> D[extract existing path args] + D --> E[create auto state in db] + E --> F[run wrapped command] + B -->|no| G[parse action] + G -->|--save| H[util.normalize_root] + H --> I[scan.scan_tree] + I --> J[db.create_state + entries insert] + G -->|--restore| K[db.get_state] + K --> L[restore.plan_restore] + L --> M[render Rich diff table] + M --> N{--dry-run?} + N -->|no| O[confirm and root check] + O --> P[restore.apply_restore] + G -->|--list| Q[query states and entries] + G -->|--delete| R[db.delete_state] + G -->|--prune-states| S[select cutoff/all] + S --> T[preview deletion table] + T --> U[confirm] + U --> V[db.prune_states_before or db.prune_all_states] +``` + +Important dependency direction: + +```text +cli.py + depends on db.py, scan.py, restore.py, util.py, Rich, argcomplete + +scan.py + depends on pathlib, os.walk, lstat/stat only + +restore.py + depends on pathlib, lstat/stat, os.chown, os.chmod only + +db.py + depends on sqlite3 and platformdirs +``` + +--- + +## 4. Snapshot storage + +Snapshots are stored in a local SQLite database. + +Default path: + +```text +platformdirs.user_data_dir("chguard")/states.db +``` + +Users can override the database with: + +```bash +chguard --db /path/to/states.db ... +``` + +### 4.1 Database schema + +The schema is created by `db.init_db()`. + +```sql +CREATE TABLE IF NOT EXISTS states ( + id INTEGER PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + root_path TEXT NOT NULL, + created_at TEXT NOT NULL, + created_by_uid INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS entries ( + state_id INTEGER NOT NULL, + path TEXT NOT NULL, + type TEXT NOT NULL, + mode INTEGER NOT NULL, + uid INTEGER NOT NULL, + gid INTEGER NOT NULL, + PRIMARY KEY (state_id, path), + FOREIGN KEY (state_id) REFERENCES states(id) ON DELETE CASCADE +); +``` + +The primary key on `(state_id, path)` means one snapshot cannot contain duplicate relative paths. Wrapper mode also keeps an in-memory `seen_paths` set to avoid duplicate inserts when a command names overlapping paths such as `foo` and `foo/bar`. + +### 4.2 Stored values + +`entries.path` is relative to `states.root_path`. The root itself is stored as the empty string `""`. + +`entries.mode` stores permission bits only, using `stat.S_IMODE()`. It does not store file type bits. + +`entries.uid` and `entries.gid` are numeric. User and group names are resolved only for restore preview display in `cli.py`. + +`entries.type` can be: + +```text +dir +file +symlink +``` + +Special files such as devices, sockets, and FIFOs are skipped by `scan.py` and wrapper-mode entry collection. + +--- + +## 5. Data objects + +The codebase uses a small set of dataclasses rather than a large domain model. + +| Dataclass | File | Purpose | +|---|---|---| +| `db.State` | `db.py` | One row from `states`, used by restore. | +| `scan.Entry` | `scan.py` | One scanned filesystem item relative to a snapshot root. | +| `restore.PlannedChange` | `restore.py` | One restore comparison result, such as mode drift, owner drift, missing path, or type mismatch. | + +The tuple shape passed from SQLite to restore functions is: + +```python +(path: str, type: str, mode: int, uid: int, gid: int) +``` + +Keep this shape stable or change both `cli.py` and `restore.py` together. + +--- + +## 6. Saving snapshots + +The save entry point is the `--save` branch in `cli.main()`. + +```text +--save PATH --name NAME + -> normalize_root(PATH) + -> reject existing NAME unless --overwrite + -> create states row + -> scan_tree(root, excludes=args.exclude) + -> refuse if a captured entry is root-owned and current process is not root + -> insert entries rows +``` + +`util.normalize_root()` expands `~` and resolves the path: + +```python +Path(path).expanduser().resolve() +``` + +`scan.scan_tree()` uses `lstat()` and `os.walk(..., followlinks=False)`. It does not follow symlinks while scanning. It records regular files, directories, and symlinks, and skips special files. + +### 6.1 Excludes + +`--exclude` is implemented by `scan._is_excluded()` as simple relative prefix matching. + +Examples: + +```text +--exclude cache skips cache and cache/... +--exclude var/tmp skips var/tmp and var/tmp/... +``` + +Excludes are not globs or regular expressions. They are stripped of leading and trailing slashes before comparison. + +### 6.2 Transaction behaviour + +Save runs inside `with conn:`. If scanning finds a root-owned entry while the process is not root, `SystemExit` interrupts the transaction and sqlite3 rolls it back. This prevents partially saved states for the normal save flow. + +--- + +## 7. Wrapper mode + +Wrapper mode is detected before argparse parses normal options. Everything after the first `--` is treated as the wrapped command. + +```bash +chguard -- chmod 755 file +chguard -- chown user:group file +chguard -- chgrp staff file +``` + +Supported commands are checked by basename only: + +```text +chown +chmod +chgrp +``` + +The wrapper snapshot flow is: + +```text +wrapper_cmd + -> _extract_paths_from_command() + -> _common_snapshot_root() + -> create auto-YYYYMMDD-HHMMSS state + -> _iter_entries_for_target() for each path + -> insert each relative path once + -> run subprocess.run(wrapper_cmd) + -> exit with subprocess return code +``` + +### 7.1 Path extraction limits + +`_extract_paths_from_command()` is intentionally simple. It treats any existing non-option argument as a path and skips arguments starting with `-`. + +This works for common forms such as: + +```bash +chguard -- chmod 644 file +chguard -- chown user:group file1 file2 +``` + +It is not a full parser for every `chmod`, `chown`, or `chgrp` option. Be careful when adding wrapper support for options that take path-like values or when supporting more commands. + +### 7.2 Snapshot root selection + +For one path, `_common_snapshot_root()` uses that path. For multiple paths, it uses `os.path.commonpath()` across resolved paths. + +That means one auto snapshot can cover commands such as: + +```bash +chguard -- chmod 700 foo1 foo2 +``` + +without creating multiple entries with the empty relative path. + +### 7.3 Empty path list + +If no existing path arguments are found, wrapper mode does not create a snapshot. It still runs the wrapped command and returns the wrapped command's exit code. + +--- + +## 8. Restore planning and application + +Restore is split into two phases: + +```text +restore.plan_restore() compare current filesystem to saved rows +restore.apply_restore() apply selected chmod/chown operations +``` + +The CLI uses `plan_restore()` first, renders a Rich table, then applies only after confirmation unless `--dry-run` is set. + +### 8.1 Restore target root + +By default, restore targets the original `states.root_path`. + +Users can override it with: + +```bash +chguard --restore NAME --root /alternate/root +``` + +The override is normalised with `normalize_root()` before use. Stored relative paths are appended to the target root. + +### 8.2 Scope flags + +Restore scope is selected in `cli.py`: + +```text +default restore permissions and ownership +--permissions restore permission bits only +--owner restore uid/gid only +--permissions --owner argparse allows both; behaviour is both +``` + +The CLI computes two booleans and passes them to both planning and apply: + +```python +restore_permissions +restore_owner +``` + +### 8.3 Planned change types + +`restore.plan_restore()` can produce these `PlannedChange.kind` values: + +```text +mode current permission bits differ from saved mode +owner current uid/gid differ from saved uid/gid +missing saved path does not currently exist +type current path type differs from saved type +``` + +The CLI restore table displays applicable `mode` and `owner` changes, plus non-applicable `missing` and `type` drift in a `Skipped` column. Missing paths and type mismatches are previewed for operator visibility but are never created, deleted, replaced, or otherwise repaired by restore. + +### 8.4 Applying changes + +`restore.apply_restore()` re-checks each path with `lstat()` before applying. It skips missing paths, special files, and type mismatches. + +When enabled by scope flags, it runs: + +```python +os.chown(path, want_uid, want_gid, follow_symlinks=False) +os.chmod(path, want_mode, follow_symlinks=False) +``` + +`PermissionError` and `NotImplementedError` are swallowed in `apply_restore()`. The CLI tries to catch obvious privilege problems before apply, but apply remains best-effort for platform differences such as chmod on symlinks. + +--- + +## 9. Safety and privilege model + +Important product boundaries: + +```text +chguard never creates files during restore +chguard never deletes files during restore +chguard never moves or renames files +chguard never changes file contents +chguard never escalates privileges automatically +``` + +### 9.1 Root-owned files during save + +Both normal save and wrapper snapshot creation refuse to save a root-owned entry when the process effective uid is not root. + +Normal save error: + +```text +This path contains root-owned files. +Saving this state requires sudo. +``` + +Wrapper mode error: + +```text +This command affects root-owned files. +Please re-run with sudo. +``` + +### 9.2 Root requirement during restore + +Restore preview and dry-run do not require root. + +Before applying, `cli.py` marks the operation as needing root when a changed path is not owned by the current effective uid. If root is needed and the process is not root, the CLI exits before confirmation and apply. + +This is intentionally conservative around ownership and mode changes. Do not add automatic sudo execution. + +### 9.3 Confirmation + +Destructive or mutating operations require explicit confirmation unless `--yes` is provided: + +```text +restore apply +prune states +``` + +If stdin is not a TTY and `--yes` is not provided, `_confirm_or_abort()` refuses to continue. + +### 9.4 Symlinks + +The implementation uses `lstat()` and `follow_symlinks=False`, so it does not follow symlink targets while scanning or restoring. + +Current code can record symlink entries with type `symlink`. Ownership restore is attempted with `os.chown(..., follow_symlinks=False)`. Permission restore is attempted with `os.chmod(..., follow_symlinks=False)` and ignored if the platform does not support it. + +The README currently describes symbolic links as skipped entirely. Treat this as a documentation/behaviour point to resolve carefully before making symlink-related changes. + +--- + +## 10. Listing, deleting, and pruning states + +### 10.1 Listing + +`--list` queries all states newest-first and displays: + +```text +State +Snapshot root +Captured paths +Created +``` + +`_captured_paths_summary()` displays `directory tree`, `file`, or `symlink` when the snapshot contains a root entry. Otherwise it shows up to five top-level captured relative paths. + +Auto snapshots with names starting `auto-` are highlighted in bright cyan. + +### 10.2 Deleting one state + +`--delete STATE` calls `db.delete_state()`. The `entries` rows are removed by `ON DELETE CASCADE`. + +There is currently no confirmation prompt for deleting one named state. + +### 10.3 Pruning states + +`--prune-states` supports three forms: + +```bash +chguard --prune-states=14 +chguard --prune-states=all +CHGUARD_STATES_LIFE=30 chguard --prune-states +``` + +Parsing lives in `_parse_prune_states_value()`. + +`--dry-run` previews matching states without deleting. Without `--dry-run`, pruning requires confirmation or `--yes`. + +The default age environment variable is: + +```text +CHGUARD_STATES_LIFE +``` + +--- + +## 11. Display and completion helpers + +`cli.py` uses Rich for tables and colourised status output. + +Important display helpers: + +```text +_uid_to_name() +_gid_to_name() +_format_owner() +_mode_to_rwx() +_captured_paths_summary() +``` + +User/group name lookup falls back to numeric ids when the uid/gid does not exist on the current host. + +Shell completion uses `argcomplete`. `complete_state_names()` opens the configured database and completes names from the `states` table. It catches all exceptions and returns an empty list so completion failures do not break normal shell use. + +--- + +## 12. Development commands + +Install dependencies: + +```bash +poetry install +``` + +Run the CLI in the development environment: + +```bash +poetry run chguard --help +``` + +Run pre-commit hooks: + +```bash +poetry run pre-commit run --all-files +``` + +Run the pytest suite: + +```bash +poetry run pytest +``` + +Build release artifacts: + +```bash +poetry build +``` + +The checked-in test suite uses pytest under `tests/`. When adding behaviour, add focused tests rather than relying only on manual CLI checks. + +--- + +## 13. Automation and security scanning + +Gitea pull request workflow: + +```text +.gitea/workflows/lint-and-security.yml + -> install pre-commit + -> pre-commit run --all-files + -> poetry export dependencies + -> pip-audit dependency audit +``` + +Scheduled/manual security workflow: + +```text +.gitea/workflows/security-scan.yml + -> install verified Cosign, Syft, and Grype + -> generate SBOM + -> scan for vulnerabilities + -> notify Node-RED on fixable Medium/High/Critical vulnerabilities + -> fail workflow on those vulnerabilities +``` + +Pre-commit currently includes Black, trailing whitespace, EOF, YAML, and TOML checks. The Bandit hook exists, but its `files` pattern is `^src/mirro/`, which does not match the current `chguard/` package layout. If relying on Bandit for this project, fix that pattern. + +--- + +## 14. Common maintenance tasks + +### 14.1 Add a new CLI option + +1. Add the argparse option in `cli.py`. +2. Decide whether it affects save, restore, wrapper mode, pruning, or display. +3. Keep persistence changes in `db.py` if schema or state lookup changes. +4. Keep scanning changes in `scan.py` if filesystem enumeration changes. +5. Keep apply semantics in `restore.py` if restore comparison or mutation changes. +6. Update README usage examples. +7. Add tests for parser behaviour and the affected operation. + +### 14.2 Change the database schema + +1. Update `db.init_db()`. +2. Decide whether old databases must be migrated. +3. Update `db.State` or add new dataclasses as needed. +4. Update all SQL in `cli.py` and `db.py` that reads or writes affected columns. +5. Add tests using a temporary database file. + +There is currently no migration system. Do not silently make schema changes that break existing user databases unless the project intentionally accepts that compatibility break. + +### 14.3 Change scan behaviour + +Start with `scan.py`. + +Preserve these invariants unless intentionally redesigning the tool: + +```text +use lstat rather than stat +do not follow symlinks +store relative paths under one snapshot root +store the root entry as the empty string +skip special files unless restore semantics are also designed +keep excludes predictable and documented +``` + +If changing excludes from prefix matching to glob or regex matching, preserve simple prefix behaviour or document the compatibility break. + +### 14.4 Change restore behaviour + +Start with `restore.plan_restore()` for comparisons and `restore.apply_restore()` for mutation. + +Keep planning and application separate. The CLI depends on being able to preview before mutating. + +Do not make restore create missing files, delete new files, replace mismatched types, or modify file contents without a deliberate product redesign. + +### 14.5 Change wrapper mode + +Start with these helpers in `cli.py`: + +```text +_extract_paths_from_command() +_common_snapshot_root() +_iter_entries_for_target() +``` + +Adding more wrapped commands requires understanding whether they only mutate ownership/permissions. Do not wrap commands that can create, delete, rename, or rewrite file contents unless the tool's scope changes. + +### 14.6 Add tests + +Good first test areas: + +```text +scan_tree records root, dirs, files, symlinks, and excludes +plan_restore reports mode, owner, missing, and type changes +apply_restore skips missing/type mismatches +db.init_db creates schema and cascade delete works +prune age parsing handles integer, all, env, invalid values +wrapper path extraction handles common chmod/chown/chgrp shapes +CLI restore dry-run never applies changes +``` + +Use temporary directories and temporary SQLite files. Avoid tests that require root unless they are explicitly skipped when not root. + +--- + +## 15. Important maintenance hazards + +### 15.1 `cli.py` is doing a lot + +`cli.py` currently contains parsing, wrapper mode, database insertion loops, restore table formatting, pruning display, completion, and privilege checks. As features grow, prefer moving domain logic into `scan.py`, `restore.py`, or new focused modules. + +### 15.2 Restore preview separates applicable and skipped drift + +`plan_restore()` reports owner, mode, missing, and type drift. The CLI displays owner/mode changes as applicable actions and missing/type drift as skipped items. Keep that distinction clear: showing skipped drift is useful, but restore must still not create missing files or replace mismatched paths. + +### 15.3 Wrapper parsing is not command-specific + +Wrapper mode does not fully parse `chmod`, `chown`, or `chgrp`. It snapshots existing non-option arguments. Any change that broadens wrapper usage should avoid giving users a false sense that every affected path was captured. + +### 15.4 Numeric ids are the source of truth + +Snapshots store uid/gid numbers. Display names are cosmetic and host-local. Do not make restore depend on resolving user or group names. + +### 15.5 Path normalisation affects restore portability + +Snapshot roots are resolved absolute paths. `--root` is the mechanism for applying a snapshot somewhere else. Do not change root/path semantics without considering existing databases. + +### 15.6 Symlink behaviour needs care + +The scanner records symlinks but never follows them. Restore uses no-follow operations where available. Platform differences around symlink chmod/chown are real, so keep user-facing wording precise: symlinks are recorded and handled best-effort without following targets. + +### 15.7 Permission errors can be best-effort + +The CLI attempts to detect when root is required, but `apply_restore()` still suppresses `PermissionError`. If maintainers need strict failure reporting, change both apply return values and CLI output so users can see partial failures. + +### 15.8 Existing user databases matter + +The default database is persistent user state. Schema and semantics changes can affect existing saved snapshots, not just new runs. + +--- + +## 16. Troubleshooting guide + +### 16.1 `--save` says sudo is required + +At least one captured entry has uid `0`, and the current process is not root. Re-run with sudo or narrow the saved path/excludes. + +### 16.2 Restore says sudo is required + +At least one owner or mode change targets a path not owned by the current effective uid. Preview and `--dry-run` are still available without sudo. + +### 16.3 Restore shows skipped items + +Missing paths and type mismatches are shown in the restore preview as skipped items. This is expected: chguard reports the drift but does not create missing files or replace paths with the wrong type. + +### 16.4 A path was not captured + +Check, in order: + +1. Does the path exist at snapshot time? +2. Is it under the normalised snapshot root? +3. Was it excluded by `--exclude` prefix matching? +4. Is it a special file such as a socket, FIFO, or device? +5. In wrapper mode, did the argument start with `-` or not exist before the command ran? + +### 16.5 Completion does not show state names + +Check that argcomplete is installed and registered for the shell, and that `--db` points at the expected database. Completion failures are intentionally silent. + +### 16.6 Prune without an age fails + +`chguard --prune-states` without a value reads `CHGUARD_STATES_LIFE`. Set the environment variable or pass an explicit value such as `--prune-states=30` or `--prune-states=all`. + +--- + +## 17. Practical code-reading map + +| Feature/question | Start with | Then read | +|---|---|---| +| CLI option behaviour | `cli.py:main()` | argparse branch for the action | +| Snapshot schema | `db.py:init_db()` | SQL call sites in `cli.py` | +| Default DB path | `db.py:default_db_path()` | `platformdirs.user_data_dir` docs | +| Save scanning | `scan.py:scan_tree()` | `cli.py` `args.save` branch | +| Exclude behaviour | `scan.py:_is_excluded()` | README usage docs | +| Wrapper snapshots | `cli.py` wrapper branch | `_extract_paths_from_command()` and `_iter_entries_for_target()` | +| Restore comparison | `restore.py:plan_restore()` | CLI restore display loop | +| Restore mutation | `restore.py:apply_restore()` | CLI confirmation/root checks | +| Rich output | `cli.py` table construction | `_format_owner()` and `_mode_to_rwx()` | +| State completion | `cli.py:complete_state_names()` | argcomplete setup | +| Pruning | `_parse_prune_states_value()` | `db.prune_states_before()` and `db.prune_all_states()` | +| Packaging | `pyproject.toml` | Poetry docs | +| Automation | `.gitea/workflows/` | `.pre-commit-config.yaml` | + +--- + +## 18. Glossary + +**State** +A named snapshot row in the `states` table. + +**Entry** +A captured file, directory, or symlink metadata row in the `entries` table. + +**Snapshot root** +The absolute root path saved in `states.root_path`. Entry paths are relative to this root. + +**Root entry** +The metadata entry for the snapshot root itself. It is stored with `entries.path = ""`. + +**Restore plan** +The list of `PlannedChange` objects produced before any mutation occurs. + +**Wrapper mode** +The `chguard -- chmod|chown|chgrp ...` mode that saves an automatic pre-command snapshot before running the command. + +**Auto snapshot** +A wrapper-created state named like `auto-YYYYMMDD-HHMMSS`. + +**Owner restore** +Restoring numeric uid/gid with `os.chown(..., follow_symlinks=False)`. + +**Permission restore** +Restoring permission bits with `os.chmod(..., follow_symlinks=False)`. + +--- + +## 19. Final maintenance model + +Most changes should preserve this model: + +```text +Scan metadata without following symlinks + -> store target-neutral numeric ownership and mode in SQLite + -> preview differences before applying + -> apply only selected chmod/chown operations + -> never create, delete, move, or rewrite files +``` + +Before changing code, ask: + +1. Is this a CLI concern, scan concern, persistence concern, or restore concern? +2. Does the SQLite schema need to change, and what happens to existing databases? +3. Does this preserve the no-content/no-create/no-delete scope? +4. Does wrapper mode still snapshot every path it claims to protect? +5. Does the restore preview still happen before mutation? +6. Does the change behave safely without root? +7. Are symlink and special-file behaviours explicit? +8. Do README examples and shell completion still match the command surface? +9. Are there focused tests for the edge case being changed? + +Keeping those boundaries clear is the main way to maintain chguard without turning a narrow metadata guardrail into a misleading general-purpose undo tool. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..711193a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,236 @@ +# chguard Threat Model and Security Scope + +chguard is a command-line systems administration tool. It is designed to be +executed intentionally by an operator, sometimes with elevated privileges, to +snapshot and restore filesystem ownership and permission metadata. + +Because of that design, chguard's security model is different from that of a +network service, web application, daemon, sandbox, or setuid program. chguard +does not attempt to defend against arbitrary local compromise of the account +executing it. If an attacker can control the command line, environment, working +directory, `PATH`, selected database, installed Python package, or wrapped +system command used by the operator, they may be able to influence what chguard +does. That situation is considered a local trust-boundary failure outside +chguard's intended security model. + +chguard only manages filesystem metadata. It does not read, store, compare, +restore, or protect file contents. + +## Core Assumptions + +chguard assumes that the person running the tool understands what they are +asking it to do. + +In particular: + +* If chguard is run as root, the root user is assumed to control and understand + the command line, environment, database path, restore root, and wrapped + command being used. +* If `--db` is used, the selected SQLite database path and its contents are + assumed to be trusted local administrative state chosen by the operator. +* If `--root` is used during restore, the alternate restore root is assumed to + be intentionally selected by the operator. +* If `--yes` is used, the operator is intentionally bypassing the interactive + confirmation prompt. +* Wrapper mode commands are assumed to be the trusted `chown`, `chmod`, or + `chgrp` implementation that the operator intended to execute. +* The operator is expected to understand the impact of restoring ownership and + permission bits, especially when restoring as root. + +## What chguard Records + +chguard snapshots a narrow set of filesystem metadata: + +* Relative path under the snapshot root. +* Entry type: regular file, directory, or symbolic link. +* Permission bits. +* Numeric `uid`. +* Numeric `gid`. + +chguard deliberately does not snapshot: + +* File contents. +* File hashes. +* ACLs. +* Extended attributes. +* Capabilities. +* SELinux, AppArmor, or other MAC labels. +* Deleted files. +* Newly created files. +* Device nodes, sockets, FIFOs, or other special files. + +User and group names are display-only. Numeric `uid` and `gid` values are the +source of truth. + +## What Is In Scope + +chguard tries to protect careful administrators from common and serious mistakes +that can occur when a privileged CLI tool records and restores filesystem +metadata. + +In-scope security concerns include: + +* Restore must not create, delete, move, rename, or rewrite files. +* Restore must not change file contents. +* Restore must preview applicable owner and mode changes before applying them. +* Mutating restore must require confirmation unless `--yes` is provided. +* Mutating prune operations must require confirmation unless `--yes` is + provided. +* Preview and dry-run restore should remain usable without root. +* chguard must not automatically run sudo or otherwise escalate privileges. +* Scanning and restoring should use no-follow filesystem operations and avoid + following symlink targets. +* Restore should re-check current filesystem state before applying chmod or + chown operations. +* Restore should skip missing paths, unsupported file types, and type mismatches + rather than replacing them. +* Wrapper mode should stay limited to ownership and permission commands: + `chown`, `chmod`, and `chgrp`. +* Wrapper mode should not use a shell to execute the wrapped command. +* SQLite access should use parameterized queries for operator-provided names and + paths. +* A state created by chguard through normal scanning should not contain relative + paths that escape the snapshot root. + +These measures are defense-in-depth. They are intended to reduce the chance of +accidental metadata changes, symlink traversal, unintended privilege changes, or +unsafe restore behavior when chguard is used normally by an administrator. + +## What Is Out Of Scope + +The following are generally out of scope and should not be reported as chguard +vulnerabilities unless they also bypass one of chguard's explicit hardening +mechanisms: + +* A malicious local user who can already control the root user's command line, + shell environment, working directory, `PATH`, Python environment, installed + package, or invoked binaries. +* A root user intentionally selecting a malicious or manually edited SQLite + database with `--db`. +* A root user intentionally restoring a snapshot that sets unsafe ownership or + permissions. +* A root user intentionally using `--root` to apply a trusted snapshot under a + different filesystem tree. +* A root user intentionally passing `--yes` and bypassing confirmation. +* A user intentionally wrapping a malicious binary whose basename is `chown`, + `chmod`, or `chgrp`. +* A user relying on chguard to restore file contents, deleted files, ACLs, + extended attributes, capabilities, MAC labels, or full undo semantics. +* A user relying on chguard as a sandbox for untrusted local users or untrusted + command execution. +* A compromised system where an attacker already controls root-owned files, + root's shell, root's Python packages, root's environment, or the privileged + tools chguard invokes. +* Reports that amount to "if root runs this tool with malicious options, root + can make the system do dangerous things." + +chguard is a tool for administrators, not a sandbox for hostile local users. It +cannot make unsafe local trust decisions safe if the operator's own execution +environment is already attacker-controlled. + +## Trusted Snapshot Databases + +chguard snapshots are stored in a local SQLite database. By default, chguard uses +the platform-specific user data directory for the invoking account. Operators +may override this with `--db`. + +Snapshot databases should be treated as trusted administrative state. They can +contain filesystem paths, ownership, group, permission, timestamp, and snapshot +root information. They do not contain file contents, but the metadata can still +reveal operational details about a system. + +Before running restore, especially as root or with `--yes`, the operator should +be confident that the selected database is the intended one and has not been +tampered with. + +chguard-created snapshots are expected to contain paths relative to the snapshot +root. chguard does not treat an arbitrary attacker-supplied SQLite database as +untrusted input to be safely enforced. + +## Wrapper Mode + +Wrapper mode exists to take an automatic pre-command snapshot before running a +metadata-changing command: + +```bash +chguard -- chmod 755 path +chguard -- chown user:group path +chguard -- chgrp group path +``` + +Wrapper mode is intentionally limited to `chmod`, `chown`, and `chgrp` by command +basename. chguard snapshots existing non-option path arguments that it can +identify, then runs the wrapped command and returns that command's exit code. + +Wrapper mode is not a full parser for every possible option accepted by those +commands. It is a guardrail for common ownership and permission changes, not a +general command supervision framework. + +## Symlinks And Filesystem Races + +chguard uses `lstat()` and no-follow operations while scanning and restoring. It +records symbolic link entries and attempts no-follow ownership or permission +restoration where the platform supports it. It should not follow a symbolic link +target and apply changes to the target as part of scanning or restore. + +Restore is best-effort across platforms. Some operations, such as changing +symlink permissions, are not supported everywhere and may be skipped. + +Because chguard operates on a live filesystem, concurrent filesystem changes can +still affect what exists at the moment restore runs. chguard mitigates this by +re-checking paths before applying changes and by skipping missing paths, special +files, and type mismatches. It does not claim to provide a transactional +filesystem restore. + +## Local Compromise + +chguard includes hardening against some local filesystem attack patterns because +it is often run with high privileges. For example, it avoids symlink traversal, +does not use a shell for wrapper mode, previews restore changes, and does not +automatically escalate privileges. + +However, local compromise cannot be ruled out completely for a privileged CLI +tool. If an attacker can influence the administrator's shell, environment, +database, binaries, Python packages, current working directory, or command-line +arguments, they may be able to influence chguard's behavior. + +Such scenarios are treated as local compromise or operator trust failures, not +as vulnerabilities in chguard by themselves. + +## Security Report Guidance + +Useful vulnerability reports include issues where chguard behaves unsafely +despite the documented trust model. Examples include: + +* chguard follows a symlink target during save or restore in a way that causes + unintended privileged chmod or chown operations. +* Restore creates, deletes, moves, renames, or rewrites files. +* Restore applies owner or mode changes without previewing them first. +* Restore applies changes without confirmation when `--yes` was not provided. +* Dry-run or preview applies filesystem changes. +* chguard automatically escalates privileges or invokes sudo. +* A snapshot produced by normal chguard scanning can contain paths that escape + the snapshot root during restore. +* Wrapper mode accepts and executes unsupported command classes outside `chown`, + `chmod`, or `chgrp`. +* Wrapper mode introduces shell injection when running ordinary operator-provided + path names or arguments. +* SQLite operations allow operator-provided state names or paths to alter + unintended database rows through injection. +* A failed safety check is silently ignored and chguard proceeds with a dangerous + operation anyway. + +Less useful reports, and normally out of scope, include: + +* "Root can restore dangerous permissions." +* "Root can pass `--yes` and bypass the confirmation prompt." +* "Root can point `--db` at a malicious SQLite database." +* "Root can use `--root` to restore metadata into a different tree." +* "A malicious local user can compromise chguard after already controlling + root's environment, Python packages, or binaries." +* "chguard does not restore file contents, ACLs, xattrs, capabilities, or full + deleted-file state." + +Reports about concrete bypasses of chguard's hardening are welcome. The project +does not treat intentional administrator-controlled execution as a vulnerability +by itself.