Repository: woodruffw/zizmor (language: python)

Issue:
Refactor: switch to `camino` for path handling
`camino` is a UTF-8-safe path handling library, which would simplify things quite a bit for us.

Ref: <https://docs.rs/camino/latest/camino/>




Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index b22909e..87a0bc4 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -188,6 +188,15 @@ version = "1.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9ac0150caa2ae65ca5bd83f25c7de183dea78d4d366469f148435e2acfbad0da"
 
+[[package]]
+name = "camino"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "cc"
 version = "1.2.1"
@@ -2575,6 +2584,7 @@ dependencies = [
  "anstream",
  "anyhow",
  "assert_cmd",
+ "camino",
  "clap",
  "clap-verbosity-flag",
  "env_logger",
diff --git a/Cargo.toml b/Cargo.toml
index 3abae75..ff8b778 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,7 @@ rust-version = "1.80.1"
 annotate-snippets = "0.11.4"
 anstream = "0.6.18"
 anyhow = "1.0.93"
+camino = { version = "1.1.9", features = ["serde1"] }
 clap = { version = "4.5.21", features = ["derive", "env"] }
 clap-verbosity-flag = "3.0.0"
 env_logger = "0.11.5"
diff --git a/Makefile b/Makefile
index d59de57..5cb2551 100644
--- a/Makefile
+++ b/Makefile
@@ -19,4 +19,4 @@ $(VENV): site-requirements.txt
 
 .PHONY: snippets
 snippets:
-	cargo run -- --help > docs/snippets/help.txt
+	cargo run -- -h > docs/snippets/help.txt
diff --git a/docs/quickstart.md b/docs/quickstart.md
index cd4c6ce..f624cb9 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -1,6 +1,6 @@
 # Quickstart
 
-First, run `zizmor --help` to make sure your installation succeeded.
+First, run `zizmor -h` to make sure your installation succeeded.
 
 You should see something like this:
 
@@ -8,30 +8,66 @@ You should see something like this:
 --8<-- "help.txt"
 ```
 
+!!! tip
+
+    Run `zizmor --help` for a longer and more detailed version of `zizmor -h`.
+
 ## Running `zizmor`
 
-You can run `zizmor` on any file(s) you have locally:
+Here are some different ways you can run `zizmor` locally:
 
-```bash
-# audit a specific workflow
-zizmor my-workflow.yml
-# discovers .github/workflows/*.yml automatically
-zizmor path/to/repo
-```
+=== "On one or more files"
 
-By default, `zizmor` will emit Rust-style diagnostics, e.g.:
+    You can run `zizmor` on one or more workflows as explicit inputs:
 
-```console
-error[pull-request-target]: use of fundamentally insecure workflow trigger
-  --> /home/william/devel/gha-hazmat/.github/workflows/pull-request-target.yml:20:1
-   |
-20 | / on:
-21 | |   # NOT OK: pull_request_target should almost never be used
-22 | |   pull_request_target:
-   | |______________________^ triggers include pull_request_target, which is almost always used insecurely
-   |
-
-1 findings (0 unknown, 0 informational, 0 low, 0 medium, 1 high)
-```
+    ```bash
+    zizmor ci.yml tests.yml lint.yml
+    ```
+
+    These can be in any directory as well:
+
+    ```
+    zizmor ./subdir/ci.yml ../sibling/tests.yml
+    ```
+
+=== "On one or more directories"
+
+    If you have multiple workflows in a single directory, `zizmor` will
+    discover them:
+
+    ```bash
+    # somewhere/ contains ci.yml and tests.yml
+    zizmor somewhere/
+    ```
+
+    Moreover, if the specified directory contains a `.github/workflows`
+    subdirectory, `zizmor` will discover workflows there:
+
+    ```bash
+    # my-local-repo/ contains .github/workflows/{ci,tests}.yml
+    zizmor my-local-repo/
+    ```
+
+=== "On one or more remote repositories"
+
+    !!! tip
+
+        Private repositories can also be audited remotely, as long
+        as your GitHub API token has sufficient permissions.
+
+    `zizmor` can also fetch workflows directly from GitHub, if given a
+    GitHub API token via `GH_TOKEN` or `--gh-token`:
+
+    ```bash
+    # audit all workflows in woodruffw/zizmor
+    # assumes you have `gh` installed
+    zizmor --gh-token=$(gh auth token) woodruffw/zizmor
+    ```
+
+    Multiple repositories will also work:
+
+    ```bash
+    zizmor --gh-token=$(gh auth token) woodruffw/zizmor woodruffw/gha-hazmat
+    ```
 
 See [Usage](./usage.md) for more examples, including examples of configuration.
diff --git a/docs/snippets/help.txt b/docs/snippets/help.txt
index 9aa7890..e67e9b1 100644
--- a/docs/snippets/help.txt
+++ b/docs/snippets/help.txt
@@ -3,69 +3,38 @@ Static analysis for GitHub Actions
 Usage: zizmor [OPTIONS] <INPUTS>...
 
 Arguments:
-  <INPUTS>...
-          The workflow filenames or directories to audit
+  <INPUTS>...  The inputs to audit
 
 Options:
   -p, --pedantic
-          Emit 'pedantic' findings.
-          
-          This is an alias for --persona=pedantic.
-
+          Emit 'pedantic' findings
       --persona <PERSONA>
-          The persona to use while auditing
-          
-          [default: regular]
-
-          Possible values:
-          - auditor:  The "auditor" persona (false positives OK)
-          - pedantic: The "pedantic" persona (code smells OK)
-          - regular:  The "regular" persona (minimal false positives)
-
+          The persona to use while auditing [default: regular] [possible values: auditor, pedantic, regular]
   -o, --offline
-          Only perform audits that don't require network access
-
+          Perform only offline operations [env: ZIZMOR_OFFLINE=]
+      --gh-token <GH_TOKEN>
+          The GitHub API token to use [env: GH_TOKEN=]
+      --no-online-audits
+          Perform only offline audits [env: ZIZMOR_NO_ONLINE_AUDITS=]
   -v, --verbose...
           Increase logging verbosity
-
   -q, --quiet...
           Decrease logging verbosity
-
   -n, --no-progress
           Disable the progress bar. This is useful primarily when running with a high verbosity level, as the two will fight for stderr
-
-      --gh-token <GH_TOKEN>
-          The GitHub API token to use
-          
-          [env: GH_TOKEN=]
-
       --format <FORMAT>
-          The output format to emit. By default, plain text will be emitted
-          
-          [default: plain]
-          [possible values: plain, json, sarif]
-
+          The output format to emit. By default, plain text will be emitted [default: plain] [possible values: plain, json, sarif]
   -c, --config <CONFIG>
           The configuration file to load. By default, any config will be discovered relative to $CWD
-
       --no-config
           Disable all configuration loading
-
       --no-exit-codes
           Disable all error codes besides success and tool failure
-
       --min-severity <MIN_SEVERITY>
-          Filter all results below this severity
-          
-          [possible values: unknown, informational, low, medium, high]
-
+          Filter all results below this severity [possible values: unknown, informational, low, medium, high]
       --min-confidence <MIN_CONFIDENCE>
-          Filter all results below this confidence
-          
-          [possible values: unknown, low, medium, high]
-
+          Filter all results below this confidence [possible values: unknown, low, medium, high]
   -h, --help
-          Print help (see a summary with '-h')
-
+          Print help (see more with '--help')
   -V, --version
           Print version
diff --git a/docs/usage.md b/docs/usage.md
index 93779ef..ad36502 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -15,10 +15,15 @@ Both of these can be made explicit through their respective command-line flags:
 
 ```bash
 # force offline, even if a GH_TOKEN is present
+# this disables all online actions, including repository fetches
 zizmor --offline workflow.yml
 
-# passing a token explicitly will forcefully enable online mode
+# passing a token explicitly will enable online mode
 zizmor --gh-token ghp-... workflow.yml
+
+# online for the purpose of fetching the input (example/example),
+# but all audits themselves are offline
+zizmor --no-online-audits --gh-token ghp-... example/example
 ```
 
 ## Output formats
@@ -317,7 +322,7 @@ jobs:
 1. Optional: Remove the `env:` block to only run `zizmor`'s offline audits.
 
 For more inspiration, see `zizmor`'s own [repository workflow scan], as well
-as  GitHub's example of [running ESLint] as a security workflow.
+as GitHub's example of [running ESLint] as a security workflow.
 
 [SARIF]: https://sarifweb.azurewebsites.net/
 
@@ -336,7 +341,7 @@ To do so, add the following to your `.pre-commit-config.yaml` `repos` section:
 
 ```yaml
 -   repo: https://github.com/woodruffw/zizmor
-    rev: v0.3.0 # (1)!
+    rev: v0.7.0 # (1)!
     hooks:
     - id: zizmor
 ```
diff --git a/src/audit/github_env.rs b/src/audit/github_env.rs
index ccfd2dd..5bc67f8 100644
--- a/src/audit/github_env.rs
+++ b/src/audit/github_env.rs
@@ -172,7 +172,7 @@ mod tests {
             ("something | tee \"${$OTHER_ENV}\" # not $GITHUB_ENV", false),
         ] {
             let audit_state = AuditState {
-                offline: false,
+                no_online_audits: false,
                 gh_token: None,
                 caches: Caches::new(),
             };
diff --git a/src/audit/impostor_commit.rs b/src/audit/impostor_commit.rs
index 72c733b..76457fa 100644
--- a/src/audit/impostor_commit.rs
+++ b/src/audit/impostor_commit.rs
@@ -116,7 +116,7 @@ impl ImpostorCommit {
 
 impl WorkflowAudit for ImpostorCommit {
     fn new(state: AuditState) -> Result<Self> {
-        if state.offline {
+        if state.no_online_audits {
             return Err(anyhow!("offline audits only requested"));
         }
 
diff --git a/src/audit/known_vulnerable_actions.rs b/src/audit/known_vulnerable_actions.rs
index ced2843..d1fcc40 100644
--- a/src/audit/known_vulnerable_actions.rs
+++ b/src/audit/known_vulnerable_actions.rs
@@ -125,7 +125,7 @@ impl WorkflowAudit for KnownVulnerableActions {
     where
         Self: Sized,
     {
-        if state.offline {
+        if state.no_online_audits {
             return Err(anyhow!("offline audits only requested"));
         }
 
diff --git a/src/audit/ref_confusion.rs b/src/audit/ref_confusion.rs
index 183100d..a5e84ba 100644
--- a/src/audit/ref_confusion.rs
+++ b/src/audit/ref_confusion.rs
@@ -61,7 +61,7 @@ impl WorkflowAudit for RefConfusion {
     where
         Self: Sized,
     {
-        if state.offline {
+        if state.no_online_audits {
             return Err(anyhow!("offline audits only requested"));
         }
 
diff --git a/src/config.rs b/src/config.rs
index a778698..03a2622 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -123,7 +123,10 @@ impl Config {
         // multiple files, as the first location is the one a user will
         // typically ignore, suppressing the rest in the process.
         for loc in &finding.locations {
-            for rule in ignores.iter().filter(|i| i.filename == loc.symbolic.name) {
+            for rule in ignores
+                .iter()
+                .filter(|i| i.filename == loc.symbolic.key.filename())
+            {
                 match rule {
                     // Rule has a line and (maybe) a column.
                     WorkflowRule {
diff --git a/src/finding/mod.rs b/src/finding/mod.rs
index 4c8819b..0976cdf 100644
--- a/src/finding/mod.rs
+++ b/src/finding/mod.rs
@@ -9,7 +9,10 @@ use regex::Regex;
 use serde::Serialize;
 use terminal_link::Link;
 
-use crate::models::{Job, Step, Workflow};
+use crate::{
+    models::{Job, Step, Workflow},
+    registry::WorkflowKey,
+};
 
 pub(crate) mod locate;
 
@@ -120,8 +123,8 @@ impl<'w> Route<'w> {
 /// Represents a symbolic workflow location.
 #[derive(Serialize, Clone, Debug)]
 pub(crate) struct SymbolicLocation<'w> {
-    /// The name of the workflow, as it appears in the workflow registry.
-    pub(crate) name: &'w str,
+    /// The unique ID of the workflow, as it appears in the workflow registry.
+    pub(crate) key: &'w WorkflowKey,
 
     /// An annotation for this location.
     pub(crate) annotation: String,
@@ -139,7 +142,7 @@ pub(crate) struct SymbolicLocation<'w> {
 impl<'w> SymbolicLocation<'w> {
     pub(crate) fn with_keys(&self, keys: &[RouteComponent<'w>]) -> SymbolicLocation<'w> {
         SymbolicLocation {
-            name: self.name,
+            key: self.key,
             annotation: self.annotation.clone(),
             link: None,
             route: self.route.with_keys(keys),
diff --git a/src/github_api.rs b/src/github_api.rs
index 482e187..acd431f 100644
--- a/src/github_api.rs
+++ b/src/github_api.rs
@@ -5,13 +5,18 @@
 
 use anyhow::{anyhow, Result};
 use reqwest::{
-    blocking,
+    blocking::{self},
     header::{HeaderMap, ACCEPT, AUTHORIZATION, USER_AGENT},
     StatusCode,
 };
 use serde::{de::DeserializeOwned, Deserialize};
 
-use crate::state::Caches;
+use crate::{
+    models::{RepositoryUses, Workflow},
+    registry::WorkflowKey,
+    state::Caches,
+    utils::PipeSelf,
+};
 
 pub(crate) struct Client {
     api_base: &'static str,
@@ -196,6 +201,59 @@ impl Client {
             .json()
             .map_err(Into::into)
     }
+
+    /// Return temporary files for all workflows listed in the repo.
+    pub(crate) fn fetch_workflows(&self, slug: &RepositoryUses) -> Result<Vec<Workflow>> {
+        let owner = slug.owner;
+        let repo = slug.repo;
+        let git_ref = slug.git_ref;
+
+        log::debug!("fetching workflows for {owner}/{repo}");
+
+        // It'd be nice if the GitHub contents API allowed us to retrieve
+        // all file contents with a directory listing, but it doesn't.
+        // Instead, we make `N+1` API calls: one to list the workflows
+        // directory, and `N` for the constituent workflow files.
+        let url = format!(
+            "{api_base}/repos/{owner}/{repo}/contents/.github/workflows",
+            api_base = self.api_base
+        );
+        let resp: Vec<File> = self
+            .http
+            .get(&url)
+            .pipe(|req| match git_ref {
+                Some(g) => req.query(&[("ref", g)]),
+                None => req,
+            })
+            .send()?
+            .error_for_status()?
+            .json()?;
+
+        let mut workflows = vec![];
+        for file in resp.into_iter().filter(|file| file.name.ends_with(".yml")) {
+            let file_url = format!("{url}/{file}", file = file.name);
+            log::debug!("fetching {file_url}");
+
+            let contents = self
+                .http
+                .get(file_url)
+                .header(ACCEPT, "application/vnd.github.raw+json")
+                .pipe(|req| match git_ref {
+                    Some(g) => req.query(&[("ref", g)]),
+                    None => req,
+                })
+                .send()?
+                .error_for_status()?
+                .text()?;
+
+            workflows.push(Workflow::from_string(
+                contents,
+                WorkflowKey::remote(slug, file.path)?,
+            )?);
+        }
+
+        Ok(workflows)
+    }
 }
 
 /// A single branch, as returned by GitHub's branches endpoints.
@@ -252,3 +310,10 @@ pub(crate) struct Advisory {
     pub(crate) ghsa_id: String,
     pub(crate) severity: String,
 }
+
+/// Represents a file listing from GitHub's contents API.
+#[derive(Deserialize)]
+pub(crate) struct File {
+    name: String,
+    path: String,
+}
diff --git a/src/main.rs b/src/main.rs
index 0d8b217..673cf76 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,12 +1,15 @@
-use std::{io::stdout, path::PathBuf, process::ExitCode, time::Duration};
+use std::{io::stdout, process::ExitCode, time::Duration};
 
+use annotate_snippets::{Level, Renderer};
 use anstream::eprintln;
 use anyhow::{anyhow, Context, Result};
 use audit::WorkflowAudit;
+use camino::{Utf8Path, Utf8PathBuf};
 use clap::{Parser, ValueEnum};
 use config::Config;
 use finding::{Confidence, Persona, Severity};
 use indicatif::{ProgressBar, ProgressDrawTarget, ProgressStyle};
+use models::Uses;
 use owo_colors::OwoColorize;
 use registry::{AuditRegistry, FindingRegistry, WorkflowRegistry};
 use state::AuditState;
@@ -37,10 +40,25 @@ struct App {
     #[arg(long, group = "_persona", value_enum, default_value_t)]
     persona: Persona,
 
-    /// Only perform audits that don't require network access.
-    #[arg(short, long)]
+    /// Perform only offline operations.
+    ///
+    /// This disables all online audit rules, and prevents zizmor from
+    /// auditing remote repositories.
+    #[arg(short, long, env = "ZIZMOR_OFFLINE", group = "_offline")]
     offline: bool,
 
+    /// The GitHub API token to use.
+    #[arg(long, env, group = "_offline")]
+    gh_token: Option<String>,
+
+    /// Perform only offline audits.
+    ///
+    /// This is a weaker version of `--offline`: instead of completely
+    /// forbidding all online operations, it only disables audits that
+    /// require connectivity.
+    #[arg(long, env = "ZIZMOR_NO_ONLINE_AUDITS")]
+    no_online_audits: bool,
+
     #[command(flatten)]
     verbose: clap_verbosity_flag::Verbosity,
 
@@ -49,10 +67,6 @@ struct App {
     #[arg(short, long)]
     no_progress: bool,
 
-    /// The GitHub API token to use.
-    #[arg(long, env)]
-    gh_token: Option<String>,
-
     /// The output format to emit. By default, plain text will be emitted
     #[arg(long, value_enum, default_value_t)]
     format: OutputFormat,
@@ -60,7 +74,7 @@ struct App {
     /// The configuration file to load. By default, any config will be
     /// discovered relative to $CWD.
     #[arg(short, long, group = "conf")]
-    config: Option<PathBuf>,
+    config: Option<Utf8PathBuf>,
 
     /// Disable all configuration loading.
     #[arg(long, group = "conf")]
@@ -78,9 +92,14 @@ struct App {
     #[arg(long)]
     min_confidence: Option<Confidence>,
 
-    /// The workflow filenames or directories to audit.
+    /// The inputs to audit.
+    ///
+    /// These can be individual workflow filenames, entire directories,
+    /// or a `user/repo` slug for a GitHub repository. In the latter case,
+    /// a `@ref` can be appended to audit the repository at a particular
+    /// git reference state.
     #[arg(required = true)]
-    inputs: Vec<PathBuf>,
+    inputs: Vec<String>,
 }
 
 #[derive(Debug, Default, Copy, Clone, ValueEnum)]
@@ -91,6 +110,15 @@ pub(crate) enum OutputFormat {
     Sarif,
 }
 
+fn tip(err: impl AsRef<str>, tip: impl AsRef<str>) -> String {
+    let message = Level::Error
+        .title(err.as_ref())
+        .footer(Level::Note.title(tip.as_ref()));
+
+    let renderer = Renderer::styled();
+    format!("{}", renderer.render(message))
+}
+
 fn run() -> Result<ExitCode> {
     human_panic::setup_panic!();
 
@@ -105,52 +133,84 @@ fn run() -> Result<ExitCode> {
         .filter_level(app.verbose.log_level_filter())
         .init();
 
-    let mut workflow_paths = vec![];
+    let audit_state = AuditState::new(&app);
+    let mut workflow_registry = WorkflowRegistry::new();
+
     for input in &app.inputs {
-        if input.is_file() {
-            workflow_paths.push(input.clone());
-        } else if input.is_dir() {
-            let mut absolute = std::fs::canonicalize(input)?;
+        let input_path = Utf8Path::new(input);
+        if input_path.is_file() {
+            workflow_registry
+                .register_by_path(input_path)
+                .with_context(|| format!("failed to register workflow: {input_path}"))?;
+        } else if input_path.is_dir() {
+            let mut absolute = input_path.canonicalize_utf8()?;
             if !absolute.ends_with(".github/workflows") {
                 absolute.push(".github/workflows")
             }
 
             log::debug!("collecting workflows from {absolute:?}");
 
-            for entry in std::fs::read_dir(absolute)? {
-                let workflow_path = entry?.path();
+            for entry in absolute.read_dir_utf8()? {
+                let entry = entry?;
+                let workflow_path = entry.path();
                 match workflow_path.extension() {
                     Some(ext) if ext == "yml" || ext == "yaml" => {
-                        workflow_paths.push(workflow_path)
+                        workflow_registry
+                            .register_by_path(workflow_path)
+                            .with_context(|| {
+                                format!("failed to register workflow: {workflow_path}")
+                            })?;
                     }
                     _ => continue,
                 }
             }
         } else {
-            return Err(anyhow!("input malformed, expected file or directory"));
+            // If this input isn't a file or directory, it's probably an
+            // `owner/repo(@ref)?` slug.
+
+            // Our pre-existing `uses: <slug>` parser does 90% of the work for us.
+            let Some(Uses::Repository(slug)) = Uses::from_step(input) else {
+                return Err(anyhow!(tip(
+                    format!("invalid input: {input}"),
+                    format!(
+                        "pass a single {file}, {directory}, or entire repo by {slug} slug",
+                        file = "file".green(),
+                        directory = "directory".green(),
+                        slug = "owner/repo".green()
+                    )
+                )));
+            };
+
+            // We don't expect subpaths here.
+            if slug.subpath.is_some() {
+                return Err(anyhow!(tip(
+                    "invalid GitHub repository reference",
+                    "pass owner/repo or owner/repo@ref"
+                )));
+            }
+
+            let client = audit_state.github_client().ok_or_else(|| {
+                anyhow!(tip(
+                    format!("can't retrieve repository: {input}", input = input.green()),
+                    format!(
+                        "try removing {offline} or passing {gh_token}",
+                        offline = "--offline".yellow(),
+                        gh_token = "--gh-token <TOKEN>".yellow(),
+                    )
+                ))
+            })?;
+
+            for workflow in client.fetch_workflows(&slug)? {
+                workflow_registry.register(workflow)?;
+            }
         }
     }
 
-    if workflow_paths.is_empty() {
-        return Err(anyhow!(
-            "no workflow files collected; empty or wrong directory?"
-        ));
+    if workflow_registry.len() == 0 {
+        return Err(anyhow!("no workflow files collected"));
     }
 
-    log::debug!(
-        "collected workflows: {workflows:?}",
-        workflows = workflow_paths
-    );
-
     let config = Config::new(&app)?;
-    let audit_state = AuditState::new(&app);
-
-    let mut workflow_registry = WorkflowRegistry::new();
-    for workflow_path in workflow_paths.iter() {
-        workflow_registry
-            .register_workflow(workflow_path)
-            .with_context(|| format!("failed to register workflow: {workflow_path:?}"))?;
-    }
 
     let mut audit_registry = AuditRegistry::new();
     macro_rules! register_audit {
diff --git a/src/models.rs b/src/models.rs
index d55b384..ef9d33f 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -1,10 +1,12 @@
 //! Enriching/context-bearing wrappers over GitHub Actions models
 //! from the `github-actions-models` crate.
 
-use std::{iter::Enumerate, ops::Deref, path::Path};
+use std::{iter::Enumerate, ops::Deref};
 
 use crate::finding::{Route, SymbolicLocation};
-use anyhow::{anyhow, Context, Result};
+use crate::registry::WorkflowKey;
+use anyhow::{Context, Result};
+use camino::Utf8Path;
 use github_actions_models::common::expr::LoE;
 use github_actions_models::workflow::event::{BareEvent, OptionalBody};
 use github_actions_models::workflow::job::RunsOn;
@@ -13,13 +15,17 @@ use github_actions_models::workflow::{
     job::{NormalJob, StepBody},
     Trigger,
 };
+use terminal_link::Link;
 
 /// Represents an entire GitHub Actions workflow.
 ///
 /// This type implements [`Deref`] for [`workflow::Workflow`],
 /// providing access to the underlying data model.
 pub(crate) struct Workflow {
-    pub(crate) path: String,
+    /// This workflow's unique key into zizmor's runtime workflow registry.
+    pub(crate) key: WorkflowKey,
+    /// A clickable (OSC 8) link to this workflow, if remote.
+    pub(crate) link: Option<String>,
     pub(crate) document: yamlpath::Document,
     inner: workflow::Workflow,
 }
@@ -33,40 +39,49 @@ impl Deref for Workflow {
 }
 
 impl Workflow {
-    /// Load a workflow from the given file on disk.
-    pub(crate) fn from_file<P: AsRef<Path>>(p: P) -> Result<Self> {
-        let contents = std::fs::read_to_string(p.as_ref())?;
-
+    /// Load a workflow from a buffer, with an assigned name.
+    pub(crate) fn from_string(contents: String, key: WorkflowKey) -> Result<Self> {
         let inner = serde_yaml::from_str(&contents)
-            .with_context(|| format!("invalid GitHub Actions workflow: {:?}", p.as_ref()))?;
+            .with_context(|| format!("invalid GitHub Actions workflow: {key}"))?;
 
         let document = yamlpath::Document::new(&contents)?;
 
+        let link = match key {
+            WorkflowKey::Local(_) => None,
+            WorkflowKey::Remote(_) => {
+                // NOTE: WorkflowKey's Display produces a URL, hence `key.to_string()`.
+                Some(Link::new(key.path(), &key.to_string()).to_string())
+            }
+        };
+
         Ok(Self {
-            path: p
-                .as_ref()
-                .to_str()
-                .ok_or_else(|| anyhow!("invalid workflow: path is not UTF-8"))?
-                .to_string(),
+            link,
+            key,
             document,
             inner,
         })
     }
 
+    /// Load a workflow from the given file on disk.
+    pub(crate) fn from_file<P: AsRef<Utf8Path>>(p: P) -> Result<Self> {
+        let contents = std::fs::read_to_string(p.as_ref())?;
+        let path = p.as_ref().canonicalize_utf8()?;
+
+        Self::from_string(contents, WorkflowKey::local(path)?)
+    }
+
     /// Returns the filename (i.e. base component) of the loaded workflow.
     ///
     /// For example, if the workflow was loaded from `/foo/bar/baz.yml`,
     /// [`Self::filename()`] returns `baz.yml`.
     pub(crate) fn filename(&self) -> &str {
-        // NOTE: Unwraps are safe here since we enforce UTF-8 paths
-        // and require a filename as an invariant.
-        Path::new(&self.path).file_name().unwrap().to_str().unwrap()
+        self.key.filename()
     }
 
     /// This workflow's [`SymbolicLocation`].
     pub(crate) fn location(&self) -> SymbolicLocation {
         SymbolicLocation {
-            name: self.filename(),
+            key: &self.key,
             annotation: "this workflow".to_string(),
             link: None,
             route: Route::new(),
diff --git a/src/registry.rs b/src/registry.rs
index 142ba70..8ef2e83 100644
--- a/src/registry.rs
+++ b/src/registry.rs
@@ -1,19 +1,110 @@
 //! Functionality for registering and managing the lifecycles of
 //! audits.
 
-use std::{collections::HashMap, path::Path, process::ExitCode};
+use std::{fmt::Display, process::ExitCode};
 
 use crate::{
     audit::WorkflowAudit,
     config::Config,
     finding::{Confidence, Finding, Persona, Severity},
-    models::Workflow,
+    models::{RepositoryUses, Workflow},
     App,
 };
 use anyhow::{anyhow, Context, Result};
+use camino::{Utf8Path, Utf8PathBuf};
+use indexmap::IndexMap;
+use serde::Serialize;
+
+#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize)]
+pub(crate) struct LocalWorkflowKey {
+    path: Utf8PathBuf,
+}
+
+#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize)]
+pub(crate) struct RemoteWorkflowKey {
+    owner: String,
+    repo: String,
+    git_ref: Option<String>,
+    path: Utf8PathBuf,
+}
+
+/// A unique identifying "key" for a workflow file in a given run of zizmor.
+///
+/// zizmor currently knows two different kinds of keys: local keys
+/// are just canonical paths to files on disk, while remote keys are
+/// relative paths within a referenced GitHub repository.
+#[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize)]
+pub(crate) enum WorkflowKey {
+    Local(LocalWorkflowKey),
+    Remote(RemoteWorkflowKey),
+}
+
+impl Display for WorkflowKey {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            WorkflowKey::Local(local) => write!(f, "file://{path}", path = local.path),
+            WorkflowKey::Remote(remote) => {
+                // No ref means assume HEAD, i.e. whatever's on the default branch.
+                let git_ref = remote.git_ref.as_deref().unwrap_or("HEAD");
+                write!(
+                    f,
+                    "https://github.com/{owner}/{repo}/blob/{git_ref}/{path}",
+                    owner = remote.owner,
+                    repo = remote.repo,
+                    path = remote.path
+                )
+            }
+        }
+    }
+}
+
+impl WorkflowKey {
+    pub(crate) fn local(path: Utf8PathBuf) -> Result<Self> {
+        // All workflow keys must have a filename component.
+        if path.file_name().is_none() {
+            return Err(anyhow!("invalid local workflow: no filename component"));
+        }
+
+        Ok(Self::Local(LocalWorkflowKey { path }))
+    }
+
+    pub(crate) fn remote(slug: &RepositoryUses, path: String) -> Result<Self> {
+        if Utf8Path::new(&path).file_name().is_none() {
+            return Err(anyhow!("invalid remote workflow: no filename component"));
+        }
+
+        Ok(Self::Remote(RemoteWorkflowKey {
+            owner: slug.owner.into(),
+            repo: slug.repo.into(),
+            git_ref: slug.git_ref.map(Into::into),
+            path: path.into(),
+        }))
+    }
+
+    /// Returns this [`WorkflowKey`]'s filepath component.
+    ///
+    /// This will be an absolute path for local keys, and a relative
+    /// path for remote keys.
+    pub(crate) fn path(&self) -> &str {
+        match self {
+            WorkflowKey::Local(local) => local.path.as_str(),
+            WorkflowKey::Remote(remote) => remote.path.as_str(),
+        }
+    }
+
+    /// Returns the filename component of this [`WorkflowKey`].
+    pub(crate) fn filename(&self) -> &str {
+        // NOTE: Safe unwraps, since the presence of a filename component
+        // is a construction invariant of all `WorkflowKey` variants.
+        match self {
+            WorkflowKey::Local(local) => local.path.file_name().unwrap(),
+            WorkflowKey::Remote(remote) => remote.path.file_name().unwrap(),
+        }
+    }
+}
 
 pub(crate) struct WorkflowRegistry {
-    pub(crate) workflows: HashMap<String, Workflow>,
+    pub(crate) workflows: IndexMap<WorkflowKey, Workflow>,
 }
 
 impl WorkflowRegistry {
@@ -27,33 +118,33 @@ impl WorkflowRegistry {
         self.workflows.len()
     }
 
-    pub(crate) fn register_workflow(&mut self, path: &Path) -> Result<()> {
-        let name = path
-            .file_name()
-            .ok_or_else(|| anyhow!("invalid workflow: no filename component"))?
-            .to_str()
-            .ok_or_else(|| anyhow!("invalid workflow: path is not UTF-8"))?
-            .to_string();
-
-        if self.workflows.contains_key(&name) {
-            return Err(anyhow!("can't register {name} more than once"));
+    pub(crate) fn register(&mut self, workflow: Workflow) -> Result<()> {
+        if self.workflows.contains_key(&workflow.key) {
+            return Err(anyhow!(
+                "can't register {key} more than once",
+                key = workflow.key
+            ));
         }
 
-        self.workflows.insert(
-            name,
-            Workflow::from_file(path).with_context(|| "couldn't load workflow from file")?,
-        );
+        self.workflows.insert(workflow.key.clone(), workflow);
 
         Ok(())
     }
 
-    pub(crate) fn iter_workflows(&self) -> std::collections::hash_map::Iter<'_, String, Workflow> {
+    pub(crate) fn register_by_path(&mut self, path: &Utf8Path) -> Result<()> {
+        let workflow =
+            Workflow::from_file(path).with_context(|| "couldn't load workflow from file")?;
+
+        self.register(workflow)
+    }
+
+    pub(crate) fn iter_workflows(&self) -> indexmap::map::Iter<'_, WorkflowKey, Workflow> {
         self.workflows.iter()
     }
 
-    pub(crate) fn get_workflow(&self, name: &str) -> &Workflow {
+    pub(crate) fn get_workflow(&self, key: &WorkflowKey) -> &Workflow {
         self.workflows
-            .get(name)
+            .get(key)
             .expect("API misuse: requested an un-registered workflow")
     }
 
@@ -66,21 +157,20 @@ impl WorkflowRegistry {
     /// The exceptional case here is when zizmor is asked to scan a single
     /// workflow at some arbitrary location on disk. In that case, just
     /// the base workflow filename itself is returned.
-    pub(crate) fn get_workflow_relative_path(&self, name: &str) -> &str {
-        let workflow = self.get_workflow(name);
-        let workflow_path = Path::new(&workflow.path);
+    pub(crate) fn get_workflow_relative_path<'a>(&self, key: &'a WorkflowKey) -> &'a str {
+        let path = key.path();
 
-        match workflow.path.rfind(".github/workflows") {
-            Some(start) => &workflow.path[start..],
+        match path.rfind(".github/workflows") {
+            Some(start) => &path[start..],
             // NOTE: Unwraps are safe since file component is always present and
             // all paths are UTF-8 by construction.
-            None => workflow_path.file_name().unwrap().to_str().unwrap(),
+            None => key.filename(),
         }
     }
 }
 
 pub(crate) struct AuditRegistry {
-    pub(crate) workflow_audits: HashMap<&'static str, Box<dyn WorkflowAudit>>,
+    pub(crate) workflow_audits: IndexMap<&'static str, Box<dyn WorkflowAudit>>,
 }
 
 impl AuditRegistry {
@@ -102,10 +192,8 @@ impl AuditRegistry {
         self.workflow_audits.insert(ident, audit);
     }
 
-    pub(crate) fn iter_workflow_audits(
-        &mut self,
-    ) -> std::collections::hash_map::IterMut<'_, &str, Box<dyn WorkflowAudit>> {
-        self.workflow_audits.iter_mut()
+    pub(crate) fn iter_workflow_audits(&self) -> indexmap::map::Iter<&str, Box<dyn WorkflowAudit>> {
+        self.workflow_audits.iter()
     }
 }
 
@@ -201,3 +289,36 @@ impl From<FindingRegistry<'_>> for ExitCode {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use crate::models::Uses;
+
+    use super::WorkflowKey;
+
+    #[test]
+    fn test_workflow_key_display() {
+        let local = WorkflowKey::local("/foo/bar/baz.yml".into()).unwrap();
+        assert_eq!(local.to_string(), "file:///foo/bar/baz.yml");
+
+        // No ref
+        let Uses::Repository(slug) = Uses::from_step("foo/bar").unwrap() else {
+            panic!()
+        };
+        let remote = WorkflowKey::remote(&slug, ".github/workflows/baz.yml".into()).unwrap();
+        assert_eq!(
+            remote.to_string(),
+            "https://github.com/foo/bar/blob/HEAD/.github/workflows/baz.yml"
+        );
+
+        // With a git ref
+        let Uses::Repository(slug) = Uses::from_step("foo/bar@v1").unwrap() else {
+            panic!()
+        };
+        let remote = WorkflowKey::remote(&slug, ".github/workflows/baz.yml".into()).unwrap();
+        assert_eq!(
+            remote.to_string(),
+            "https://github.com/foo/bar/blob/v1/.github/workflows/baz.yml"
+        );
+    }
+}
diff --git a/src/render.rs b/src/render.rs
index 61a4b6f..91e9a4b 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -4,7 +4,7 @@ use std::collections::{hash_map::Entry, HashMap};
 
 use crate::{
     finding::{Finding, Location, Severity},
-    registry::{FindingRegistry, WorkflowRegistry},
+    registry::{FindingRegistry, WorkflowKey, WorkflowRegistry},
 };
 use annotate_snippets::{Level, Renderer, Snippet};
 use anstream::{print, println};
@@ -29,9 +29,9 @@ pub(crate) fn finding_snippet<'w>(
 ) -> Vec<Snippet<'w>> {
     // Our finding might span multiple workflows, so we need to group locations
     // by their enclosing workflow to generate each snippet correctly.
-    let mut locations_by_workflow: HashMap<&str, Vec<&Location<'w>>> = HashMap::new();
+    let mut locations_by_workflow: HashMap<&WorkflowKey, Vec<&Location<'w>>> = HashMap::new();
     for location in &finding.locations {
-        match locations_by_workflow.entry(location.symbolic.name) {
+        match locations_by_workflow.entry(location.symbolic.key) {
             Entry::Occupied(mut e) => {
                 e.get_mut().push(location);
             }
@@ -42,14 +42,14 @@ pub(crate) fn finding_snippet<'w>(
     }
 
     let mut snippets = vec![];
-    for (workflow_name, locations) in locations_by_workflow {
-        let workflow = registry.get_workflow(workflow_name);
+    for (workflow_key, locations) in locations_by_workflow {
+        let workflow = registry.get_workflow(workflow_key);
 
         snippets.push(
             Snippet::source(workflow.document.source())
                 .fold(true)
                 .line_start(1)
-                .origin(&workflow.path)
+                .origin(workflow.link.as_deref().unwrap_or(workflow_key.path()))
                 .annotations(locations.iter().map(|loc| {
                     let annotation = match loc.symbolic.link {
                         Some(ref link) => link,
diff --git a/src/sarif.rs b/src/sarif.rs
index 94b2c97..fb5421e 100644
--- a/src/sarif.rs
+++ b/src/sarif.rs
@@ -69,7 +69,7 @@ fn build_locations(registry: &WorkflowRegistry, locations: &[Location<'_>]) -> V
                         .artifact_location(
                             ArtifactLocation::builder()
                                 .uri_base_id("%SRCROOT%")
-                                .uri(registry.get_workflow_relative_path(location.symbolic.name))
+                                .uri(registry.get_workflow_relative_path(location.symbolic.key))
                                 .build(),
                         )
                         .region(
diff --git a/src/state.rs b/src/state.rs
index 9c2d3c7..e4f7bdf 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -9,7 +9,7 @@ use crate::{
 
 #[derive(Clone)]
 pub(crate) struct AuditState {
-    pub(crate) offline: bool,
+    pub(crate) no_online_audits: bool,
     pub(crate) gh_token: Option<String>,
     pub(crate) caches: Caches,
 }
@@ -18,7 +18,7 @@ impl AuditState {
     pub(crate) fn new(app: &App) -> Self {
         Self {
             caches: Caches::new(),
-            offline: app.offline,
+            no_online_audits: app.no_online_audits,
             gh_token: app.gh_token.clone(),
         }
     }
diff --git a/src/utils.rs b/src/utils.rs
index 76d6b38..fdb8c04 100644
--- a/src/utils.rs
+++ b/src/utils.rs
@@ -2,6 +2,23 @@
 
 use github_actions_models::common::expr::ExplicitExpr;
 
+/// Convenience trait for inline transformations of `Self`.
+///
+/// This is similar to the `tap` crate's `Pipe` trait, except that
+/// it's a little less general (`pipe<T>(T) -> T``, instead of
+/// `pipe<T, U>(T) -> U`).
+pub(crate) trait PipeSelf<F> {
+    fn pipe(self, f: F) -> Self
+    where
+        F: FnOnce(Self) -> Self,
+        Self: Sized,
+    {
+        f(self)
+    }
+}
+
+impl<T, F> PipeSelf<F> for T where T: Sized {}
+
 /// Splits the given `patterns` string into one or more patterns, using
 /// approximately the same rules as GitHub's `@actions/glob` package.
 pub(crate) fn split_patterns(patterns: &str) -> impl Iterator<Item = &str> {


Test patch:
diff --git a/tests/snapshot.rs b/tests/snapshot.rs
index f157599..08b0923 100644
--- a/tests/snapshot.rs
+++ b/tests/snapshot.rs
@@ -5,38 +5,54 @@ use common::workflow_under_test;
 mod common;
 
 #[allow(dead_code)]
-pub(crate) enum OutputMode {
+enum OutputMode {
     Stdout,
     Stderr,
     Both,
 }
 
-pub(crate) struct Zizmor {
+struct Zizmor {
+    cmd: Command,
+    offline: bool,
     workflow: Option<String>,
-    args: Vec<String>,
     output: OutputMode,
 }
 
 impl Zizmor {
+    /// Create a new zizmor runner.
     fn new() -> Self {
+        let cmd = Command::cargo_bin("zizmor").unwrap();
+
         Self {
+            cmd,
+            offline: true,
             workflow: None,
-            args: vec!["--offline".into()],
             output: OutputMode::Stdout,
         }
     }
 
     fn args<'a>(mut self, args: impl IntoIterator<Item = &'a str>) -> Self {
-        self.args.extend(args.into_iter().map(Into::into));
+        self.cmd.args(args);
+        self
+    }
+
+    fn setenv(mut self, key: &str, value: &str) -> Self {
+        self.cmd.env(key, value);
+        self
+    }
 
+    fn unsetenv(mut self, key: &str) -> Self {
+        self.cmd.env_remove(key);
         self
     }
 
     fn workflow(mut self, workflow: impl Into<String>) -> Self {
-        let workflow = workflow.into();
-        self.args.push(workflow.clone());
-        self.workflow = Some(workflow);
+        self.workflow = Some(workflow.into());
+        self
+    }
 
+    fn offline(mut self, flag: bool) -> Self {
+        self.offline = flag;
         self
     }
 
@@ -46,11 +62,16 @@ impl Zizmor {
         self
     }
 
-    fn run(self) -> Result<String> {
-        let mut cmd = Command::cargo_bin("zizmor")?;
-        cmd.args(self.args);
+    fn run(mut self) -> Result<String> {
+        if self.offline {
+            self.cmd.arg("--offline");
+        }
+
+        if let Some(workflow) = &self.workflow {
+            self.cmd.arg(workflow);
+        }
 
-        let output = cmd.output()?;
+        let output = self.cmd.output()?;
 
         let mut raw = String::from_utf8(match self.output {
             OutputMode::Stdout => output.stdout,
@@ -58,18 +79,54 @@ impl Zizmor {
             OutputMode::Both => [output.stdout, output.stderr].concat(),
         })?;
 
-        if let Some(workflow) = self.workflow {
-            raw = raw.replace(&workflow, "@@INPUT@@");
+        if let Some(workflow) = &self.workflow {
+            raw = raw.replace(workflow, "@@INPUT@@");
         }
 
         Ok(raw)
     }
 }
 
-pub(crate) fn zizmor() -> Zizmor {
+fn zizmor() -> Zizmor {
     Zizmor::new()
 }
 
+#[test]
+fn test_cant_retrieve() -> Result<()> {
+    insta::assert_snapshot!(zizmor()
+        .output(OutputMode::Stderr)
+        .offline(true)
+        .unsetenv("GH_TOKEN")
+        .args(["pypa/sampleproject"])
+        .run()?);
+
+    Ok(())
+}
+
+#[test]
+fn test_conflicting_online_options() -> Result<()> {
+    insta::assert_snapshot!(zizmor()
+        .output(OutputMode::Stderr)
+        .setenv("GH_TOKEN", "phony")
+        .offline(true)
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .output(OutputMode::Stderr)
+        .offline(true)
+        .args(["--gh-token=phony"])
+        .run()?);
+
+    insta::assert_snapshot!(zizmor()
+        .output(OutputMode::Stderr)
+        .setenv("ZIZMOR_OFFLINE", "true")
+        .setenv("GH_TOKEN", "phony")
+        .offline(false) // explicitly disable so that we test ZIZMOR_OFFLINE above
+        .run()?);
+
+    Ok(())
+}
+
 #[test]
 fn artipacked() -> Result<()> {
     insta::assert_snapshot!(zizmor()
diff --git a/tests/snapshots/snapshot__cant_retrieve.snap b/tests/snapshots/snapshot__cant_retrieve.snap
new file mode 100644
index 0000000..8f77733
--- /dev/null
+++ b/tests/snapshots/snapshot__cant_retrieve.snap
@@ -0,0 +1,7 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().output(OutputMode::Stderr).args([\"pypa/sampleproject\"]).run()?"
+snapshot_kind: text
+---
+error: can't retrieve repository: pypa/sampleproject
+ = note: try removing --offline or passing --gh-token <TOKEN>
diff --git a/tests/snapshots/snapshot__conflicting_online_options-2.snap b/tests/snapshots/snapshot__conflicting_online_options-2.snap
new file mode 100644
index 0000000..cee9aa2
--- /dev/null
+++ b/tests/snapshots/snapshot__conflicting_online_options-2.snap
@@ -0,0 +1,10 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().output(OutputMode::Stderr).offline(true).args([\"--gh-token=phony\"]).run()?"
+snapshot_kind: text
+---
+error: the argument '--gh-token <GH_TOKEN>' cannot be used with '--offline'
+
+Usage: zizmor --gh-token <GH_TOKEN> <INPUTS>...
+
+For more information, try '--help'.
diff --git a/tests/snapshots/snapshot__conflicting_online_options-3.snap b/tests/snapshots/snapshot__conflicting_online_options-3.snap
new file mode 100644
index 0000000..4680eec
--- /dev/null
+++ b/tests/snapshots/snapshot__conflicting_online_options-3.snap
@@ -0,0 +1,10 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().output(OutputMode::Stderr).setenv(\"ZIZMOR_OFFLINE\",\n\"true\").setenv(\"GH_TOKEN\", \"phony\").run()?"
+snapshot_kind: text
+---
+error: the argument '--offline' cannot be used with '--gh-token <GH_TOKEN>'
+
+Usage: zizmor --offline <INPUTS>...
+
+For more information, try '--help'.
diff --git a/tests/snapshots/snapshot__conflicting_online_options.snap b/tests/snapshots/snapshot__conflicting_online_options.snap
new file mode 100644
index 0000000..a498dff
--- /dev/null
+++ b/tests/snapshots/snapshot__conflicting_online_options.snap
@@ -0,0 +1,10 @@
+---
+source: tests/snapshot.rs
+expression: "zizmor().output(OutputMode::Stderr).setenv(\"GH_TOKEN\",\n\"phony\").offline(true).run()?"
+snapshot_kind: text
+---
+error: the argument '--offline' cannot be used with '--gh-token <GH_TOKEN>'
+
+Usage: zizmor --offline <INPUTS>...
+
+For more information, try '--help'.