Add DEVELOPMENT.md and SECURITY.md
This commit is contained in:
+835
@@ -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
@@ -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.
|
||||
Reference in New Issue
Block a user