Repository: woodruffw/zizmor (language: python)

Issue:
Feature: split unpinned-uses into regular and pedantic options
### Pre-submission checks

- [X] I am **not** reporting a bug (crash, false positive/negative, etc). These must be filed via the bug report template.
- [X] I have looked through the open issues for a duplicate request.

### What's the problem this feature will solve?

As discussed in #178, it would be good to split the check for unpinned `uses` into two checks:

1. A normal check that is satisfied by branch, tag, or SHA
2. A pedantic-level check that is only satisfied by SHA

### Describe the solution you'd like

Creating this issue to indicate that I am, in fact, working on it.

### Additional context

_No response_
Feature: split unpinned-uses into regular and pedantic options
### Pre-submission checks

- [X] I am **not** reporting a bug (crash, false positive/negative, etc). These must be filed via the bug report template.
- [X] I have looked through the open issues for a duplicate request.

### What's the problem this feature will solve?

As discussed in #178, it would be good to split the check for unpinned `uses` into two checks:

1. A normal check that is satisfied by branch, tag, or SHA
2. A pedantic-level check that is only satisfied by SHA

### Describe the solution you'd like

Creating this issue to indicate that I am, in fact, working on it.

### Additional context

_No response_


Discussion / hints:

Fix patch:
diff --git a/docs/audits.md b/docs/audits.md
index 4771efd..3c92a9c 100644
--- a/docs/audits.md
+++ b/docs/audits.md
@@ -544,6 +544,9 @@ GitHub Actions will use the latest commit on the referenced repository
 This can represent a (small) security risk, as it leaves the calling workflow
 at the mercy of the callee action's default branch.
 
+`uses:` clauses with no pin are flagged as *Medium* severity. `uses:` clauses
+with a branch or tag pin are flagged as *Low* severity.
+
 ### Remediation
 
 For repository actions (like @actions/checkout): add a branch, tag, or SHA
diff --git a/src/audit/unpinned_uses.rs b/src/audit/unpinned_uses.rs
index f3bd4d3..ca8773d 100644
--- a/src/audit/unpinned_uses.rs
+++ b/src/audit/unpinned_uses.rs
@@ -21,19 +21,28 @@ impl WorkflowAudit for UnpinnedUses {
             return Ok(vec![]);
         };
 
-        if uses.unpinned() {
-            findings.push(
-                Self::finding()
-                    .confidence(Confidence::High)
-                    .severity(Severity::Informational)
-                    .add_location(
-                        step.location()
-                            .with_keys(&["uses".into()])
-                            .annotated("action is not pinned to a tag, branch, or hash ref"),
-                    )
-                    .build(step.workflow())?,
-            );
-        }
+        let (annotation, severity) = if uses.unpinned() {
+            (
+                "action is not pinned to a tag, branch, or hash ref",
+                Severity::Medium,
+            )
+        } else if uses.unhashed() {
+            ("action is not pinned to a hash ref", Severity::Low)
+        } else {
+            return Ok(vec![]);
+        };
+
+        findings.push(
+            Self::finding()
+                .confidence(Confidence::High)
+                .severity(severity)
+                .add_location(
+                    step.location()
+                        .with_keys(&["uses".into()])
+                        .annotated(annotation),
+                )
+                .build(step.workflow())?,
+        );
 
         Ok(findings)
     }
diff --git a/src/models.rs b/src/models.rs
index 6001893..8f6000a 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -433,6 +433,13 @@ impl<'a> Uses<'a> {
             Uses::Repository(repo) => repo.git_ref.is_none(),
         }
     }
+
+    pub(crate) fn unhashed(&self) -> bool {
+        match self {
+            Uses::Docker(docker) => docker.hash.is_some(),
+            Uses::Repository(repo) => !repo.ref_is_commit(),
+        }
+    }
 }
 
 #[cfg(test)]


Test patch:
diff --git a/tests/acceptance.rs b/tests/acceptance.rs
index 0b84c4b..1476b4f 100644
--- a/tests/acceptance.rs
+++ b/tests/acceptance.rs
@@ -67,7 +67,7 @@ fn audit_artipacked() -> anyhow::Result<()> {
     assert_value_match(
         &findings,
         "$[0].locations[0].concrete.feature",
-        "uses: actions/checkout@v4",
+        "uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683",
     );
 
     Ok(())
@@ -188,12 +188,12 @@ fn audit_unpinned_uses() -> anyhow::Result<()> {
 
     let execution = zizmor().args(cli_args).output()?;
 
-    assert_eq!(execution.status.code(), Some(11));
+    assert_eq!(execution.status.code(), Some(13));
 
     let findings = serde_json::from_slice(&execution.stdout)?;
 
     assert_value_match(&findings, "$[0].determinations.confidence", "High");
-    assert_value_match(&findings, "$[0].determinations.severity", "Informational");
+    assert_value_match(&findings, "$[0].determinations.severity", "Medium");
     assert_value_match(
         &findings,
         "$[0].locations[0].concrete.feature",
diff --git a/tests/test-data/artipacked.yml b/tests/test-data/artipacked.yml
index 965f5a9..6893b83 100644
--- a/tests/test-data/artipacked.yml
+++ b/tests/test-data/artipacked.yml
@@ -10,4 +10,4 @@ jobs:
   artipacked:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # tag=v4.2.2
diff --git a/tests/test-data/inlined-ignores.yml b/tests/test-data/inlined-ignores.yml
index 38f4bdb..55845a0 100644
--- a/tests/test-data/inlined-ignores.yml
+++ b/tests/test-data/inlined-ignores.yml
@@ -8,7 +8,7 @@ jobs:
   artipacked-ignored:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v4 # zizmor: ignore[artipacked]
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # zizmor: ignore[artipacked]
 
   insecure-commands-ignored:
     runs-on: ubuntu-latest
@@ -26,3 +26,8 @@ jobs:
         password: hackme # zizmor: ignore[hardcoded-container-credentials]
     steps:
       - run: echo 'This is a honeypot actually!'
+
+  unpinned-uses-ignored:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: github/codeql-action/upload-sarif # zizmor: ignore[unpinned-uses]
diff --git a/tests/test-data/template-injection.yml b/tests/test-data/template-injection.yml
index 569fce5..2920063 100644
--- a/tests/test-data/template-injection.yml
+++ b/tests/test-data/template-injection.yml
@@ -10,7 +10,7 @@ jobs:
     runs-on: ubuntu-latest
 
     steps:
-      - uses: actions/github-script@v7
+      - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # tag=v7.0.1
         with:
           script: |
             return "doing a thing: ${{ github.event.issue.title }}"
diff --git a/tests/test-data/use-trusted-publishing.yml b/tests/test-data/use-trusted-publishing.yml
index 5d42920..39ec8fb 100644
--- a/tests/test-data/use-trusted-publishing.yml
+++ b/tests/test-data/use-trusted-publishing.yml
@@ -10,6 +10,6 @@ jobs:
       id-token: write
     steps:
       - name: vulnerable-2
-        uses: pypa/gh-action-pypi-publish@release/v1
+        uses: pypa/gh-action-pypi-publish@release/v1 # zizmor: ignore[unpinned-uses]
         with:
           password: ${{ secrets.PYPI_TOKEN }}