Add DEVELOPMENT.md and SECURITY.md

This commit is contained in:
2026-06-23 16:01:05 +01:00
parent ac49ea53e7
commit e527e71488
2 changed files with 1071 additions and 0 deletions
+835
View File
@@ -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.
+236
View File
@@ -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.