Repository: zizmorcore/zizmor (Rust)

Issue:
[BUG]: String slicing panic with multibyte characters in workflow files
### Pre-submission checks

- [x] I am **not** filing a feature request. These should be filed via the feature request form instead.
- [x] I have looked through the [open issues](https://github.com/zizmorcore/zizmor/issues?q=is%3Aissue+is%3Aopen+) for a duplicate report.

### zizmor version

1.10.0

### Expected behavior

YAML files containing multibyte characters should be parsed without crashing.

### Actual behavior

Panic occurs when analyzing GitHub Actions workflow files containing multibyte characters. The panic occurs with the following error:

```
byte index 52 is not a char boundary; it is inside 'ド' (bytes 50..53) of `|
          for index in {1..2}; do
            # ドドド
            event_name="${{ github.event_name }}"
            echo "$index: $event_name"
          done`
```

The panic occurs in `crates/zizmor/src/finding/location.rs` at line 215.

### Reproduction steps

1. Create a GitHub Actions workflow file (e.g., `.github/workflows/panic.yml`) with multibyte characters:
   ```yaml
   name: Panic
   on:
     pull_request: {}
   permissions: {}
   jobs:
     example1:
       name: "Example 1"
       runs-on: ubuntu-latest
       timeout-minutes: 5
       steps:
         - name: "Panic occurred in file 'crates/zizmor/src/finding/location.rs' at line 215"
           run: |
             for index in {1..2}; do
               # ドドド
               event_name="${{ github.event_name }}"
               echo "$index: $event_name"
             done
     example2:
       name: "Example 2"
       runs-on: ubuntu-latest
       timeout-minutes: 5
       steps:
         - name: "Panic occurred in file 'crates/zizmor/src/finding/location.rs' at line 215"
           run: |
             curl -X POST https://api.example.com -H "Content-type: application/json" \
               -d "{\"text\":\"ドドド: https://github.com/${{ github.repository }}\"}"
   ```
2. Run: `uvx zizmor@1.10.0 -p --min-severity=low .`
3. The panic will occur

### Logs

```plain text
$ uvx zizmor@1.10.0 -p --min-severity=low --verbose .
DEBUG zizmor::config: no config discovered; loading default
DEBUG zizmor::config: loaded config: Config { rules: {} }
DEBUG zizmor::state: using cache directory: "/Users/kano/.cache/zizmor"
DEBUG collect_inputs:collect_from_dir{input_path="."}: ignore::gitignore: opened gitignore file: /Users/kano/.config/git/ignore
DEBUG collect_inputs:collect_from_dir{input_path="."}: globset: glob converted to regex: Glob { glob: "**/._*", re: "(?-u)^(?:/?|.*/)\\._[^/]*$", opts: GlobOptions { case_insensitive: false, literal_separator: true, backslash_escape: true, empty_alternates: false }, tokens: Tokens([RecursivePrefix, Literal('.'), Literal('_'), ZeroOrMore]) }
DEBUG collect_inputs:collect_from_dir{input_path="."}: globset: glob converted to regex: Glob { glob: "**/~$*.doc*", re: "(?-u)^(?:/?|.*/)\\~\\$[^/]*\\.doc[^/]*$", opts: GlobOptions { case_insensitive: false, literal_separator: true, backslash_escape: true, empty_alternates: false }, tokens: Tokens([RecursivePrefix, Literal('~'), Literal('$'), ZeroOrMore, Literal('.'), Literal('d'), Literal('o'), Literal('c'), ZeroOrMore]) }
DEBUG collect_inputs:collect_from_dir{input_path="."}: globset: glob converted to regex: Glob { glob: "**/~$*.xls*", re: "(?-u)^(?:/?|.*/)\\~\\$[^/]*\\.xls[^/]*$", opts: GlobOptions { case_insensitive: false, literal_separator: true, backslash_escape: true, empty_alternates: false }, tokens: Tokens([RecursivePrefix, Literal('~'), Literal('$'), ZeroOrMore, Literal('.'), Literal('x'), Literal('l'), Literal('s'), ZeroOrMore]) }
DEBUG collect_inputs:collect_from_dir{input_path="."}: globset: glob converted to regex: Glob { glob: "**/~$*.ppt*", re: "(?-u)^(?:/?|.*/)\\~\\$[^/]*\\.ppt[^/]*$", opts: GlobOptions { case_insensitive: false, literal_separator: true, backslash_escape: true, empty_alternates: false }, tokens: Tokens([RecursivePrefix, Literal('~'), Literal('$'), ZeroOrMore, Literal('.'), Literal('p'), Literal('p'), Literal('t'), ZeroOrMore]) }
DEBUG collect_inputs:collect_from_dir{input_path="."}: globset: glob converted to regex: Glob { glob: "**/*.~vsd*", re: "(?-u)^(?:/?|.*/)[^/]*\\.\\~vsd[^/]*$", opts: GlobOptions { case_insensitive: false, literal_separator: true, backslash_escape: true, empty_alternates: false }, tokens: Tokens([RecursivePrefix, ZeroOrMore, Literal('.'), Literal('~'), Literal('v'), Literal('s'), Literal('d'), ZeroOrMore]) }
DEBUG collect_inputs:collect_from_dir{input_path="."}: globset: built glob set; 0 literals, 19 basenames, 8 extensions, 0 prefixes, 0 suffixes, 0 required extensions, 5 regexes
DEBUG collect_inputs:collect_from_dir{input_path="."}: ignore::gitignore: opened gitignore file: ./.git/info/exclude
DEBUG collect_inputs:collect_from_dir{input_path="."}: zizmor::registry: registering workflow input as with key file://./.github/workflows/panic.yml
 INFO zizmor: skipping impostor-commit: can't run without a GitHub API token
 INFO zizmor: skipping ref-confusion: can't run without a GitHub API token
 INFO zizmor: skipping known-vulnerable-actions: can't run without a GitHub API token
 INFO zizmor: skipping forbidden-uses: audit not configured
 INFO zizmor: skipping stale-action-refs: can't run without a GitHub API token
DEBUG audit: zizmor: running artipacked on file://./.github/workflows/panic.yml
DEBUG audit: zizmor: running unsound-contains on file://./.github/workflows/panic.yml
DEBUG audit: zizmor: running excessive-permissions on file://./.github/workflows/panic.yml
DEBUG audit: zizmor: running dangerous-triggers on file://./.github/workflows/panic.yml
DEBUG audit: zizmor: running use-trusted-publishing on file://./.github/workflows/panic.yml
DEBUG audit: zizmor: running template-injection on file://./.github/workflows/panic.yml
[00:00:00] ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ panic.yml
  ↳ ⠁ audit{input=Workflow(file://./.github/workflows/panic.yml)}                                                                                                                                                                                                                                                             Well, this is embarrassing.

zizmor had a problem and crashed. To help us diagnose the problem you can send us a crash report.

We have generated a report file at "/var/folders/kp/gfy65n3n0mq0cblbkb_26pqh0000gn/T/report-15cd388d-27b8-426b-8388-754c8add0091.toml". Submit an issue or email with the subject of "zizmor Crash Report" and include the report as an attachment.

- Homepage: https://docs.zizmor.sh
- Authors: William Woodruff <william@yossarian.net>

We take privacy seriously, and do not perform any automated error collection. In order to improve the software, we rely on people to submit reports.

Thank you kindly!
```

### Additional context

```toml
name = "zizmor"
operating_system = "Mac OS 15.5.0 [64-bit]"
crate_version = "1.10.0"
explanation = """
Panic occurred in file 'crates/zizmor/src/finding/location.rs' at line 215
"""
cause = """
byte index 52 is not a char boundary; it is inside 'ド' (bytes 50..53) of `|
          for index in {1..2}; do
            # ドドド
            event_name="${{ github.event_name }}"
            echo "$index: $event_name"
          done`"""
method = "Panic"
backtrace = """
   0:        0x1002edad0 - backtrace::capture::Backtrace::new::h4d86140a410b2bd6
   1:        0x1003e7950 - human_panic::report::Report::new::hb9f48a1546b6c8ca
   2:        0x1007f2ad8 - human_panic::setup_panic::{{closure}}::h8e250d57f455aa28
   3:        0x1006e233c - std::panicking::rust_panic_with_hook::h1882a30575fbb763
   4:        0x10071258c - std::panicking::begin_panic_handler::{{closure}}::h39275ef3005e6337
   5:        0x1007124fc - std::sys::backtrace::__rust_end_short_backtrace::h6ede323c05a76849
   6:        0x100712e28 - __rustc[95feac21a9532783]::rust_begin_unwind
   7:        0x1009befa8 - core::panicking::panic_fmt::h529fda7ea817ba4f
   8:        0x10035beac - core::str::slice_error_fail_rt::h88ad3ed5401bd849
   9:        0x1009bf510 - core::str::slice_error_fail::h1a6bc2122f7e64f9
  10:        0x100836930 - zizmor::finding::location::Subfeature::locate_within::h79e8839c393a74a7
  11:        0x10083664c - zizmor::finding::location::SymbolicLocation::concretize::hc97f75de81c723d4
  12:        0x10079c0c4 - zizmor::finding::FindingBuilder::build::hf9215e7b44a00c12
  13:        0x10082ab88 - <zizmor::audit::template_injection::TemplateInjection as zizmor::audit::Audit>::audit_step::hf64bee279bea469d
  14:        0x100834e80 - zizmor::audit::Audit::audit_normal_job::h4b8e96ec0b7ecda6
  15:        0x1008355d4 - zizmor::audit::Audit::audit_workflow::h85d31f2c0e742ab7
  16:        0x1008382a0 - zizmor::audit::Audit::audit::hd6ae9c41b00e5aca
  17:        0x10087851c - zizmor::run::hc5931af8690a2dba
  18:        0x1008659b0 - zizmor::main::hafe1742ff6e8146f
  19:        0x10085eb00 - std::sys::backtrace::__rust_begin_short_backtrace::he14c9d84f82cc321
  20:        0x1008656bc - _main
"""
```


Discussion:
Hi @ponko2, thanks for the report! I'm looking into this now.
Triaged: I was able to reproduce this and isolate it to an assumption within `Subfeature::locate_within` that the `after` offset always lands on a codepoint, which isn't actually guaranteed to be true.

The fix here is pretty simple: we should only ever do these kinds of scans over byte slices, since all of our offsets are byte-level not codepoint level. I'll have a fix up shortly 🙂 
#989 will fix this. Thanks again @ponko2!

I'll likely do a release for this tomorrow morning or afternoon (US Eastern time).
Released with v1.11.0.



Patch:
diff --git a/Cargo.lock b/Cargo.lock
index a8164393a..3d039b77e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1369,9 +1369,9 @@ dependencies = [
 
 [[package]]
 name = "memchr"
-version = "2.7.4"
+version = "2.7.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
+checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
 
 [[package]]
 name = "memmap2"
@@ -3596,6 +3596,7 @@ dependencies = [
  "itertools",
  "jsonschema",
  "line-index",
+ "memchr",
  "owo-colors",
  "regex",
  "reqwest",
diff --git a/Cargo.toml b/Cargo.toml
index 1f9051bac..da63ae481 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -42,6 +42,7 @@ indicatif = "0.17.11"
 insta = "1.43.0"
 jsonschema = "0.30.0"
 line-index = "0.1.2"
+memchr = "2.7.5"
 owo-colors = "4.2.1"
 regex = "1.11.1"
 reqwest = { version = "0.12.20", default-features = false }
diff --git a/crates/github-actions-expressions/src/lib.rs b/crates/github-actions-expressions/src/lib.rs
index f7e0d9e58..48097333b 100644
--- a/crates/github-actions-expressions/src/lib.rs
+++ b/crates/github-actions-expressions/src/lib.rs
@@ -134,7 +134,7 @@ impl<'src> Literal<'src> {
 // TODO: Move this type to some kind of common crate? It's useful to have
 // a single span type everywhere, instead of the current mash of span helpers
 // and ranges we have throughout zizmor.
-/// Represents a `[start, end)` span for a source expression.
+/// Represents a `[start, end)` byte span for a source expression.
 #[derive(Copy, Clone, Debug, PartialEq)]
 pub struct Span {
     /// The start of the span, inclusive.
diff --git a/crates/zizmor/Cargo.toml b/crates/zizmor/Cargo.toml
index f77e660bc..93e301481 100644
--- a/crates/zizmor/Cargo.toml
+++ b/crates/zizmor/Cargo.toml
@@ -47,6 +47,7 @@ indicatif.workspace = true
 itertools.workspace = true
 jsonschema.workspace = true
 line-index.workspace = true
+memchr.workspace = true
 owo-colors.workspace = true
 regex.workspace = true
 reqwest = { workspace = true, features = ["blocking", "json", "rustls-tls"] }
diff --git a/crates/zizmor/src/finding/location.rs b/crates/zizmor/src/finding/location.rs
index 7f18fb903..79efb0422 100644
--- a/crates/zizmor/src/finding/location.rs
+++ b/crates/zizmor/src/finding/location.rs
@@ -129,11 +129,11 @@ pub(crate) enum Fragment<'doc> {
     /// might contain multiple lines, e.g. a multi-line GitHub Actions
     /// expression, since the subfeature's indentation won't necessarily match
     /// the surrounding feature's YAML-level indentation.
-    Regex(#[serde(serialize_with = "Fragment::serialize_regex")] regex::Regex),
+    Regex(#[serde(serialize_with = "Fragment::serialize_regex")] regex::bytes::Regex),
 }
 
 impl<'doc> Fragment<'doc> {
-    fn serialize_regex<S>(regex: &regex::Regex, serializer: S) -> Result<S::Ok, S::Error>
+    fn serialize_regex<S>(regex: &regex::bytes::Regex, serializer: S) -> Result<S::Ok, S::Error>
     where
         S: serde::Serializer,
     {
@@ -172,7 +172,7 @@ impl<'doc> Fragment<'doc> {
             static WHITESPACE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\s+").unwrap());
             let regex = WHITESPACE.replace_all(&escaped, "\\s+");
 
-            Fragment::Regex(Regex::new(&regex).unwrap())
+            Fragment::Regex(regex::bytes::Regex::new(&regex).unwrap())
         }
     }
 }
@@ -211,14 +211,26 @@ impl<'doc> Subfeature<'doc> {
     /// can't be found. The returned span is relative to the feature's
     /// start.
     fn locate_within(&self, feature: &str) -> Option<Span> {
+        // NOTE: Our inputs are always valid UTF-8 but `after` may not
+        // be a valid UTF-8 codepoint index, so everything below operates
+        // on a byte slice.
+        // Why, you might ask, might `after` not be a valid codepoint index?
+        // Because `after` is a fuzzy anchor: we know our subfeature starts
+        // *somewhere* after `after`, but we don't know exactly where.
+        // This happens because we have a rough sense of where the subfeature
+        // is *after* YAML parsing, but we don't know exactly where it is
+        // in the original YAML feature due to significant whitespace.
+        let feature = feature.as_bytes();
         let bias = self.after;
         let focus = &feature[bias..];
 
         match &self.fragment {
-            Fragment::Raw(fragment) => focus.find(fragment).map(|start| {
-                let end = start + fragment.len();
-                Span::from(start..end).adjust(bias)
-            }),
+            Fragment::Raw(fragment) => {
+                memchr::memmem::find(focus, fragment.as_bytes()).map(|start| {
+                    let end = start + fragment.len();
+                    Span::from(start..end).adjust(bias)
+                })
+            }
             Fragment::Regex(regex) => regex
                 .find(focus)
                 .map(|m| Span::from(m.range()).adjust(bias)),
diff --git a/docs/release-notes.md b/docs/release-notes.md
index f7c9644c0..8228c098b 100644
--- a/docs/release-notes.md
+++ b/docs/release-notes.md
@@ -15,6 +15,11 @@ of `zizmor`.
 * The [bot-conditions] audit now produces findings on triggers other than
   `pull_request_target` (#921)
 
+### Bug Fixes 🐛
+
+* Fixed a bug where `zizmor` would crash when attempting to extract
+  subfeatures from features containing non-ASCII codepoints (#989)
+
 ## 1.10.0
 
 This is a **huge** new release, with multiple new features, enhancements,


Test patch:
diff --git a/crates/zizmor/tests/integration/snapshot.rs b/crates/zizmor/tests/integration/snapshot.rs
index b03a1ef71..764d3a365 100644
--- a/crates/zizmor/tests/integration/snapshot.rs
+++ b/crates/zizmor/tests/integration/snapshot.rs
@@ -413,6 +413,13 @@ fn template_injection() -> Result<()> {
             .run()?
     );
 
+    insta::assert_snapshot!(
+        zizmor()
+            .input(input_under_test("template-injection/issue-988-repro.yml"))
+            .args(["--persona=pedantic"])
+            .run()?
+    );
+
     Ok(())
 }
 
diff --git a/crates/zizmor/tests/integration/snapshots/integration__snapshot__template_injection-15.snap b/crates/zizmor/tests/integration/snapshots/integration__snapshot__template_injection-15.snap
new file mode 100644
index 000000000..0694f943f
--- /dev/null
+++ b/crates/zizmor/tests/integration/snapshots/integration__snapshot__template_injection-15.snap
@@ -0,0 +1,29 @@
+---
+source: crates/zizmor/tests/integration/snapshot.rs
+expression: "zizmor().input(input_under_test(\"template-injection/issue-988-repro.yml\")).args([\"--persona=pedantic\"]).run()?"
+snapshot_kind: text
+---
+note[template-injection]: code injection via template expansion
+  --> @@INPUT@@:16:29
+   |
+13 |         run: |
+   |         --- note: this run block
+14 |           for index in {1..2}; do
+15 |             # ドドド
+16 |             event_name="${{ github.event_name }}"
+   |                             ----------------- note: may expand into attacker-controllable code
+   |
+   = note: audit confidence → Unknown
+
+note[template-injection]: code injection via template expansion
+  --> @@INPUT@@:27:57
+   |
+25 |         run: |
+   |         --- note: this run block
+26 |           curl -X POST https://api.example.com -H "Content-type: application/json" \
+27 |             -d "{\"text\":\"ドドド: https://github.com/${{ github.repository }}\"}"
+   |                                                            ----------------- note: may expand into attacker-controllable code
+   |
+   = note: audit confidence → Unknown
+
+2 findings: 2 unknown, 0 informational, 0 low, 0 medium, 0 high
diff --git a/crates/zizmor/tests/integration/test-data/template-injection/issue-988-repro.yml b/crates/zizmor/tests/integration/test-data/template-injection/issue-988-repro.yml
new file mode 100644
index 000000000..492b6038f
--- /dev/null
+++ b/crates/zizmor/tests/integration/test-data/template-injection/issue-988-repro.yml
@@ -0,0 +1,27 @@
+# https://github.com/zizmorcore/zizmor/issues/988
+name: Panic
+on:
+  pull_request: {}
+permissions: {}
+jobs:
+  example1:
+    name: "Example 1"
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    steps:
+      - name: "Panic occurred in file 'crates/zizmor/src/finding/location.rs' at line 215"
+        run: |
+          for index in {1..2}; do
+            # ドドド
+            event_name="${{ github.event_name }}"
+            echo "$index: $event_name"
+          done
+  example2:
+    name: "Example 2"
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+    steps:
+      - name: "Panic occurred in file 'crates/zizmor/src/finding/location.rs' at line 215"
+        run: |
+          curl -X POST https://api.example.com -H "Content-type: application/json" \
+            -d "{\"text\":\"ドドド: https://github.com/${{ github.repository }}\"}"