Skip to content

Commit ca240a8

Browse files
committed
fix(release): enforce version metadata sync
1 parent 0413894 commit ca240a8

8 files changed

Lines changed: 358 additions & 10 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,5 @@ content_calendar.ics
4949
!CHANGELOG.md
5050
!ROADMAP.md
5151
!RESEARCH*.md
52+
!SECURITY.md
5253
!docs/PYTHON_ADVISORIES.md

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
Notable changes from the June 2026 hardening/audit pass. The authoritative
44
record also lives in the git commit messages.
55

6+
## [Unreleased]
7+
8+
### Fixed — release process
9+
10+
- Version sync now covers the security support table, CEP package-lock root
11+
metadata, and the C2PA claim-generator string so release smoke fails when
12+
those public version surfaces drift.
13+
614
## [1.33.0] — 2026-06-11 — June hardening & quality pass
715

816
### Fixed — Docker/runtime

SECURITY.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# Security Policy
2+
3+
## Supported Versions
4+
5+
OpenCut ships rapidly. We actively support the **latest minor** (`1.33.x`) and the one immediately preceding it (`1.32.x`). Older minors receive security-only backports for 90 days after they're superseded.
6+
7+
| Version | Supported | Security fixes until |
8+
|---------|-------------------|----------------------|
9+
| 1.33.x | ✅ Active ||
10+
| 1.32.x | ✅ Previous | +90 days after 1.33 |
11+
| 1.31.x | ⚠️ Critical only | +30 days after 1.33 |
12+
| ≤ 1.30 | ❌ End of life | n/a |
13+
14+
Version numbers ship in [`opencut/__init__.py`](opencut/__init__.py) and are kept in sync by [`scripts/sync_version.py`](scripts/sync_version.py).
15+
16+
## Reporting a Vulnerability
17+
18+
**Please do not open public GitHub issues for security problems.**
19+
20+
Email [matt@mavenimaging.com](mailto:matt@mavenimaging.com) with:
21+
22+
1. A description of the issue — what you see, what you expected.
23+
2. Reproducer steps, ideally as a minimal request / script / config.
24+
3. The commit SHA + `__version__` you tested against.
25+
4. Your assessment of severity (low / medium / high / critical) and why.
26+
27+
We acknowledge reports within **72 hours** and aim to land a fix or mitigation within:
28+
29+
- **Critical** — 72 hours (RCE, auth bypass, data exfiltration, sandbox escape)
30+
- **High** — 7 days (privilege escalation, unauthenticated DoS on a production endpoint)
31+
- **Medium** — 30 days (authenticated DoS, information disclosure, supply-chain risk)
32+
- **Low** — 90 days (hardening suggestions, theoretical concerns)
33+
34+
We don't run a paid bounty programme, but we credit reporters in `CHANGELOG.md` and the release notes unless you prefer to remain anonymous.
35+
36+
## In Scope
37+
38+
- `opencut/` backend (Flask API, job system, CLI, MCP server)
39+
- `extension/com.opencut.panel/` CEP panel (HTML/JS/ExtendScript)
40+
- `extension/com.opencut.uxp/` UXP panel (HTML/JS)
41+
- `installer/` (C# WPF Windows installer)
42+
- `scripts/` build + utility scripts
43+
- `tests/fuzz/` harness targets
44+
45+
## Out of Scope
46+
47+
- Third-party dependencies (report upstream). We monitor `pyproject.toml` / `requirements.txt` via Dependabot.
48+
- User-supplied plugins loaded via `~/.opencut/plugins/` — plugins run with the host's trust, so audit before installing.
49+
- Social-engineering / phishing attacks against maintainers.
50+
- Reports that require pre-existing local code execution (e.g. "an attacker with shell access can edit `~/.opencut/settings.json`").
51+
52+
## Known-Safe-By-Design Surface
53+
54+
OpenCut's security model leans on a handful of intentional choices:
55+
56+
- **CSRF on every mutation.** `@require_csrf` decorator on all `POST`/`PUT`/`PATCH`/`DELETE` routes. Token rotates per server start, delivered via `GET /health`, sent as `X-OpenCut-Token` header.
57+
- **Path validation.** All file-accepting routes pass user-supplied paths through `security.validate_path()` / `validate_filepath()` / `validate_output_path()`. Realpath resolution, null-byte rejection, symlink-out-of-allowlist defence.
58+
- **SSRF defence.** Outbound URL validators (`_validate_webhook_url`, `_validate_download_url`) reject localhost, loopback, private IPs, link-local, reserved ranges.
59+
- **Rate-limit categories.** Four-way classification (`gpu_heavy` / `cpu_heavy` / `io_bound` / `light`) bounds concurrent work per category — see `core/rate_limit_categories.py`.
60+
- **Scripting console sandbox.** Dunder builtins stripped, `__import__` / `exec` / `eval` / `compile` / `open` / `os` / `sys` / `subprocess` blocked in AST. Context keys containing `__` rejected.
61+
- **Fuzz harness** for parsers (`tests/fuzz/`) — SRT / VTT / `.cube` / voice-grammar parsers are expected to be total.
62+
- **Atomic writes** for user-data files via `tempfile + os.replace`.
63+
64+
## Hardening Recommendations
65+
66+
Operators running OpenCut in a shared-network environment should:
67+
68+
1. Bind to `127.0.0.1` only (default) — the service is single-user. Non-loopback binds require `OPENCUT_ALLOW_REMOTE=1`.
69+
2. **Use the persistent local auth token when binding non-loopback.**
70+
Setting `OPENCUT_ALLOW_REMOTE=1` automatically issues a token under
71+
`~/.opencut/auth.json` (POSIX: mode `0600`). Every non-loopback
72+
request must include `X-OpenCut-Auth: <token>`. Loopback peers
73+
(`127.0.0.1`, `::1`) still bypass the token to keep the single-user
74+
workflow snappy.
75+
3. Read or rotate the token explicitly:
76+
```bash
77+
opencut-server --print-auth # print the persisted token
78+
opencut-server --rotate-auth # generate a fresh token, then exit
79+
```
80+
The `GET /auth/info` endpoint returns *metadata only* — it never
81+
includes the token value. Treat `~/.opencut/auth.json` like an SSH
82+
private key; never check it into source control.
83+
4. Set `SENTRY_DSN` so crashes route to a tracker you control.
84+
5. Set `PLAUSIBLE_HOST` + `PLAUSIBLE_DOMAIN` (optional) for usage telemetry.
85+
6. Configure `OPENCUT_TEMP_CLEANUP_*` to fit the expected workload.
86+
7. Use the bundled FFmpeg or build FFmpeg explicitly — distro builds can lag on CVE fixes.
87+
8. Keep `~/.opencut/plugins/` empty until you've audited each plugin manifest.
88+
89+
### Threat model for non-loopback binds
90+
91+
Default deployment: a single user, on their workstation, talking to
92+
`127.0.0.1:5679`. The Adobe Premiere CEP/UXP panel sits on the same
93+
machine. We deliberately do **not** require an API key in that path
94+
because the token would be visible to anything that can read the panel
95+
preferences anyway.
96+
97+
When the operator opts into `OPENCUT_ALLOW_REMOTE=1` (e.g. remote
98+
render host on a private VLAN), the threat surface changes:
99+
100+
- Anyone who can hit the bind address can issue render jobs, read media
101+
paths, or call shell-adjacent endpoints (FFmpeg invocations, OS
102+
shell-out for ``open``/Finder integration).
103+
- CSRF alone is not enough — CSRF protects browser sessions, not API
104+
clients on the same network.
105+
106+
The local auth token closes that gap: non-loopback callers must include
107+
the token in the `X-OpenCut-Auth` header (a `?auth=` query string is
108+
also accepted for tools that can't set headers). `/health` and
109+
`/auth/info` remain exempt so panels can bootstrap connectivity and
110+
render a "Authentication required" hint.
111+
112+
## Software Bill of Materials (SBOM)
113+
114+
Generate a declared-dependency CycloneDX SBOM from `pyproject.toml`,
115+
`requirements.txt`, and OpenCut model-card metadata:
116+
117+
```bash
118+
python scripts/sbom.py
119+
```
120+
121+
The script writes `dist/opencut-declared-sbom.cyclonedx.json` (or `.xml` with
122+
`--format xml`) and marks the CycloneDX metadata as `declared-only`. The
123+
committed `requirements-lock.txt` is audited separately by the pip-audit release
124+
gate; it is not presented as a resolved installed-package SBOM inventory.

extension/com.opencut.panel/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

opencut/core/c2pa_sidecar.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
SPEC_VERSION = "0.2-sidecar" # OpenCut sidecar wire-format; bumped for the F140 C2PA 2.3 alignment
7272
MANIFEST_SPEC_VERSION = SPEC_VERSION # public alias
7373
C2PA_SPEC_VERSION = "2.3" # the C2PA specification version our action vocabulary follows
74-
CLAIM_GENERATOR_DEFAULT = "OpenCut/1.32.0 (sidecar; c2pa-spec 2.3)"
74+
CLAIM_GENERATOR_DEFAULT = "OpenCut/1.33.0 (sidecar; c2pa-spec 2.3)"
7575

7676

7777
# F140 — C2PA 2.3 action vocabulary. This is the documented set of

scripts/sync_version.py

Lines changed: 85 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
INIT_PY = ROOT / "opencut" / "__init__.py"
2020

2121
# Each entry: (relative path, regex pattern, replacement template)
22-
# The replacement template uses {v} for the version string.
22+
# The replacement template uses {v} for the version string and may also use
23+
# computed release-series placeholders from version_tokens().
2324
TARGETS = [
2425
(
2526
"pyproject.toml",
@@ -110,6 +111,19 @@
110111
r'("version":\s*")[0-9]+\.[0-9]+\.[0-9]+(")',
111112
r'\g<1>{v}\g<2>',
112113
),
114+
# CEP package-lock.json root package metadata. Keep these patterns scoped
115+
# to the package root so dependency versions such as lightningcss 1.32.0
116+
# are not rewritten as OpenCut releases.
117+
(
118+
"extension/com.opencut.panel/package-lock.json",
119+
r'(^\s*"version":\s*")[0-9]+\.[0-9]+\.[0-9]+(",\s*$)',
120+
r'\g<1>{v}\g<2>',
121+
),
122+
(
123+
"extension/com.opencut.panel/package-lock.json",
124+
r'("":\s*\{\s*"name":\s*"opencut-panel",\s*"version":\s*")[0-9]+\.[0-9]+\.[0-9]+(")',
125+
r'\g<1>{v}\g<2>',
126+
),
113127
# UXP manifest.json
114128
(
115129
"extension/com.opencut.uxp/manifest.json",
@@ -133,9 +147,72 @@
133147
r'(<span id="uxpVersionDisplay">)[0-9]+\.[0-9]+\.[0-9]+( \(UXP\)</span>)',
134148
r'\g<1>{v}\g<2>',
135149
),
150+
# Security support policy tracks minor series, not full patch releases.
151+
(
152+
"SECURITY.md",
153+
r'(latest minor\*\* \(`)[0-9]+\.[0-9]+\.x(`\) and the one immediately preceding it \(`)[0-9]+\.[0-9]+\.x(`\))',
154+
r'\g<1>{series}\g<2>{previous_series}\g<3>',
155+
),
156+
(
157+
"SECURITY.md",
158+
r'(\| )[0-9]+\.[0-9]+\.x(\s+\|[^\n]*Active[^\n]*\|\s*)[^\n|]+(\s*\|)',
159+
r'\g<1>{series}\g<2>— \g<3>',
160+
),
161+
(
162+
"SECURITY.md",
163+
r'(\| )[0-9]+\.[0-9]+\.x(\s+\|[^\n]*Previous[^\n]*\|\s*)\+90 days after [0-9]+\.[0-9]+(\s*\|)',
164+
r'\g<1>{previous_series}\g<2>+90 days after {latest_minor}\g<3>',
165+
),
166+
(
167+
"SECURITY.md",
168+
r'(\| )[0-9]+\.[0-9]+\.x(\s+\|[^\n]*Critical only[^\n]*\|\s*)\+30 days after [0-9]+\.[0-9]+(\s*\|)',
169+
r'\g<1>{critical_series}\g<2>+30 days after {latest_minor}\g<3>',
170+
),
171+
(
172+
"SECURITY.md",
173+
r'(\| )[≤<=>\s]*[0-9]+\.[0-9]+(\s+\|[^\n]*End of life[^\n]*\|\s*)n/a(\s*\|)',
174+
r'\g<1>≤ {eol_minor}\g<2>n/a\g<3>',
175+
),
176+
(
177+
"opencut/core/c2pa_sidecar.py",
178+
r'(CLAIM_GENERATOR_DEFAULT\s*=\s*"OpenCut/)[0-9]+\.[0-9]+\.[0-9]+( \(sidecar; c2pa-spec 2\.3\)")',
179+
r'\g<1>{v}\g<2>',
180+
),
136181
]
137182

138183

184+
def version_tokens(version: str) -> dict[str, str]:
185+
"""Return replacement tokens derived from an X.Y.Z version string."""
186+
major_s, minor_s, patch_s = version.split(".")
187+
major = int(major_s)
188+
minor = int(minor_s)
189+
190+
def minor_at(offset: int) -> int:
191+
return max(minor + offset, 0)
192+
193+
return {
194+
"v": version,
195+
"major": str(major),
196+
"minor": str(minor),
197+
"patch": patch_s,
198+
"series": f"{major}.{minor}.x",
199+
"previous_series": f"{major}.{minor_at(-1)}.x",
200+
"critical_series": f"{major}.{minor_at(-2)}.x",
201+
"latest_minor": f"{major}.{minor}",
202+
"previous_minor": f"{major}.{minor_at(-1)}",
203+
"critical_minor": f"{major}.{minor_at(-2)}",
204+
"eol_minor": f"{major}.{minor_at(-3)}",
205+
}
206+
207+
208+
def render_replacement(replacement: str, version: str) -> str:
209+
"""Expand sync_version replacement placeholders for a target."""
210+
rendered = replacement
211+
for key, value in version_tokens(version).items():
212+
rendered = rendered.replace(f"{{{key}}}", value)
213+
return rendered
214+
215+
139216
def read_version() -> str:
140217
"""Read __version__ from opencut/__init__.py."""
141218
text = INIT_PY.read_text(encoding="utf-8")
@@ -158,7 +235,7 @@ def set_version(new_ver: str) -> None:
158235
print(f" SET {INIT_PY.relative_to(ROOT)} -> {new_ver}")
159236

160237

161-
def check_file(rel_path: str, pattern: str, version: str) -> bool:
238+
def check_file(rel_path: str, pattern: str, replacement: str, version: str) -> bool:
162239
"""Check if a file's version matches. Returns True if in sync."""
163240
fpath = ROOT / rel_path
164241
if not fpath.exists():
@@ -169,9 +246,10 @@ def check_file(rel_path: str, pattern: str, version: str) -> bool:
169246
if not m:
170247
return True # Pattern not found — nothing to check
171248

172-
# Extract current version from the matched text
173249
matched = m.group(0)
174-
if version in matched:
250+
repl = render_replacement(replacement, version)
251+
expected = re.sub(pattern, repl, matched, count=1, flags=re.MULTILINE)
252+
if expected == matched:
175253
return True
176254

177255
print(f" MISMATCH {rel_path} (expected {version})")
@@ -186,7 +264,7 @@ def sync_file(rel_path: str, pattern: str, replacement: str, version: str) -> bo
186264
return False
187265

188266
text = fpath.read_text(encoding="utf-8")
189-
repl = replacement.replace("{v}", version)
267+
repl = render_replacement(replacement, version)
190268
updated, count = re.subn(pattern, repl, text, count=1, flags=re.MULTILINE)
191269

192270
if count == 0:
@@ -217,8 +295,8 @@ def main() -> None:
217295
if args.check:
218296
print(f"\nChecking version {version} across project files:\n")
219297
all_ok = True
220-
for rel_path, pattern, _replacement in TARGETS:
221-
if not check_file(rel_path, pattern, version):
298+
for rel_path, pattern, replacement in TARGETS:
299+
if not check_file(rel_path, pattern, replacement, version):
222300
all_ok = False
223301
if all_ok:
224302
print(f"\nAll files in sync at v{version}.")

tests/test_c2pa_sidecar.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ def test_cloud_trust_list_omitted_when_blank(asset):
225225
def test_claim_generator_documents_c2pa_2_3():
226226
"""The default claim_generator string must surface the spec version
227227
we target so verifiers see it without parsing the manifest."""
228+
from opencut import __version__
229+
230+
assert f"OpenCut/{__version__}" in c2pa.CLAIM_GENERATOR_DEFAULT
228231
assert "2.3" in c2pa.CLAIM_GENERATOR_DEFAULT
229232

230233

0 commit comments

Comments
 (0)