22 Commits
0.2.1 ... 0.3.3

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
7 changed files with 291 additions and 59 deletions

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

View File

@@ -5,7 +5,7 @@
# chguard # chguard
<div align="center"> <div align="center">
<img src="chguard.png" alt="chguard logo" width="256" /> <img src="https://git.sysmd.uk/guardutils/chguard/raw/branch/main/chguard.png" alt="chguard logo" width="256" />
</div> </div>
@@ -30,6 +30,31 @@ A single confirmation prompt at the end of a restore (default: **No**).
### Dry-run mode ### Dry-run mode
Preview restore operations without prompting or applying changes. 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 ### Scope control
Restore: Restore:
* both ownership and permissions (default) * both ownership and permissions (default)
@@ -55,7 +80,6 @@ Restore:
It only concerns itself with **ownership** and **permissions**. It only concerns itself with **ownership** and **permissions**.
## Installation ## Installation
### From GuardUtils package repo ### From GuardUtils package repo
@@ -179,6 +203,16 @@ chguard --restore app-baseline --permissions
chguard --restore app-baseline --owner 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 ## Privilege model
`chguard` never escalates privileges automatically `chguard` never escalates privileges automatically

View File

@@ -8,6 +8,7 @@ import sys
import stat import stat
import pwd import pwd
import grp import grp
import subprocess
from collections import Counter, defaultdict from collections import Counter, defaultdict
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@@ -37,7 +38,6 @@ def get_version():
def _uid_to_name(uid: int) -> str: def _uid_to_name(uid: int) -> str:
"""Return username for uid, or uid as string if unknown."""
try: try:
return pwd.getpwuid(uid).pw_name return pwd.getpwuid(uid).pw_name
except KeyError: except KeyError:
@@ -45,7 +45,6 @@ def _uid_to_name(uid: int) -> str:
def _gid_to_name(gid: int) -> str: def _gid_to_name(gid: int) -> str:
"""Return group name for gid, or gid as string if unknown."""
try: try:
return grp.getgrgid(gid).gr_name return grp.getgrgid(gid).gr_name
except KeyError: except KeyError:
@@ -53,12 +52,10 @@ def _gid_to_name(gid: int) -> str:
def _format_owner(uid: int, 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)}" return f"{_uid_to_name(uid)}:{_gid_to_name(gid)}"
def _mode_to_rwx(mode: int) -> str: def _mode_to_rwx(mode: int) -> str:
"""Convert numeric mode to rwx-style permissions."""
bits = ( bits = (
stat.S_IRUSR, stat.S_IRUSR,
stat.S_IWUSR, stat.S_IWUSR,
@@ -88,7 +85,6 @@ def _mode_to_rwx(mode: int) -> str:
def _is_root() -> bool: def _is_root() -> bool:
"""Return True if running as root."""
return os.geteuid() == 0 return os.geteuid() == 0
@@ -105,6 +101,17 @@ def complete_state_names(prefix, parsed_args, **kwargs):
return [] 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: def main() -> None:
""" """
Entry point for the CLI. Entry point for the CLI.
@@ -116,12 +123,33 @@ def main() -> None:
- Symlinks are skipped during scanning - 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( parser = argparse.ArgumentParser(
prog="chguard", prog="chguard",
description="Snapshot and restore filesystem ownership and permissions.", description="Snapshot and restore filesystem ownership and permissions.",
) )
actions = parser.add_mutually_exclusive_group(required=True) 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( parser.add_argument(
"--version", "--version",
@@ -216,11 +244,84 @@ def main() -> None:
argcomplete.autocomplete(parser) argcomplete.autocomplete(parser)
args = parser.parse_args() 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() console = Console()
conn = connect(Path(args.db).expanduser().resolve() if args.db else None) conn = connect(Path(args.db).expanduser().resolve() if args.db else None)
init_db(conn) 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: if args.list:
rows = conn.execute( rows = conn.execute(
"SELECT name, root_path, created_at FROM states ORDER BY created_at DESC" "SELECT name, root_path, created_at FROM states ORDER BY created_at DESC"
@@ -230,11 +331,31 @@ def main() -> None:
console.print("No saved states.") console.print("No saved states.")
return 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: for name, root, created in rows:
dt = datetime.fromisoformat(created) dt = datetime.fromisoformat(created)
ts = dt.strftime("%Y-%m-%d %H:%M:%S %z") 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 args.delete:
if delete_state(conn, args.delete) == 0: if delete_state(conn, args.delete) == 0:
@@ -248,42 +369,49 @@ def main() -> None:
root = normalize_root(args.save) root = normalize_root(args.save)
if state_exists(conn, args.name): try:
if not args.overwrite: with conn: # start transaction
raise SystemExit( if state_exists(conn, args.name):
f"State '{args.name}' already exists (use --overwrite)" if not args.overwrite:
) raise SystemExit(
delete_state(conn, args.name) 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()) state_id = create_state(
conn, args.name, str(root), os.getuid(), commit=False
# 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.execute( # Abort early if root-owned files exist and user is not root.
""" # This prevents creating snapshots that cannot be meaningfully restored.
INSERT INTO entries (state_id, path, type, mode, uid, gid) for entry in scan_tree(root, excludes=args.exclude):
VALUES (?, ?, ?, ?, ?, ?) if entry.uid == 0 and not _is_root():
""", raise SystemExit(
( "This path contains root-owned files.\n"
state_id, "Saving this state requires sudo."
entry.path, )
entry.type,
entry.mode,
entry.uid,
entry.gid,
),
)
conn.commit() conn.execute(
console.print(f"Saved state '{args.name}' for {root}") """
return 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 args.restore:
if not args.state: if not args.state:
@@ -339,7 +467,7 @@ def main() -> None:
] = f"{_format_owner(bu, bg)}{_format_owner(au, ag)}" ] = f"{_format_owner(bu, bg)}{_format_owner(au, ag)}"
counts["owner"] += 1 counts["owner"] += 1
if au != current_uid: if ch.path.stat().st_uid != current_uid:
needs_root = True needs_root = True
elif ch.kind == "mode" and restore_permissions: 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( 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: ) -> int:
cur = conn.execute( cur = conn.execute(
"INSERT INTO states (name, root_path, created_at, created_by_uid) VALUES (?, ?, ?, ?)", "INSERT INTO states (name, root_path, created_at, created_by_uid) VALUES (?, ?, ?, ?)",
(name, root_path, utc_now_iso(), created_by_uid), (name, root_path, utc_now_iso(), created_by_uid),
) )
conn.commit() if commit:
conn.commit()
return int(cur.lastrowid) 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,)) cur = conn.execute("DELETE FROM states WHERE name = ?", (name,))
conn.commit() if commit:
conn.commit()
return cur.rowcount return cur.rowcount

16
poetry.lock generated
View File

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

View File

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