31 Commits
0.1.0 ... main

Author SHA1 Message Date
db60b4e42b Merge pull request 'Fix list view to be a rich table' (#6) from fix_list_view into main
All checks were successful
Trivy Scan / security-scan (push) Successful in 27s
Reviewed-on: #6
2026-01-24 09:24:39 +00:00
afc964a076 Fix list view to be a rich table
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 1m50s
2026-01-24 09:21:51 +00:00
d42f6d5fe1 Merge pull request 'Relax dependencies to match Fedora 42 packages' (#5) from fix_filelock_platformdird_deps into main
All checks were successful
Trivy Scan / security-scan (push) Successful in 28s
Reviewed-on: #5
2026-01-17 16:44:06 +00:00
103d6159d1 Relax dependencies to match Fedora 42 packages
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 1m56s
2026-01-17 16:41:08 +00:00
2ee9588fb9 Update filelock and virtualenv
All checks were successful
Trivy Scan / security-scan (push) Successful in 26s
2026-01-15 17:06:08 +00:00
a2720e245f Change trivy.json output path 2026-01-15 16:47:54 +00:00
8e3404bd51 Rework the trivy scan job 2026-01-15 16:45:21 +00:00
5ff1e935a3 Fix trivy scan volume path to be explicit 2026-01-15 16:39:40 +00:00
7fafb1fa8e Fix trivy scan volume path 2026-01-15 16:36:23 +00:00
f87eb5f438 Fix trivy scan volume path 2026-01-15 16:22:47 +00:00
1c3025b2d6 Fix docker command to trivy scan 2026-01-15 16:19:08 +00:00
4980903572 Fix trivy.json output path 2026-01-15 16:13:48 +00:00
a7183b3286 Fix trivy.json output path 2026-01-15 16:09:17 +00:00
984aeccfdd Fix trivy fs path 2026-01-15 16:05:51 +00:00
dc6e7840c7 Modify severity level on trivy-scan workflow 2026-01-15 15:48:41 +00:00
0464982b94 Add trivy-scan workflow 2026-01-15 15:41:29 +00:00
4a4cb8183f Merge pull request 'Add wrapper mode' (#4) from ch_wrapper into main
Reviewed-on: #4
2025-12-30 17:31:45 +00:00
20a0dca080 Add wrapper mode, update README, version bump 0.3.0
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 2m7s
2025-12-30 17:27:15 +00:00
7c391b8dbc Fix logo path in README 2025-12-23 16:18:18 +00:00
aafad81bb6 Merge pull request 'Make save operation transactional' (#3) from transaction_patch into main
Reviewed-on: #3
2025-12-23 16:15:46 +00:00
9658f534ea Run save inside a single transaction to avoid partial writes when permission checks fail or state creation errors occur
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 59s
2025-12-23 16:07:46 +00:00
5af28d21ca Add .python-version to .gitignore 2025-12-21 14:03:52 +00:00
b0395a432f Merge pull request 'Patch dependecies' (#2) from fix_dependencies_notation into main
Reviewed-on: #2
2025-12-21 10:25:30 +00:00
603e2ac0c6 Patch dependecies
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 1m0s
2025-12-21 10:23:59 +00:00
9c915576e9 Merge pull request 'Add tab completion, update README, version bump 0.2.0' (#1) from relax_dependencies into main
Reviewed-on: #1
2025-12-21 10:03:33 +00:00
96970b6963 Rework lint-and-security workflow to add Poetry and the export plugin to work with pip-audit
All checks were successful
Lint & Security / precommit-and-security (pull_request) Successful in 59s
2025-12-21 10:01:05 +00:00
5353310e15 Edit workflow to run pip-audit against a poetry export file
Some checks failed
Lint & Security / precommit-and-security (pull_request) Failing after 1m11s
2025-12-21 09:54:08 +00:00
e8f63386bb Add filelock ^3.20.1 in order to get away from CVE-2025-68146
Some checks failed
Lint & Security / precommit-and-security (pull_request) Failing after 47s
2025-12-21 09:40:56 +00:00
e0ec2ce60a Add tab completion, update README, version bump 0.2.0
Some checks failed
Lint & Security / precommit-and-security (pull_request) Failing after 1m37s
2025-12-21 09:29:21 +00:00
a5551e7047 Add gitea workflow and CODEOWNERS 2025-12-21 08:46:30 +00:00
4b67e721e7 Add logo file, update README 2025-12-21 07:36:36 +00:00
10 changed files with 439 additions and 78 deletions

1
.gitea/CODEOWNERS Normal file
View File

@@ -0,0 +1 @@
* @mdaleo404

View File

@@ -0,0 +1,36 @@
name: Lint & Security
on:
pull_request:
jobs:
precommit-and-security:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install pre-commit
run: pip install pre-commit
- name: Run pre-commit hooks
run: pre-commit run --all-files --color always
- name: Install Poetry
run: |
pip install poetry
poetry self add poetry-plugin-export
- name: Install pip-audit
run: pip install pip-audit
- name: Audit dependencies (Poetry lockfile)
run: |
poetry export -f requirements.txt --without-hashes \
| pip-audit -r /dev/stdin

View File

@@ -0,0 +1,61 @@
---
name: Trivy Scan
on:
schedule:
- cron: 17 8 * * *
workflow_dispatch:
jobs:
security-scan:
runs-on: running-man
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Trivy scan via Docker
id: trivy
continue-on-error: true
run: |
docker run --rm \
--volumes-from "$HOSTNAME" \
aquasec/trivy:latest \
fs /workspace/guardutils/chguard \
--scanners vuln \
--pkg-types library \
--include-dev-deps \
--severity MEDIUM,HIGH,CRITICAL \
--ignore-unfixed \
--format json \
--output /workspace/guardutils/chguard/trivy.json \
--exit-code 1
- name: Notify Node-RED on vulnerabilities
if: steps.trivy.outcome == 'failure'
run: |
jq -r '
{
repo: "guardutils/chguard",
summary: (
"Total: " +
((.Results[].Vulnerabilities | length) | tostring)
),
vulnerabilities: [
.Results[].Vulnerabilities[] | {
library: .PkgName,
cve: .VulnerabilityID,
severity: .Severity,
installed: .InstalledVersion,
fixed: .FixedVersion,
title: .Title,
url: .PrimaryURL
}
]
}
' trivy.json \
| curl -s -X POST https://nodered.sysmd.uk/trivy-alert \
-H "Content-Type: application/json" \
--data-binary @-
- name: Fail workflow if vulnerabilities found
if: steps.trivy.outcome == 'failure'
run: exit 1

2
.gitignore vendored
View File

@@ -85,7 +85,7 @@ ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.

View File

@@ -4,6 +4,11 @@
# chguard
<div align="center">
<img src="https://git.sysmd.uk/guardutils/chguard/raw/branch/main/chguard.png" alt="chguard logo" width="256" />
</div>
**chguard** is a safety-first command-line tool that snapshots and restores
filesystem ownership and permissions.
@@ -25,6 +30,31 @@ A single confirmation prompt at the end of a restore (default: **No**).
### Dry-run mode
Preview restore operations without prompting or applying changes.
### Wrapper mode (automatic snapshots)
`chguard` can also run as a wrapper around ownership and permission commands.
In this mode, `chguard` automatically saves a snapshot before the command runs, so the user can easily restore the previous state if needed.
#### Supported commands
Wrapper mode is intentionally limited to commands that modify filesystem metadata only:
* `chown`
* `chmod`
* `chgrp`
Other commands are rejected to avoid giving a _false sense of protection_.
#### Automatic snapshot names
Snapshots created in wrapper mode are named automatically, for example:
```
auto-20251230-161301
```
Auto-generated snapshots are visually distinguished in the output so they are easy to identify.
### Scope control
Restore:
* both ownership and permissions (default)
@@ -50,7 +80,6 @@ Restore:
It only concerns itself with **ownership** and **permissions**.
## Installation
### From GuardUtils package repo
@@ -174,6 +203,16 @@ chguard --restore app-baseline --permissions
chguard --restore app-baseline --owner
```
### Wrapper mode
Use `--` to separate `chguard` arguments from the wrapped command:
```
chguard -- chown user:group file
chguard -- chmod 755 file
chguard -- chgrp staff file
```
## Privilege model
`chguard` never escalates privileges automatically
@@ -193,6 +232,16 @@ Snapshots are stored in a local SQLite database containing:
Usernames and permission strings are resolved only for display.
### TAB completion
Add this to your `.bashrc`
```
eval "$(register-python-argcomplete chguard)"
```
And then
```
source ~/.bashrc
```
## pre-commit
This project uses [**pre-commit**](https://pre-commit.com/) to run automatic formatting and security checks before each commit (Black, Bandit, and various safety checks).

BIN
chguard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -1,11 +1,14 @@
from __future__ import annotations
import argparse
import argcomplete
import importlib.metadata
import os
import sys
import stat
import pwd
import grp
import subprocess
from collections import Counter, defaultdict
from pathlib import Path
from datetime import datetime
@@ -27,8 +30,14 @@ from chguard.restore import plan_restore, apply_restore
from chguard.util import normalize_root
def get_version():
try:
return importlib.metadata.version("chguard")
except importlib.metadata.PackageNotFoundError:
return "unknown"
def _uid_to_name(uid: int) -> str:
"""Return username for uid, or uid as string if unknown."""
try:
return pwd.getpwuid(uid).pw_name
except KeyError:
@@ -36,7 +45,6 @@ def _uid_to_name(uid: int) -> str:
def _gid_to_name(gid: int) -> str:
"""Return group name for gid, or gid as string if unknown."""
try:
return grp.getgrgid(gid).gr_name
except KeyError:
@@ -44,12 +52,10 @@ def _gid_to_name(gid: int) -> str:
def _format_owner(uid: int, gid: int) -> str:
"""Format uid/gid as username:group."""
return f"{_uid_to_name(uid)}:{_gid_to_name(gid)}"
def _mode_to_rwx(mode: int) -> str:
"""Convert numeric mode to rwx-style permissions."""
bits = (
stat.S_IRUSR,
stat.S_IWUSR,
@@ -79,10 +85,33 @@ def _mode_to_rwx(mode: int) -> str:
def _is_root() -> bool:
"""Return True if running as root."""
return os.geteuid() == 0
def complete_state_names(prefix, parsed_args, **kwargs):
try:
conn = connect(
Path(parsed_args.db).expanduser().resolve()
if parsed_args.db
else None
)
rows = conn.execute("SELECT name FROM states").fetchall()
return [name for (name,) in rows if name.startswith(prefix)]
except Exception:
return []
def _extract_paths_from_command(cmd: list[str]) -> list[Path]:
paths = []
for arg in cmd:
if arg.startswith("-"):
continue
p = Path(arg)
if p.exists():
paths.append(p.resolve())
return paths
def main() -> None:
"""
Entry point for the CLI.
@@ -94,57 +123,205 @@ def main() -> None:
- Symlinks are skipped during scanning
"""
wrapper_cmd = None
if "--" in sys.argv:
idx = sys.argv.index("--")
wrapper_cmd = sys.argv[idx + 1 :]
sys.argv = sys.argv[:idx]
parser = argparse.ArgumentParser(
prog="chguard",
description="Snapshot and restore filesystem ownership and permissions.",
)
actions = parser.add_mutually_exclusive_group(required=True)
actions.add_argument("--save", metavar="PATH", help="Save state for PATH")
parser = argparse.ArgumentParser(
prog="chguard",
description="Snapshot and restore filesystem ownership and permissions.",
epilog=(
"Wrapper mode:\n"
" chguard -- chown [OPTIONS] PATH...\n"
" chguard -- chmod [OPTIONS] PATH...\n"
" chguard -- chgrp [OPTIONS] PATH...\n\n"
"In wrapper mode, chguard automatically saves a snapshot of ownership\n"
"and permissions for the affected paths before running the command.\n"
"Only chown, chmod, and chgrp are supported."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
actions = parser.add_mutually_exclusive_group(required=wrapper_cmd is None)
parser.add_argument(
"--version",
action="version",
version=f"chguard {get_version()}",
)
actions.add_argument(
"--restore", action="store_true", help="Restore a saved state"
)
"--save",
metavar="PATH",
help="Save state for PATH",
).completer = argcomplete.FilesCompleter()
actions.add_argument(
"--list", action="store_true", help="List saved states"
"--restore",
action="store_true",
help="Restore a saved state",
)
actions.add_argument(
"--delete", metavar="STATE", help="Delete a saved state"
"--list",
action="store_true",
help="List saved states",
)
actions.add_argument(
"--delete",
metavar="STATE",
help="Delete a saved state",
).completer = complete_state_names
# positional STATE
parser.add_argument(
"state",
nargs="?",
help="State name (required with --restore)",
).completer = complete_state_names
parser.add_argument(
"--name",
help="State name (required with --save)",
)
parser.add_argument(
"state", nargs="?", help="State name (required with --restore)"
)
parser.add_argument("--name", help="State name (required with --save)")
parser.add_argument(
"--overwrite", action="store_true", help="Overwrite existing state"
"--overwrite",
action="store_true",
help="Overwrite existing state",
)
parser.add_argument(
"--permissions", action="store_true", help="Restore MODE only"
)
parser.add_argument(
"--owner", action="store_true", help="Restore OWNER only"
"--permissions",
action="store_true",
help="Restore MODE only",
)
parser.add_argument(
"--dry-run", action="store_true", help="Preview only; do not apply"
)
parser.add_argument(
"--yes", action="store_true", help="Apply without confirmation"
"--owner",
action="store_true",
help="Restore OWNER only",
)
parser.add_argument("--root", metavar="PATH", help="Override restore root")
parser.add_argument(
"--exclude", action="append", default=[], help="Exclude path prefix"
"--dry-run",
action="store_true",
help="Preview only; do not apply",
)
parser.add_argument("--db", metavar="PATH", help="Override database path")
parser.add_argument(
"--yes",
action="store_true",
help="Apply without confirmation",
)
parser.add_argument(
"--root",
metavar="PATH",
help="Override restore root",
).completer = argcomplete.FilesCompleter()
parser.add_argument(
"--exclude",
action="append",
default=[],
help="Exclude path prefix",
).completer = argcomplete.FilesCompleter()
parser.add_argument(
"--db",
metavar="PATH",
help="Override database path",
).completer = argcomplete.FilesCompleter()
argcomplete.autocomplete(parser)
args = parser.parse_args()
if wrapper_cmd is not None:
if not wrapper_cmd:
raise SystemExit("No command provided after '--'")
cmd = Path(wrapper_cmd[0]).name
if cmd not in ("chown", "chmod", "chgrp"):
raise SystemExit(
"Wrapper mode only supports chown, chmod, and chgrp"
)
console = Console()
conn = connect(Path(args.db).expanduser().resolve() if args.db else None)
init_db(conn)
if wrapper_cmd:
paths = _extract_paths_from_command(wrapper_cmd)
if paths:
auto_name = f"auto-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
with conn:
root_path = str(Path(paths[0]).resolve())
state_id = create_state(
conn, auto_name, root_path, os.getuid(), commit=False
)
for path in paths:
if path.is_dir():
for entry in scan_tree(path):
if entry.uid == 0 and not _is_root():
raise SystemExit(
"This command affects root-owned files.\n"
"Please re-run with sudo."
)
conn.execute(
"""
INSERT INTO entries (state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
state_id,
entry.path,
entry.type,
entry.mode,
entry.uid,
entry.gid,
),
)
else:
st = path.lstat()
if st.st_uid == 0 and not _is_root():
raise SystemExit(
"This command affects root-owned files.\n"
"Please re-run with sudo."
)
conn.execute(
"""
INSERT INTO entries (state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
state_id,
str(path),
"file",
stat.S_IMODE(st.st_mode),
st.st_uid,
st.st_gid,
),
)
console.print(
f"Saved pre-command snapshot: [cyan]{auto_name}[/cyan]"
)
proc = subprocess.run(wrapper_cmd)
sys.exit(proc.returncode)
if args.list:
rows = conn.execute(
"SELECT name, root_path, created_at FROM states ORDER BY created_at DESC"
@@ -154,11 +331,31 @@ def main() -> None:
console.print("No saved states.")
return
table = Table(box=box.SIMPLE, header_style="bold")
table.add_column("State")
table.add_column("Root path")
table.add_column("Created")
for name, root, created in rows:
dt = datetime.fromisoformat(created)
ts = dt.strftime("%Y-%m-%d %H:%M:%S %z")
console.print(f"{name}\t{root}\t[dim]{ts}[/dim]")
return
state_name = (
f"[bright_cyan]{name}[/bright_cyan]"
if name.startswith("auto-")
else name
)
root = f"[bright_magenta]{root}[/bright_magenta]"
ts = f"[bright_cyan]{created}[/bright_cyan]"
table.add_row(
state_name,
root,
ts,
)
console.print(table)
if args.delete:
if delete_state(conn, args.delete) == 0:
@@ -172,42 +369,49 @@ def main() -> None:
root = normalize_root(args.save)
if state_exists(conn, args.name):
if not args.overwrite:
raise SystemExit(
f"State '{args.name}' already exists (use --overwrite)"
)
delete_state(conn, args.name)
try:
with conn: # start transaction
if state_exists(conn, args.name):
if not args.overwrite:
raise SystemExit(
f"State '{args.name}' already exists (use --overwrite)"
)
# if the new save fails, this delete_state step will also roll back
delete_state(conn, args.name, commit=False)
state_id = create_state(conn, args.name, str(root), os.getuid())
# Abort early if root-owned files exist and user is not root.
# This prevents creating snapshots that cannot be meaningfully restored.
for entry in scan_tree(root, excludes=args.exclude):
if entry.uid == 0 and not _is_root():
raise SystemExit(
"This path contains root-owned files.\n"
"Saving this state requires sudo."
state_id = create_state(
conn, args.name, str(root), os.getuid(), commit=False
)
conn.execute(
"""
INSERT INTO entries (state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
state_id,
entry.path,
entry.type,
entry.mode,
entry.uid,
entry.gid,
),
)
# Abort early if root-owned files exist and user is not root.
# This prevents creating snapshots that cannot be meaningfully restored.
for entry in scan_tree(root, excludes=args.exclude):
if entry.uid == 0 and not _is_root():
raise SystemExit(
"This path contains root-owned files.\n"
"Saving this state requires sudo."
)
conn.commit()
console.print(f"Saved state '{args.name}' for {root}")
return
conn.execute(
"""
INSERT INTO entries (state_id, path, type, mode, uid, gid)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
state_id,
entry.path,
entry.type,
entry.mode,
entry.uid,
entry.gid,
),
)
console.print(f"Saved state '{args.name}' for {root}")
return
except SystemExit:
raise
if args.restore:
if not args.state:
@@ -263,7 +467,7 @@ def main() -> None:
] = f"{_format_owner(bu, bg)}{_format_owner(au, ag)}"
counts["owner"] += 1
if au != current_uid:
if ch.path.stat().st_uid != current_uid:
needs_root = True
elif ch.kind == "mode" and restore_permissions:

View File

@@ -61,19 +61,28 @@ def state_exists(conn: sqlite3.Connection, name: str) -> bool:
def create_state(
conn: sqlite3.Connection, name: str, root_path: str, created_by_uid: int
conn: sqlite3.Connection,
name: str,
root_path: str,
created_by_uid: int,
*,
commit: bool = True,
) -> int:
cur = conn.execute(
"INSERT INTO states (name, root_path, created_at, created_by_uid) VALUES (?, ?, ?, ?)",
(name, root_path, utc_now_iso(), created_by_uid),
)
conn.commit()
if commit:
conn.commit()
return int(cur.lastrowid)
def delete_state(conn: sqlite3.Connection, name: str) -> int:
def delete_state(
conn: sqlite3.Connection, name: str, commit: bool = True
) -> int:
cur = conn.execute("DELETE FROM states WHERE name = ?", (name,))
conn.commit()
if commit:
conn.commit()
return cur.rowcount

16
poetry.lock generated
View File

@@ -38,13 +38,13 @@ files = [
[[package]]
name = "filelock"
version = "3.20.1"
version = "3.20.3"
description = "A platform independent file lock."
optional = false
python-versions = ">=3.10"
files = [
{file = "filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a"},
{file = "filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c"},
{file = "filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1"},
{file = "filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1"},
]
[[package]]
@@ -267,18 +267,18 @@ files = [
[[package]]
name = "virtualenv"
version = "20.35.4"
version = "20.36.1"
description = "Virtual Python Environment builder"
optional = false
python-versions = ">=3.8"
files = [
{file = "virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b"},
{file = "virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c"},
{file = "virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f"},
{file = "virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba"},
]
[package.dependencies]
distlib = ">=0.3.7,<1"
filelock = ">=3.12.2,<4"
filelock = {version = ">=3.20.1,<4", markers = "python_version >= \"3.10\""}
platformdirs = ">=3.9.1,<5"
typing-extensions = {version = ">=4.13.2", markers = "python_version < \"3.11\""}
@@ -289,4 +289,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess
[metadata]
lock-version = "2.0"
python-versions = ">=3.10,<4.0"
content-hash = "49f77d614e46109e49e997fa270cb7093d6f7e7d258e370c4eddd4354c20437f"
content-hash = "8cfa38f4e2f17dba430ea08f7be3c91890a0c7a4535b69d9565b84d714f589bc"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "chguard"
version = "0.1.0"
version = "0.3.3"
description = "Safety-first tool to snapshot and restore filesystem ownership and permissions."
authors = ["Marco D'Aleo <marco@marcodaleo.com>"]
license = "GPL-3.0-or-later"
@@ -12,7 +12,8 @@ repository = "https://git.sysmd.uk/guardutils/chguard"
python = ">=3.10,<4.0"
rich = ">=12"
argcomplete = ">=2"
platformdirs = "^4.5.1"
platformdirs = ">=4.2.2"
filelock = ">=3.15.4"
[tool.poetry.scripts]
chguard = "chguard.cli:main"