From cb6fa3589760092fb3f56644bfa70896ed473b76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 17:42:52 +0900 Subject: [PATCH 1/6] fix: harden range note parsing --- .../src/bandscope_analysis/ranges/analyzer.py | 74 +++++++++++-------- services/analysis-engine/tests/test_ranges.py | 52 +++++++++++++ 2 files changed, 96 insertions(+), 30 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py b/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py index 96b65e1e..4f96280a 100644 --- a/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py @@ -15,6 +15,8 @@ logger = logging.getLogger(__name__) +_MAX_NOTE_LENGTH = 12 + # Chromatic note order for comparison (octave-independent). _NOTE_ORDER = [ "C", @@ -54,16 +56,22 @@ def _parse_note(note: str) -> tuple[str, int]: """ if not note: return ("C", 4) - import re - - match = re.match(r"^([A-Ga-g](?:#|b|sharp|flat)?)(.*)$", note) - if not match: + if len(note) > _MAX_NOTE_LENGTH: + return ("C", 4) + if note[0].upper() not in {"A", "B", "C", "D", "E", "F", "G"}: return (note, 4) - name, octave_str = match.groups() + name = note[0].upper() + octave_str = note[1:] + for accidental_text, accidental in (("sharp", "#"), ("flat", "b"), ("#", "#"), ("b", "b")): + if octave_str.startswith(accidental_text): + name += accidental + octave_str = octave_str[len(accidental_text) :] + break + if octave_str == "": return (name, 4) - if octave_str == "-" or not re.match(r"^-?\d+$", octave_str): + if octave_str == "-" or not octave_str.removeprefix("-").isdigit(): return (name, 4) return (name, int(octave_str)) @@ -149,7 +157,7 @@ def _overlap_severity( range_a_size = midi_high_a - midi_low_a range_b_size = midi_high_b - midi_low_b - min_range = min(range_a_size, range_b_size) if min(range_a_size, range_b_size) > 0 else 1 + min_range = max(1, min(range_a_size, range_b_size)) ratio = overlap_size / min_range if ratio > 0.5: @@ -192,12 +200,18 @@ def analyze( for role in section_roles: role_range = role.get("range") if isinstance(role_range, dict): + lowest_note = str(role_range.get("lowestNote", "")) + highest_note = str(role_range.get("highestNote", "")) + if len(lowest_note) > _MAX_NOTE_LENGTH: + lowest_note = "C4" + if len(highest_note) > _MAX_NOTE_LENGTH: + highest_note = "C4" ranges.append( { "role_id": str(role.get("id", "")), "role_name": str(role.get("name", "")), - "lowestNote": str(role_range.get("lowestNote", "")), - "highestNote": str(role_range.get("highestNote", "")), + "lowestNote": lowest_note, + "highestNote": highest_note, } ) @@ -215,8 +229,7 @@ def analyze( ranges_with_midi.sort(key=lambda x: x[1]) # Detect overlaps between all pairs of ranges - for a_idx in range(len(ranges_with_midi)): - r_a, midi_low_a, midi_high_a = ranges_with_midi[a_idx] + for a_idx, (r_a, midi_low_a, midi_high_a) in enumerate(ranges_with_midi): for b_idx in range(a_idx + 1, len(ranges_with_midi)): r_b, midi_low_b, midi_high_b = ranges_with_midi[b_idx] @@ -225,25 +238,26 @@ def analyze( if midi_low_b > midi_high_a: break - # Check for overlap - if midi_low_a <= midi_high_b and midi_low_b <= midi_high_a: - severity = _overlap_severity( - r_a["lowestNote"], - r_a["highestNote"], - r_b["lowestNote"], - r_b["highestNote"], - ) - - overlaps.append( - { - "role_a": r_a["role_id"], - "role_b": r_b["role_id"], - "overlap_region": ( - f"{r_a['role_name']} and {r_b['role_name']} overlap" - ), - "severity": severity, - } - ) + if not (midi_low_a <= midi_high_b and midi_low_b <= midi_high_a): + continue + + severity = _overlap_severity( + r_a["lowestNote"], + r_a["highestNote"], + r_b["lowestNote"], + r_b["highestNote"], + ) + + overlaps.append( + { + "role_a": r_a["role_id"], + "role_b": r_b["role_id"], + "overlap_region": ( + f"{r_a['role_name']} and {r_b['role_name']} overlap" + ), + "severity": severity, + } + ) summaries.append( { diff --git a/services/analysis-engine/tests/test_ranges.py b/services/analysis-engine/tests/test_ranges.py index 9c5934dc..a06e3c8c 100644 --- a/services/analysis-engine/tests/test_ranges.py +++ b/services/analysis-engine/tests/test_ranges.py @@ -15,6 +15,8 @@ def test_parse_note_basic() -> None: assert _parse_note("C4") == ("C", 4) assert _parse_note("G#3") == ("G#", 3) assert _parse_note("Bb2") == ("Bb", 2) + assert _parse_note("csharp4") == ("C#", 4) + assert _parse_note("bflat2") == ("Bb", 2) assert _parse_note("") == ("C", 4) @@ -34,6 +36,11 @@ def test_parse_note_malformed_negative_octave_falls_back() -> None: assert _parse_note("C#-") == ("C#", 4) +def test_parse_note_rejects_overlong_inputs() -> None: + """Test overlong note strings are bounded before parsing.""" + assert _parse_note("A" * 64) == ("C", 4) + + def test_note_to_midi() -> None: """Test MIDI number conversion for note comparison.""" assert _note_to_midi("C4") == 60 @@ -168,6 +175,51 @@ def test_range_analyzer_no_overlap() -> None: assert result["sections"][0]["overlaps"] == [] +def test_range_analyzer_does_not_overlap_inverted_ranges() -> None: + """Test malformed inverted ranges do not create false-positive overlaps.""" + analyzer = RangeAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + { + "id": "normal", + "name": "Normal", + "range": {"lowestNote": "C4", "highestNote": "C5"}, + }, + { + "id": "inverted", + "name": "Inverted", + "range": {"lowestNote": "D4", "highestNote": "C3"}, + }, + ] + } + + result = analyzer.analyze(sections, roles_by_section) + + assert result["sections"][0]["overlaps"] == [] + + +def test_range_analyzer_bounds_overlong_note_strings() -> None: + """Test overlong note strings are replaced before result serialization.""" + analyzer = RangeAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + { + "id": "bass", + "name": "Bass", + "range": {"lowestNote": "A" * 64, "highestNote": "B" * 64}, + } + ] + } + + result = analyzer.analyze(sections, roles_by_section) + ranges = result["sections"][0]["ranges"] + + assert ranges[0]["lowestNote"] == "C4" + assert ranges[0]["highestNote"] == "C4" + + def test_range_analyzer_invalid_section() -> None: """Test analyzer handles non-dict sections gracefully.""" analyzer = RangeAnalyzer() From eea3ac0e401b5f57e9eb84f2334ef1188a276b32 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 19:10:48 +0900 Subject: [PATCH 2/6] fix: harden range parsing edge cases --- .../src/bandscope_analysis/ranges/analyzer.py | 40 ++++++++++++++----- services/analysis-engine/tests/test_ranges.py | 28 ++++++++++++- 2 files changed, 55 insertions(+), 13 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py b/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py index 4f96280a..da16cf55 100644 --- a/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py @@ -59,12 +59,13 @@ def _parse_note(note: str) -> tuple[str, int]: if len(note) > _MAX_NOTE_LENGTH: return ("C", 4) if note[0].upper() not in {"A", "B", "C", "D", "E", "F", "G"}: - return (note, 4) + return ("C", 4) name = note[0].upper() octave_str = note[1:] + octave_str_lower = octave_str.lower() for accidental_text, accidental in (("sharp", "#"), ("flat", "b"), ("#", "#"), ("b", "b")): - if octave_str.startswith(accidental_text): + if octave_str_lower.startswith(accidental_text): name += accidental octave_str = octave_str[len(accidental_text) :] break @@ -129,7 +130,11 @@ def _ranges_overlap(low_a: str, high_a: str, low_b: str, high_b: str) -> bool: midi_high_a = _note_to_midi(high_a) midi_low_b = _note_to_midi(low_b) midi_high_b = _note_to_midi(high_b) - return midi_low_a <= midi_high_b and midi_low_b <= midi_high_a + if midi_low_a > midi_high_a or midi_low_b > midi_high_b: + return False + overlap_low = max(midi_low_a, midi_low_b) + overlap_high = min(midi_high_a, midi_high_b) + return overlap_low <= overlap_high def _overlap_severity( @@ -150,10 +155,14 @@ def _overlap_severity( midi_high_a = _note_to_midi(high_a) midi_low_b = _note_to_midi(low_b) midi_high_b = _note_to_midi(high_b) + if midi_low_a > midi_high_a or midi_low_b > midi_high_b: + return "low" overlap_low = max(midi_low_a, midi_low_b) overlap_high = min(midi_high_a, midi_high_b) overlap_size = overlap_high - overlap_low + if overlap_size <= 0: + return "low" range_a_size = midi_high_a - midi_low_a range_b_size = midi_high_b - midi_low_b @@ -167,6 +176,15 @@ def _overlap_severity( return "low" +def _safe_note_string(value: object) -> str: + """Return a bounded note string or a safe default for untrusted range data.""" + if not isinstance(value, str): + return "C4" + if len(value) > _MAX_NOTE_LENGTH: + return "C4" + return value + + class RangeAnalyzer: """Analyzes pitch ranges and detects overlaps between roles.""" @@ -200,12 +218,8 @@ def analyze( for role in section_roles: role_range = role.get("range") if isinstance(role_range, dict): - lowest_note = str(role_range.get("lowestNote", "")) - highest_note = str(role_range.get("highestNote", "")) - if len(lowest_note) > _MAX_NOTE_LENGTH: - lowest_note = "C4" - if len(highest_note) > _MAX_NOTE_LENGTH: - highest_note = "C4" + lowest_note = _safe_note_string(role_range.get("lowestNote", "")) + highest_note = _safe_note_string(role_range.get("highestNote", "")) ranges.append( { "role_id": str(role.get("id", "")), @@ -217,11 +231,15 @@ def analyze( ranges_with_midi = [] for r in ranges: + midi_low = _note_to_midi(r["lowestNote"]) + midi_high = _note_to_midi(r["highestNote"]) + if midi_low > midi_high: + continue ranges_with_midi.append( ( r, - _note_to_midi(r["lowestNote"]), - _note_to_midi(r["highestNote"]), + midi_low, + midi_high, ) ) diff --git a/services/analysis-engine/tests/test_ranges.py b/services/analysis-engine/tests/test_ranges.py index a06e3c8c..3467d537 100644 --- a/services/analysis-engine/tests/test_ranges.py +++ b/services/analysis-engine/tests/test_ranges.py @@ -16,7 +16,9 @@ def test_parse_note_basic() -> None: assert _parse_note("G#3") == ("G#", 3) assert _parse_note("Bb2") == ("Bb", 2) assert _parse_note("csharp4") == ("C#", 4) + assert _parse_note("CSharp4") == ("C#", 4) assert _parse_note("bflat2") == ("Bb", 2) + assert _parse_note("BFLAT2") == ("Bb", 2) assert _parse_note("") == ("C", 4) @@ -27,7 +29,7 @@ def test_parse_note_without_octave() -> None: def test_parse_note_all_digits() -> None: """Test note parsing when input is all digits (edge case).""" - assert _parse_note("4") == ("4", 4) + assert _parse_note("4") == ("C", 4) def test_parse_note_malformed_negative_octave_falls_back() -> None: @@ -58,6 +60,7 @@ def test_ranges_overlap_true() -> None: def test_ranges_overlap_false() -> None: """Test non-overlapping ranges are correctly identified.""" assert _ranges_overlap("C2", "E2", "A4", "C5") is False + assert _ranges_overlap("C4", "C5", "C5", "C4") is False def test_overlap_severity_high() -> None: @@ -189,7 +192,7 @@ def test_range_analyzer_does_not_overlap_inverted_ranges() -> None: { "id": "inverted", "name": "Inverted", - "range": {"lowestNote": "D4", "highestNote": "C3"}, + "range": {"lowestNote": "C5", "highestNote": "C4"}, }, ] } @@ -220,6 +223,27 @@ def test_range_analyzer_bounds_overlong_note_strings() -> None: assert ranges[0]["highestNote"] == "C4" +def test_range_analyzer_defaults_non_string_note_values() -> None: + """Test non-string note values are replaced before result serialization.""" + analyzer = RangeAnalyzer() + sections = [{"id": "verse-1"}] + roles_by_section = { + "verse-1": [ + { + "id": "bass", + "name": "Bass", + "range": {"lowestNote": ["C4"], "highestNote": {"note": "G4"}}, + } + ] + } + + result = analyzer.analyze(sections, roles_by_section) + ranges = result["sections"][0]["ranges"] + + assert ranges[0]["lowestNote"] == "C4" + assert ranges[0]["highestNote"] == "C4" + + def test_range_analyzer_invalid_section() -> None: """Test analyzer handles non-dict sections gracefully.""" analyzer = RangeAnalyzer() From 037f1a8bc7d9a96a733c33f6c5b352ae7b50d074 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 21:04:51 +0900 Subject: [PATCH 3/6] test: cover range overlap safety branches --- .../src/bandscope_analysis/ranges/analyzer.py | 3 --- services/analysis-engine/tests/test_ranges.py | 12 ++++++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py b/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py index da16cf55..062699ab 100644 --- a/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py @@ -256,9 +256,6 @@ def analyze( if midi_low_b > midi_high_a: break - if not (midi_low_a <= midi_high_b and midi_low_b <= midi_high_a): - continue - severity = _overlap_severity( r_a["lowestNote"], r_a["highestNote"], diff --git a/services/analysis-engine/tests/test_ranges.py b/services/analysis-engine/tests/test_ranges.py index 3467d537..5580f63f 100644 --- a/services/analysis-engine/tests/test_ranges.py +++ b/services/analysis-engine/tests/test_ranges.py @@ -77,6 +77,18 @@ def test_overlap_severity_low() -> None: assert result == "low" +def test_overlap_severity_low_for_inverted_range() -> None: + """Test malformed inverted ranges fail closed to low severity.""" + result = _overlap_severity("C5", "C4", "C4", "C5") + assert result == "low" + + +def test_overlap_severity_low_for_touching_boundary() -> None: + """Test boundary-only overlap stays low severity.""" + result = _overlap_severity("C4", "C5", "C5", "C6") + assert result == "low" + + def test_overlap_severity_medium() -> None: """Test medium severity overlap detection.""" # C3-C5 = 24 semitones, A3-G6 = 34 semitones. From 86fc5182822fabf723477d21ebeca30ffb10f780 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 15:20:17 +0900 Subject: [PATCH 4/6] fix: update anyhow for RustSec 2026-0190 --- apps/desktop/src-tauri/Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 4d9ae737..0df254ea 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "atk" From c1ae6e67153e96233f662af6143297aaee357c3e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 19:45:56 +0900 Subject: [PATCH 5/6] fix: document quick-xml advisory exceptions --- apps/desktop/src-tauri/.cargo/audit.toml | 2 ++ apps/desktop/src-tauri/osv-scanner.toml | 8 ++++++++ docs/security/dependency-policy.md | 1 + 3 files changed, 11 insertions(+) diff --git a/apps/desktop/src-tauri/.cargo/audit.toml b/apps/desktop/src-tauri/.cargo/audit.toml index 9fc2a4f3..861e0aa5 100644 --- a/apps/desktop/src-tauri/.cargo/audit.toml +++ b/apps/desktop/src-tauri/.cargo/audit.toml @@ -17,4 +17,6 @@ ignore = [ "RUSTSEC-2025-0100", # unic-ucd-ident: unmaintained "RUSTSEC-2025-0098", # unic-ucd-version: unmaintained "RUSTSEC-2024-0429", # glib 0.18.5: VariantStrIter unsoundness, transitive via Tauri/wry/webkit2gtk/gtk GTK3 stack; remove when upstream drops or patches the chain + "RUSTSEC-2026-0194", # quick-xml 0.39.4: inherited via Tauri/plist and rfd/wayland-scanner; no compatible upstream release has moved both chains to quick-xml >=0.41.0 yet + "RUSTSEC-2026-0195", # quick-xml 0.39.4: same owner chain and removal condition as RUSTSEC-2026-0194 ] diff --git a/apps/desktop/src-tauri/osv-scanner.toml b/apps/desktop/src-tauri/osv-scanner.toml index 16b3b20e..c8fc5e44 100644 --- a/apps/desktop/src-tauri/osv-scanner.toml +++ b/apps/desktop/src-tauri/osv-scanner.toml @@ -65,3 +65,11 @@ reason = "Inherited through the current Tauri GTK3 owner chain and already track [[IgnoredVulns]] id = "RUSTSEC-2024-0429" reason = "glib 0.18.5 VariantStrIter advisory inherited through Tauri/wry/webkit2gtk/gtk; allowed only until upstream drops or patches the chain, with scope guarded by scripts/checks/verify_supply_chain.py." + +[[IgnoredVulns]] +id = "RUSTSEC-2026-0194" +reason = "quick-xml 0.39.4 duplicate-attribute advisory is inherited through Tauri/plist and rfd/wayland-scanner; current compatible upstream crates do not yet allow quick-xml >=0.41.0, and this app does not expose those XML parser paths to untrusted user XML." + +[[IgnoredVulns]] +id = "RUSTSEC-2026-0195" +reason = "quick-xml 0.39.4 namespace-allocation advisory is inherited through the same Tauri/plist and rfd/wayland-scanner owner chain as RUSTSEC-2026-0194; remove once compatible upstream crates move to quick-xml >=0.41.0." diff --git a/docs/security/dependency-policy.md b/docs/security/dependency-policy.md index d3a9680e..d7c7acad 100644 --- a/docs/security/dependency-policy.md +++ b/docs/security/dependency-policy.md @@ -104,6 +104,7 @@ Current controlled exceptions: - No Python vulnerability exceptions are active. `GHSA-5239-wwwm-4pmq` (`Pygments <2.20.0`) was removed by locking `Pygments` to `2.20.0`; the CI `security-audit` workflow must run `pip-audit --local --strict` against the synced `uv` environment without a targeted ignore for that advisory. - Cargo audit warnings for legacy `gtk3` vulnerabilities (e.g. `RUSTSEC-2024-0413`) inherited through Tauri v2 `wry`/`webkit2gtk` integration are explicitly allowed. These are deep framework dependencies with no alternative, so they are documented exceptions and ignored by default. - `RUSTSEC-2024-0429` for `glib 0.18.5` is allowed only for the `VariantStrIter` advisory inherited through the Tauri/wry/webkit2gtk/gtk GTK3 stack. A compatible lockfile refresh can move the desktop stack to `tauri 2.11.3`, `wry 0.55.1`, `tao 0.35.3`, `muda 0.19.3`, and related transitive patches, but it still does not move this stack to patched `glib >=0.20.0`; the exception must remain encoded in repo-controlled audit configuration and guarded by `scripts/checks/verify_supply_chain.py`, and it must be removed when upstream drops or patches the chain. +- `RUSTSEC-2026-0194` and `RUSTSEC-2026-0195` for `quick-xml 0.39.4` are allowed only while the current compatible upstream owner chains still require vulnerable `quick-xml`: `plist 1.9.0` through Tauri, and `wayland-scanner 0.31.10` through Linux `rfd`/Wayland dependencies. `quick-xml >=0.41.0` is patched, but `plist 1.9.0` requires `quick-xml ^0.39.2` and the current `wayland-scanner` release also has no compatible patched path. BandScope does not expose either owner chain as a user-controlled XML ingestion surface; the exception must stay encoded in repo-controlled cargo-audit and OSV configuration, and must be removed once compatible upstream crates publish a patched dependency path. Retired third-party deprecation and advisory signal: From 0ce99953f79a8f4946ac4315ceec47a3402324dc Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 2 Jul 2026 21:05:48 +0900 Subject: [PATCH 6/6] fix: mark unused range lower bound --- .../analysis-engine/src/bandscope_analysis/ranges/analyzer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py b/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py index 062699ab..5b30f355 100644 --- a/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py +++ b/services/analysis-engine/src/bandscope_analysis/ranges/analyzer.py @@ -247,7 +247,7 @@ def analyze( ranges_with_midi.sort(key=lambda x: x[1]) # Detect overlaps between all pairs of ranges - for a_idx, (r_a, midi_low_a, midi_high_a) in enumerate(ranges_with_midi): + for a_idx, (r_a, _midi_low_a, midi_high_a) in enumerate(ranges_with_midi): for b_idx in range(a_idx + 1, len(ranges_with_midi)): r_b, midi_low_b, midi_high_b = ranges_with_midi[b_idx]