Skip to content

Commit c3bb76a

Browse files
authored
Fix/ctrlregen supply chain hardening (#25)
* fix(scripts): support pinned org/repo@revision in markdiffusion harness --model now accepts an @revision suffix passed through to diffusers from_pretrained, keeping Hub loads reproducible and shrinking the malicious-repository swap surface behind CVE-2026-44513/CVE-2026-45804 (diffusers trust_remote_code bypass family). Malformed values exit 2. Default behavior is unchanged when no revision is given. * ci(workflows): report-only pip-audit for optional backend pins Weekly scoped pip-audit over skills/remove-ai-marks/scripts/ requirements-*.txt. The Dependabot alerts for these research pins are dismissed as tolerable risk, so this job keeps the findings visible without blocking CI. * fix(ci): harden pip-audit workflow and pin model revisions to full SHAs - move continue-on-error from job to audit step so checkout/setup/install failures stay fatal while audit findings remain report-only; propagate audit status via exit "$rc" - set persist-credentials: false on actions/checkout (artipacked) - pin pip-audit==2.10.1 to match ci.yml - require full 40-char commit SHAs for org/repo@revision model specs; reject mutable refs; reject unrevisioned models unless --offline - pin DEFAULT_MODEL to its Hub HEAD commit (f71d7867) - pin diffusers==0.40.0 in requirements-markdiffusion.txt - add tests for revision parsing and unpinned-model rejection
1 parent d691cb1 commit c3bb76a

4 files changed

Lines changed: 169 additions & 4 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: pip-audit (optional backends)
2+
3+
on:
4+
push:
5+
branches: [main]
6+
paths:
7+
- "skills/remove-ai-marks/scripts/requirements-*.txt"
8+
- ".github/workflows/pip-audit-optional.yml"
9+
pull_request:
10+
paths:
11+
- "skills/remove-ai-marks/scripts/requirements-*.txt"
12+
- ".github/workflows/pip-audit-optional.yml"
13+
schedule:
14+
# Weekly Monday report so dismissed Dependabot alerts stay visible.
15+
- cron: "17 4 * * 1"
16+
workflow_dispatch:
17+
18+
permissions:
19+
contents: read
20+
21+
jobs:
22+
audit:
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
26+
with:
27+
persist-credentials: false
28+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
29+
with:
30+
python-version: "3.12"
31+
32+
- name: Install pip-audit
33+
run: python -m pip install --upgrade pip pip-audit==2.10.1
34+
35+
- name: Audit optional-backend requirement files
36+
# Report-only by design: the audited files pin research-backend versions
37+
# deliberately (see skills/remove-ai-marks/scripts/requirements-ctrlregen.txt
38+
# header and research/dependabot-ctrlregen-advisory-review.md). Findings must
39+
# surface here without blocking CI, while checkout/setup/install failures
40+
# above stay fatal.
41+
continue-on-error: true
42+
run: |
43+
set +e
44+
rc=0
45+
for f in skills/remove-ai-marks/scripts/requirements-*.txt; do
46+
echo "::group::pip-audit $f"
47+
python -m pip_audit -r "$f" --progress-spinner off || rc=1
48+
echo "::endgroup::"
49+
done
50+
echo "pip-audit sweep finished (rc=${rc}, report-only)"
51+
exit "$rc"

skills/remove-ai-marks/scripts/markdiffusion_harness.py

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import argparse
3434
import io
3535
import os
36+
import re
3637
import sys
3738
from pathlib import Path
3839
from typing import Any
@@ -60,6 +61,10 @@
6061
IMAGE_SCHEMES = {"TR", "RI", "ROBIN", "WIND", "SFW", "GS", "GM", "PRC", "SEAL"}
6162

6263
DEFAULT_MODEL = "huanzi05/stable-diffusion-2-1-base"
64+
# Hub HEAD of DEFAULT_MODEL (verified via the HF API in 2026-08), pinned so the
65+
# default load is reproducible. Bump deliberately, never automatically.
66+
DEFAULT_MODEL_REVISION = "f71d7867a2745c420aa93441638b119c85995963"
67+
_DEFAULT_PINNED_MODEL = f"{DEFAULT_MODEL}@{DEFAULT_MODEL_REVISION}"
6368

6469
# Algorithm configs are a few hundred bytes (TR.json/GS.json). Cap well above
6570
# that so a crafted or accidental huge file is refused before either this script
@@ -122,21 +127,46 @@ def _import_markdiffusion(upstream: Path | None) -> Any:
122127
return markdiffusion
123128

124129

130+
_FULL_COMMIT_SHA = re.compile(r"\A[0-9a-f]{40}\Z")
131+
132+
133+
def _split_model_revision(model: str) -> tuple[str, str | None]:
134+
"""Split a mandatory ``org/repo@<full-commit-sha>`` suffix from a --model value.
135+
136+
Pinning a revision keeps Hub loads reproducible and shrinks the
137+
malicious-repository swap surface exposed by CVE-2026-44513-class
138+
diffusers supply-chain attacks, so mutable refs (branches, tags) are
139+
rejected here and unrevisioned models must go through --offline.
140+
"""
141+
repo, sep, revision = model.partition("@")
142+
if sep and (not repo or not revision):
143+
raise ValueError(f"invalid model {model!r}: expected 'org/repo@<full-commit-sha>'")
144+
if sep and not _FULL_COMMIT_SHA.fullmatch(revision):
145+
raise ValueError(
146+
f"invalid revision {revision!r} in {model!r}: pass the full 40-character "
147+
"commit ID (branches and tags are mutable refs)"
148+
)
149+
return (repo, revision) if sep else (model, None)
150+
151+
125152
def _load_diffusion(model: str, device: str, offline: bool, size: int):
126153
"""Load the Stable Diffusion pipeline and scheduler used by the harness."""
127154
if offline:
128155
os.environ.setdefault("HF_HUB_OFFLINE", "1")
129156
load_kwargs = {"local_files_only": True} if offline else {}
157+
repo, revision = _split_model_revision(model)
158+
if revision is not None:
159+
load_kwargs["revision"] = revision
130160

131161
import torch
132162
from diffusers import DPMSolverMultistepScheduler, StableDiffusionPipeline
133163

134164
scheduler = DPMSolverMultistepScheduler.from_pretrained(
135-
model, subfolder="scheduler", **load_kwargs
165+
repo, subfolder="scheduler", **load_kwargs
136166
)
137167
dtype = torch.float16 if device == "cuda" else torch.float32
138168
pipe = StableDiffusionPipeline.from_pretrained(
139-
model,
169+
repo,
140170
scheduler=scheduler,
141171
torch_dtype=dtype,
142172
safety_checker=None,
@@ -426,8 +456,10 @@ def _add_common(p: argparse.ArgumentParser) -> None:
426456
)
427457
p.add_argument(
428458
"--model",
429-
default=os.environ.get("MARKDIFFUSION_MODEL", DEFAULT_MODEL),
430-
help=f"HF Stable Diffusion model (default: $MARKDIFFUSION_MODEL or {DEFAULT_MODEL})",
459+
default=os.environ.get("MARKDIFFUSION_MODEL", _DEFAULT_PINNED_MODEL),
460+
help="HF Stable Diffusion model as org/repo@<full-commit-sha>; mutable refs "
461+
"are rejected unless --offline "
462+
f"(default: $MARKDIFFUSION_MODEL or {_DEFAULT_PINNED_MODEL})",
431463
)
432464
p.add_argument(
433465
"--device",
@@ -521,6 +553,18 @@ def main() -> int:
521553
eprint(str(e))
522554
return 2
523555

556+
try:
557+
_, revision = _split_model_revision(args.model)
558+
except ValueError as e:
559+
eprint(str(e))
560+
return 2
561+
if revision is None and not args.offline:
562+
eprint(
563+
f"unpinned model {args.model!r}: pass org/repo@<full-commit-sha> "
564+
"(or use --offline to load from the local cache only)"
565+
)
566+
return 2
567+
524568
raw_upstream = args.upstream_dir or os.environ.get("MARKDIFFUSION_DIR")
525569
upstream = resolve_upstream(str(raw_upstream) if raw_upstream else None)
526570

skills/remove-ai-marks/scripts/requirements-markdiffusion.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,9 @@
66
# platform index (CUDA or CPU) and satisfies markdiffusion's own
77
# torch>=2.4,<2.11 range, so it is intentionally not listed here.
88
markdiffusion==1.0.2
9+
10+
# diffusers is pinned explicitly because markdiffusion declares only
11+
# diffusers>=0.25; unpinned, pip resolves latest and load/audit behavior
12+
# drifts between runs. Bump deliberately after validating against the pinned
13+
# markdiffusion release.
14+
diffusers==0.40.0

tests/test_markdiffusion_harness.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,67 @@ def test_cli_resolve_config_missing(tmp_path: Path):
106106

107107
with __import__("pytest").raises(_Unavailable):
108108
_resolve_config(None, str(tmp_path / "missing.json"))
109+
110+
111+
_VALID_SHA = "f71d7867a2745c420aa93441638b119c85995963"
112+
113+
114+
def test_split_model_revision_accepts_full_commit_sha():
115+
"""A full 40-char lowercase hex commit ID passes through verbatim."""
116+
from markdiffusion_harness import _split_model_revision
117+
118+
assert _split_model_revision(f"org/repo@{_VALID_SHA}") == ("org/repo", _VALID_SHA)
119+
120+
121+
def test_split_model_revision_unrevisioned_returns_none():
122+
"""No '@' -> repo id unchanged and revision None."""
123+
from markdiffusion_harness import _split_model_revision
124+
125+
assert _split_model_revision("org/repo") == ("org/repo", None)
126+
127+
128+
def test_split_model_revision_rejects_mutable_refs_and_malformed():
129+
"""Branches, tags, short/long/non-hex SHAs and malformed specs raise."""
130+
import pytest
131+
from markdiffusion_harness import _split_model_revision
132+
133+
for bad in (
134+
"org/repo@main",
135+
"org/repo@v1.0.0",
136+
"org/repo@" + "a" * 39,
137+
"org/repo@" + "a" * 41,
138+
"org/repo@" + "g" * 40,
139+
"@" + "a" * 40,
140+
"org/repo@",
141+
):
142+
with pytest.raises(ValueError):
143+
_split_model_revision(bad)
144+
145+
146+
def test_default_model_is_pinned_to_full_sha():
147+
"""The built-in default carries an immutable full-commit-SHA revision."""
148+
from markdiffusion_harness import (
149+
DEFAULT_MODEL,
150+
DEFAULT_MODEL_REVISION,
151+
_split_model_revision,
152+
)
153+
154+
repo, revision = _split_model_revision(f"{DEFAULT_MODEL}@{DEFAULT_MODEL_REVISION}")
155+
assert repo == DEFAULT_MODEL
156+
assert revision == DEFAULT_MODEL_REVISION
157+
158+
159+
def test_cli_unpinned_online_model_rejected(tmp_path: Path):
160+
"""Online + unrevisioned --model -> exit 2 before any upstream access."""
161+
img = tmp_path / "img.png"
162+
img.write_bytes(b"x")
163+
r = _run_adapter(
164+
"detect",
165+
str(img),
166+
"--scheme",
167+
"tr",
168+
"--model",
169+
"huanzi05/stable-diffusion-2-1-base",
170+
)
171+
assert r.returncode == 2
172+
assert "unpinned" in (r.stderr or "")

0 commit comments

Comments
 (0)