Skip to content

Commit 75734f7

Browse files
committed
actions: add lockfile-to-data-extension generator for unpinned-tag
Add a Go tool that parses a repository's Actions lockfile (.github/workflows/actions.lock) with the canonical parser at github.com/github/actions-lockfile/go and emits a CodeQL data extension populating pinnedByLockfileDataModel, the predicate the actions/unpinned-tag query already consumes to suppress lockfile-pinned refs. The generator is transport-agnostic: it produces the same [workflow_path, nwo, ref] rows whether they ship as a model pack applied via --model-packs (as today, mirroring codeql/immutable-actions-list) or later feed an extractor-native relation, so the parsing core is reusable without touching the query. Lockfiles record the resolved ref (e.g. v4.3.1) while workflows usually write a shorter mutable tag (v4). Since the query matches the ref as written, the generator expands every full-semver resolved ref into its major.minor and major-only forms, so uses: owner/action@v4 is recognized as pinned by a v4.3.1 lockfile entry. Verified end to end against a synthetic repo: the lockfile-pinned short-tag ref is suppressed while unlocked refs still report. actions-lockfile is not yet public, so go.mod carries a local replace directive for building and testing; remove it once the module is published.
1 parent 88c36ab commit 75734f7

9 files changed

Lines changed: 402 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/lockfile-extension-generator
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# lockfile-extension-generator
2+
3+
Turns a repository's GitHub Actions lockfile (`.github/workflows/actions.lock`)
4+
into a CodeQL data extension that populates the `pinnedByLockfileDataModel`
5+
extensible predicate consumed by the `actions/unpinned-tag`
6+
(`js/actions/actions-workflow-unpinned-tag`) query.
7+
8+
## Why
9+
10+
`actions/unpinned-tag` flags `uses:` references pinned to a mutable tag (e.g.
11+
`actions/checkout@v4`) rather than an immutable commit SHA. When a repository
12+
maintains an Actions lockfile, every such tag is already bound to a verified
13+
commit SHA at run time by the lockfile — which is exactly the pinning evidence
14+
the query otherwise lacks. Feeding the lockfile's bindings into
15+
`pinnedByLockfileDataModel` lets the query suppress those references while still
16+
flagging genuinely unpinned ones.
17+
18+
The generator is deliberately **transport-agnostic**. It parses the lockfile
19+
with the canonical parser at `github.com/github/actions-lockfile/go` and emits
20+
the `[workflow_path, nwo, ref]` rows the query already matches on. Today those
21+
rows ship as a data-extension model pack applied at analysis time via
22+
`--model-packs` (the same mechanism as `codeql/immutable-actions-list`). The
23+
same parsing core can later feed an extractor-native relation without changing
24+
the query.
25+
26+
## Ref normalization
27+
28+
A lockfile records the *resolved* ref for each dependency — the CLI prefers a
29+
full semver tag such as `v4.3.1`. A workflow author, however, usually writes a
30+
shorter, mutable tag such as `v4` or `v4.3` in `uses:`, and the query matches on
31+
the ref exactly as written. For every full-semver resolved ref the generator
32+
therefore also emits its major.minor (`v4.3`) and major-only (`v4`) forms, so a
33+
`uses: owner/action@v4` is recognized as pinned by a lockfile entry that
34+
resolved to `v4.3.1`. Partial tags, pre-release tags, branches, and SHAs pass
35+
through unchanged.
36+
37+
## Usage
38+
39+
```
40+
lockfile-extension-generator <source-root> [output-file]
41+
```
42+
43+
- `<source-root>`: repository root to scan; the lockfile is read from
44+
`<source-root>/.github/workflows/actions.lock`.
45+
- `[output-file]`: destination for the extension YAML; defaults to stdout.
46+
47+
If the repository has no lockfile the generator exits successfully without
48+
writing anything, so it is safe to run unconditionally.
49+
50+
## Building and testing locally
51+
52+
`github.com/github/actions-lockfile` is not yet public, so the module cannot be
53+
fetched by the module proxy. Point the `replace` directive in `go.mod` at a
54+
local clone before building or testing:
55+
56+
```
57+
go mod edit -replace github.com/github/actions-lockfile/go=/path/to/actions-lockfile/go
58+
go test ./...
59+
```
60+
61+
Remove the `replace` directive once actions-lockfile is published.
62+
63+
## End-to-end check
64+
65+
```
66+
# A repo whose workflow writes `uses: owner/action@v4` while the lockfile
67+
# resolves it to v4.3.1:
68+
lockfile-extension-generator /path/to/repo /tmp/pack/ext/pinned.yml
69+
codeql database analyze <db> \
70+
codeql/actions-queries:Security/CWE-829/UnpinnedActionsTag.ql \
71+
--additional-packs=/tmp/pack --model-packs=<your-pack-name>
72+
```
73+
74+
The lockfile-pinned reference is suppressed; references not covered by the
75+
lockfile are still reported.
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
// Package main implements a generator that turns a repository's Actions lockfile
2+
// (`.github/workflows/actions.lock`) into a CodeQL data extension populating the
3+
// `pinnedByLockfileDataModel` extensible predicate consumed by the
4+
// `actions/unpinned-tag` query.
5+
//
6+
// The generator is deliberately transport-agnostic: it parses the lockfile with
7+
// the canonical parser at `github.com/github/actions-lockfile/go` and emits the
8+
// same `[workflow_path, nwo, ref]` rows the query already matches on. Today those
9+
// rows are shipped as a data-extension model pack (applied at analysis time via
10+
// `--model-packs`, exactly like `codeql/immutable-actions-list`). The same parsing
11+
// core can later feed an extractor-native TRAP relation without changing the query.
12+
package main
13+
14+
import (
15+
"fmt"
16+
"sort"
17+
"strings"
18+
19+
"github.com/github/actions-lockfile/go/pkg/lockfile"
20+
)
21+
22+
// row is a single `pinnedByLockfileDataModel` tuple: the reference
23+
// `nwo`@`ref` in the workflow or composite action file at `workflowPath` is
24+
// pinned by the repository's Actions lockfile.
25+
type row struct {
26+
workflowPath string
27+
nwo string
28+
ref string
29+
}
30+
31+
// rowsFromLockfile parses lockfile `contents` and returns the deduplicated,
32+
// sorted set of `pinnedByLockfileDataModel` rows it implies.
33+
//
34+
// A lockfile records the *resolved* ref for each dependency (the CLI prefers a
35+
// full semver tag such as `v4.3.1`), but a workflow author usually writes a
36+
// shorter, mutable tag such as `v4` or `v4.3` in `uses:`. The query matches on
37+
// the ref exactly as written, so for every full-semver resolved ref we also
38+
// emit its major-only (`v4`) and major.minor (`v4.3`) forms. This lets a
39+
// `uses: owner/action@v4` be recognised as pinned by a lockfile entry that
40+
// resolved to `v4.3.1`, which is the common real-world case.
41+
func rowsFromLockfile(contents []byte) ([]row, error) {
42+
f, err := lockfile.Parse(contents)
43+
if err != nil {
44+
return nil, fmt.Errorf("parsing lockfile: %w", err)
45+
}
46+
47+
seen := make(map[row]struct{})
48+
for workflowPath, pinKeys := range f.Workflows {
49+
for _, key := range pinKeys {
50+
pin, ok := lockfile.ParsePin(key)
51+
if !ok {
52+
continue
53+
}
54+
// Prefer the resolved ref recorded in the dependency metadata; fall
55+
// back to the ref embedded in the pin key.
56+
ref := pin.Ref
57+
if dep, ok := f.Dependencies[key]; ok && dep.Ref != "" {
58+
ref = dep.Ref
59+
}
60+
for _, r := range refVariants(ref) {
61+
seen[row{workflowPath: workflowPath, nwo: pin.NWO, ref: r}] = struct{}{}
62+
}
63+
}
64+
}
65+
66+
rows := make([]row, 0, len(seen))
67+
for r := range seen {
68+
rows = append(rows, r)
69+
}
70+
sort.Slice(rows, func(i, j int) bool {
71+
if rows[i].workflowPath != rows[j].workflowPath {
72+
return rows[i].workflowPath < rows[j].workflowPath
73+
}
74+
if rows[i].nwo != rows[j].nwo {
75+
return rows[i].nwo < rows[j].nwo
76+
}
77+
return rows[i].ref < rows[j].ref
78+
})
79+
return rows, nil
80+
}
81+
82+
// refVariants returns every ref string a workflow author might have written in
83+
// `uses:` that the resolved `ref` covers. For a full semver tag (e.g. `v4.3.1`)
84+
// this is the tag itself plus its major.minor (`v4.3`) and major-only (`v4`)
85+
// forms; for anything else (a branch, a partial tag, a SHA) it is just the ref.
86+
func refVariants(ref string) []string {
87+
sv, ok := lockfile.ParseSemVer(ref)
88+
if !ok || !sv.IsFull() {
89+
return []string{ref}
90+
}
91+
variants := []string{ref}
92+
if minor := sv.MinorTag(); minor != ref {
93+
variants = append(variants, minor)
94+
}
95+
if major := sv.MajorTag(); major != ref {
96+
variants = append(variants, major)
97+
}
98+
return variants
99+
}
100+
101+
// renderExtension serialises `rows` as a CodeQL data-extension YAML document that
102+
// adds to the `pinnedByLockfileDataModel` extensible predicate in
103+
// `codeql/actions-all`.
104+
func renderExtension(rows []row) string {
105+
var b strings.Builder
106+
b.WriteString("# Generated by lockfile-extension-generator from .github/workflows/actions.lock.\n")
107+
b.WriteString("# Do not edit by hand; regenerate from the repository's Actions lockfile.\n")
108+
b.WriteString("extensions:\n")
109+
b.WriteString(" - addsTo:\n")
110+
b.WriteString(" pack: codeql/actions-all\n")
111+
b.WriteString(" extensible: pinnedByLockfileDataModel\n")
112+
b.WriteString(" data:\n")
113+
for _, r := range rows {
114+
fmt.Fprintf(&b, " - [%q, %q, %q]\n", r.workflowPath, r.nwo, r.ref)
115+
}
116+
return b.String()
117+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func TestRowsFromLockfile(t *testing.T) {
9+
contents, err := os.ReadFile("testdata/actions.lock")
10+
if err != nil {
11+
t.Fatalf("reading fixture: %v", err)
12+
}
13+
14+
rows, err := rowsFromLockfile(contents)
15+
if err != nil {
16+
t.Fatalf("rowsFromLockfile: %v", err)
17+
}
18+
19+
// Every full-semver resolved ref expands to {raw, minor, major} so a
20+
// workflow that writes a shorter tag in `uses:` still matches. Rows are
21+
// sorted by (workflowPath, nwo, ref).
22+
want := []row{
23+
{".github/workflows/ci.yml", "actions/checkout", "v4"},
24+
{".github/workflows/ci.yml", "actions/checkout", "v4.3"},
25+
{".github/workflows/ci.yml", "actions/checkout", "v4.3.1"},
26+
{".github/workflows/ci.yml", "some-owner/pinned-action", "v1"},
27+
{".github/workflows/ci.yml", "some-owner/pinned-action", "v1.2"},
28+
{".github/workflows/ci.yml", "some-owner/pinned-action", "v1.2.0"},
29+
{".github/workflows/release.yml", "actions/checkout", "v4"},
30+
{".github/workflows/release.yml", "actions/checkout", "v4.3"},
31+
{".github/workflows/release.yml", "actions/checkout", "v4.3.1"},
32+
}
33+
34+
if len(rows) != len(want) {
35+
t.Fatalf("got %d rows, want %d:\n%#v", len(rows), len(want), rows)
36+
}
37+
for i := range want {
38+
if rows[i] != want[i] {
39+
t.Errorf("row %d: got %+v, want %+v", i, rows[i], want[i])
40+
}
41+
}
42+
}
43+
44+
func TestRefVariants(t *testing.T) {
45+
cases := []struct {
46+
ref string
47+
want []string
48+
}{
49+
{"v4.3.1", []string{"v4.3.1", "v4.3", "v4"}},
50+
{"v1.0.0", []string{"v1.0.0", "v1.0", "v1"}},
51+
// Partial tags are already their own broadest form.
52+
{"v4", []string{"v4"}},
53+
{"v4.3", []string{"v4.3"}},
54+
// Pre-release and non-semver refs pass through untouched.
55+
{"v2.0.0-beta.1", []string{"v2.0.0-beta.1"}},
56+
{"main", []string{"main"}},
57+
}
58+
for _, c := range cases {
59+
got := refVariants(c.ref)
60+
if len(got) != len(c.want) {
61+
t.Errorf("refVariants(%q) = %v, want %v", c.ref, got, c.want)
62+
continue
63+
}
64+
for i := range c.want {
65+
if got[i] != c.want[i] {
66+
t.Errorf("refVariants(%q) = %v, want %v", c.ref, got, c.want)
67+
break
68+
}
69+
}
70+
}
71+
}
72+
73+
func TestRenderExtensionMatchesGolden(t *testing.T) {
74+
contents, err := os.ReadFile("testdata/actions.lock")
75+
if err != nil {
76+
t.Fatalf("reading fixture: %v", err)
77+
}
78+
rows, err := rowsFromLockfile(contents)
79+
if err != nil {
80+
t.Fatalf("rowsFromLockfile: %v", err)
81+
}
82+
got := renderExtension(rows)
83+
84+
want, err := os.ReadFile("testdata/expected.yml")
85+
if err != nil {
86+
t.Fatalf("reading golden: %v", err)
87+
}
88+
if got != string(want) {
89+
t.Errorf("rendered extension does not match testdata/expected.yml:\n--- got ---\n%s", got)
90+
}
91+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module github.com/github/codeql/actions/extractor/tools/lockfile-extension-generator
2+
3+
go 1.23
4+
5+
require github.com/github/actions-lockfile/go v0.0.0-00010101000000-000000000000
6+
7+
require gopkg.in/yaml.v3 v3.0.1 // indirect
8+
9+
// LOCAL TESTING ONLY: github.com/github/actions-lockfile is not yet public, so it
10+
// cannot be fetched by the module proxy. Point this at a local clone of the
11+
// repository to build and test the generator:
12+
//
13+
// go mod edit -replace github.com/github/actions-lockfile/go=/path/to/actions-lockfile/go
14+
//
15+
// Remove this replace directive once actions-lockfile is published.
16+
replace github.com/github/actions-lockfile/go => /Users/nodeselector/ghq/github.com/github/actions-lockfile/go
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2+
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4+
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5+
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
6+
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
7+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
8+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
9+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
10+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/github/actions-lockfile/go/pkg/lockfile"
9+
)
10+
11+
// main reads a repository's Actions lockfile and writes the corresponding
12+
// `pinnedByLockfileDataModel` data extension.
13+
//
14+
// Usage:
15+
//
16+
// lockfile-extension-generator <source-root> [output-file]
17+
//
18+
// `<source-root>` is the root of the repository to scan; the lockfile is read
19+
// from `<source-root>/.github/workflows/actions.lock`. When `[output-file]` is
20+
// omitted the extension is written to stdout. If the repository has no lockfile
21+
// the generator exits successfully without writing anything, so it is safe to
22+
// run unconditionally.
23+
func main() {
24+
if len(os.Args) < 2 || len(os.Args) > 3 {
25+
fmt.Fprintf(os.Stderr, "usage: %s <source-root> [output-file]\n", filepath.Base(os.Args[0]))
26+
os.Exit(2)
27+
}
28+
sourceRoot := os.Args[1]
29+
30+
lockPath := filepath.Join(sourceRoot, filepath.FromSlash(lockfile.Path))
31+
contents, err := os.ReadFile(lockPath)
32+
if err != nil {
33+
if os.IsNotExist(err) {
34+
// No lockfile: nothing to pin. A clean no-op keeps this safe to run
35+
// against every repository.
36+
return
37+
}
38+
fmt.Fprintf(os.Stderr, "error: reading %s: %v\n", lockPath, err)
39+
os.Exit(1)
40+
}
41+
42+
rows, err := rowsFromLockfile(contents)
43+
if err != nil {
44+
fmt.Fprintf(os.Stderr, "error: %v\n", err)
45+
os.Exit(1)
46+
}
47+
48+
out := renderExtension(rows)
49+
if len(os.Args) == 3 {
50+
if err := os.WriteFile(os.Args[2], []byte(out), 0o644); err != nil {
51+
fmt.Fprintf(os.Stderr, "error: writing %s: %v\n", os.Args[2], err)
52+
os.Exit(1)
53+
}
54+
return
55+
}
56+
fmt.Print(out)
57+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Machine-generated by `gh actions-lock`; used here as a test fixture.
2+
version: 'v0.0.2'
3+
workflows:
4+
'.github/workflows/ci.yml':
5+
- 'actions/checkout@v4.3.1'
6+
- 'some-owner/pinned-action@v1.2.0'
7+
'.github/workflows/release.yml':
8+
- 'actions/checkout@v4.3.1'
9+
dependencies:
10+
'actions/checkout@v4.3.1':
11+
ref: 'v4.3.1'
12+
commit: 'sha1-34e114876b0b11c390a56381ad16ebd13914f8d5'
13+
owner_id: 44036562
14+
repo_id: 197814629
15+
'some-owner/pinned-action@v1.2.0':
16+
ref: 'v1.2.0'
17+
commit: 'sha1-1111111111111111111111111111111111111111'
18+
owner_id: 12345
19+
repo_id: 67890

0 commit comments

Comments
 (0)