Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions actions/extractor/tools/autobuild.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ else
export LGTM_INDEX_FILTERS
fi

# Capture the source root before handing off to the JavaScript autobuilder,
# which may change the working directory. The Actions extractor runs from the
# source root, so `pwd` here is the root of the repository being analysed.
ACTIONS_SRC_ROOT="$(pwd)"

# Find the JavaScript extractor directory via `codeql resolve extractor`.
CODEQL_EXTRACTOR_JAVASCRIPT_ROOT="$("${CODEQL_DIST}/codeql" resolve extractor --language javascript)"
export CODEQL_EXTRACTOR_JAVASCRIPT_ROOT
Expand All @@ -55,3 +60,12 @@ env CODEQL_EXTRACTOR_JAVASCRIPT_DIAGNOSTIC_DIR="${CODEQL_EXTRACTOR_ACTIONS_DIAGN
CODEQL_EXTRACTOR_JAVASCRIPT_TRAP_DIR="${CODEQL_EXTRACTOR_ACTIONS_TRAP_DIR}" \
CODEQL_EXTRACTOR_JAVASCRIPT_WIP_DATABASE="${CODEQL_EXTRACTOR_ACTIONS_WIP_DATABASE}" \
"${JAVASCRIPT_AUTO_BUILD}"

# Generate the lockfile-pinned data extension from the repository's Actions
# lockfile, if present. This is a no-op for repositories without a lockfile.
GENERATE_LOCKFILE_EXTENSION="$(CDPATH= cd "$(dirname "$0")" && pwd)/generate-lockfile-extension.sh"
if [ -x "${GENERATE_LOCKFILE_EXTENSION}" ]; then
echo "Generating lockfile-pinned data extension."
"${GENERATE_LOCKFILE_EXTENSION}" "${ACTIONS_SRC_ROOT}" || \
echo "Lockfile-pinned data extension generation failed; continuing without it." >&2
fi
93 changes: 93 additions & 0 deletions actions/extractor/tools/generate-lockfile-extension.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
#!/bin/sh

# Generates a CodeQL data extension that records the `uses:` references pinned by
# the repository's GitHub Actions lockfile (`.github/workflows/actions.lock`),
# populating the `pinnedByLockfileDataModel` extensible predicate consumed by the
# `actions/unpinned-tag` query.
#
# It is invoked by the Actions extractor autobuild during `codeql database
# create`. The generated extension is written into the database as a
# self-contained model pack under:
#
# <database>/lockfile-extension/
# qlpack.yml
# ext/pinned_by_lockfile.model.yml
#
# CodeQL does not auto-apply extensions carried inside a database, so analysis
# must add this pack explicitly, e.g.:
#
# codeql database analyze <db> ... \
# --additional-packs <db>/lockfile-extension \
# --model-packs codeql/actions-lockfile-pins
#
# The step is a clean no-op when the repository has no lockfile, so it is safe to
# run against every database.

set -eu

SRC_ROOT="${1:?usage: generate-lockfile-extension.sh <source-root>}"
LOCKFILE="${SRC_ROOT}/.github/workflows/actions.lock"

if [ ! -f "${LOCKFILE}" ]; then
echo "No Actions lockfile at '${LOCKFILE}'; skipping lockfile-pinned extension."
exit 0
fi

if [ -z "${CODEQL_EXTRACTOR_ACTIONS_WIP_DATABASE:-}" ]; then
echo "CODEQL_EXTRACTOR_ACTIONS_WIP_DATABASE is not set; cannot write lockfile extension." >&2
exit 0
fi

SCRIPT_DIR="$(CDPATH= cd "$(dirname "$0")" && pwd)"
GEN_DIR="${SCRIPT_DIR}/lockfile-extension-generator"

PACK_DIR="${CODEQL_EXTRACTOR_ACTIONS_WIP_DATABASE}/lockfile-extension"

# Stage the pack in a sibling temp dir inside the database directory (same
# filesystem as the final location) so the final `mv` is an atomic rename, and
# only publish a complete pack on success -- a failure part-way through never
# leaves a half-written pack dir (e.g. an `ext/` with no `qlpack.yml`) that would
# break `--additional-packs`.
STAGE_DIR="$(mktemp -d "${CODEQL_EXTRACTOR_ACTIONS_WIP_DATABASE}/lockfile-extension.XXXXXX")"
trap 'rm -rf "${STAGE_DIR}"' EXIT
mkdir -p "${STAGE_DIR}/ext"

# Resolve the generator: prefer a prebuilt binary shipped with the extractor;
# otherwise build from source with the Go toolchain if it is available.
GEN_BIN="${GEN_DIR}/bin/lockfile-extension-generator"
RUN_GENERATOR=""
if [ -x "${GEN_BIN}" ]; then
RUN_GENERATOR="${GEN_BIN}"
elif command -v go >/dev/null 2>&1; then
BUILT_BIN="${STAGE_DIR}/lockfile-extension-generator"
echo "Building lockfile-extension-generator from source with 'go build'."
( cd "${GEN_DIR}" && go build -o "${BUILT_BIN}" . )
RUN_GENERATOR="${BUILT_BIN}"
else
echo "No lockfile-extension-generator binary and no Go toolchain; skipping lockfile-pinned extension." >&2
exit 0
fi

"${RUN_GENERATOR}" "${SRC_ROOT}" "${STAGE_DIR}/ext/pinned_by_lockfile.model.yml"

cat > "${STAGE_DIR}/qlpack.yml" <<'EOF'
name: codeql/actions-lockfile-pins
version: 0.0.1
library: true
warnOnImplicitThis: true
# Generated per-database from the repository's Actions lockfile by the Actions
# extractor. Apply it at analysis time with `--model-packs
# codeql/actions-lockfile-pins` (and `--additional-packs <db>/lockfile-extension`).
extensionTargets:
codeql/actions-all: "*"
dataExtensions:
- ext/*.model.yml
EOF

# Publish atomically: the pack only appears in the database once fully written.
rm -f "${STAGE_DIR}/lockfile-extension-generator"
rm -rf "${PACK_DIR}"
mv "${STAGE_DIR}" "${PACK_DIR}"
trap - EXIT

echo "Wrote lockfile-pinned data extension to '${PACK_DIR}'."
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/lockfile-extension-generator
120 changes: 120 additions & 0 deletions actions/extractor/tools/lockfile-extension-generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# lockfile-extension-generator

Turns a repository's GitHub Actions lockfile (`.github/workflows/actions.lock`)
into a CodeQL data extension that populates the `pinnedByLockfileDataModel`
extensible predicate consumed by the `actions/unpinned-tag`
(`js/actions/actions-workflow-unpinned-tag`) query.

## Why

`actions/unpinned-tag` flags `uses:` references pinned to a mutable tag (e.g.
`actions/checkout@v4`) rather than an immutable commit SHA. When a repository
maintains an Actions lockfile, every such tag is already bound to a verified
commit SHA at run time by the lockfile — which is exactly the pinning evidence
the query otherwise lacks. Feeding the lockfile's bindings into
`pinnedByLockfileDataModel` lets the query suppress those references while still
flagging genuinely unpinned ones.

The generator is deliberately **transport-agnostic**. It parses the minimal,
stable core of the lockfile format directly (see `lockfile.go`) and emits the
`[workflow_path, nwo, ref]` rows the query already matches on. It has no
dependency on the `github.com/github/actions-lockfile` module, so it builds
anywhere the Go toolchain is available. Today those rows ship as a
data-extension model pack applied at analysis time via `--model-packs` (the same
mechanism as `codeql/immutable-actions-list`). The same parsing core can later
feed an extractor-native relation without changing the query.

## Extractor integration

The Actions extractor runs this generator automatically during `codeql database
create` (see `../generate-lockfile-extension.sh`, invoked from
`../autobuild.sh`). When the repository has a lockfile, the extractor writes a
self-contained model pack into the database:

```
<database>/lockfile-extension/
qlpack.yml # name: codeql/actions-lockfile-pins
ext/pinned_by_lockfile.model.yml # generated pinnedByLockfileDataModel rows
```

CodeQL does not auto-apply extensions carried inside a database, so analysis
must add this pack explicitly:

```
codeql database analyze <db> \
codeql/actions-queries:Security/CWE-829/UnpinnedActionsTag.ql \
--additional-packs <db>/lockfile-extension \
--model-packs codeql/actions-lockfile-pins
```

Wiring that flag into the analysis harness (e.g. the CodeQL Action) is the
remaining integration step and lives outside this repository. Generation is a
clean no-op for repositories without a lockfile, so the extractor step is safe
to run unconditionally.

## Ref normalization

A lockfile records the *resolved* ref for each dependency — the CLI prefers a
full semver tag such as `v4.3.1`. A workflow author, however, usually writes a
shorter, mutable tag such as `v4` or `v4.3` in `uses:`, and the query matches on
the ref exactly as written. For every full-semver resolved ref the generator
therefore also emits its major.minor (`v4.3`) and major-only (`v4`) forms, so a
`uses: owner/action@v4` is recognized as pinned by a lockfile entry that
resolved to `v4.3.1`. Partial tags, pre-release tags, branches, and SHAs pass
through unchanged.

## Usage

```
lockfile-extension-generator <source-root> [output-file]
```

- `<source-root>`: repository root to scan; the lockfile is read from
`<source-root>/.github/workflows/actions.lock`.
- `[output-file]`: destination for the extension YAML; defaults to stdout.

If the repository has no lockfile the generator exits successfully without
writing anything, so it is safe to run unconditionally.

## Building and testing locally

The generator depends only on `gopkg.in/yaml.v3`, so it builds and tests with a
stock Go toolchain and no special setup:

```
go build ./...
go test ./...
```

The golden test (`testdata/expected.yml`) pins the exact generated output for a
representative lockfile, so any change to parsing or ref normalization must be
reflected there deliberately. The lockfile parsing in `lockfile.go` mirrors the
canonical semantics of `github.com/github/actions-lockfile`; if that format
evolves, update `lockfile.go` and the golden fixture together.

## End-to-end check

```
# A repo whose workflow writes `uses: owner/action@v4` while the lockfile
# resolves it to v4.3.1:
lockfile-extension-generator /path/to/repo /tmp/pack/ext/pinned.yml
codeql database analyze <db> \
codeql/actions-queries:Security/CWE-829/UnpinnedActionsTag.ql \
--additional-packs=/tmp/pack --model-packs=<your-pack-name>
```

The lockfile-pinned reference is suppressed; references not covered by the
lockfile are still reported.

## Limitations

- **Composite actions.** A lockfile records each workflow's *transitive* pin
list keyed by the workflow path. A `uses:` that appears only inside a
composite action file (`.github/actions/*/action.yml`) is therefore not
emitted against that action file's own path, so the query does not suppress
it. This is a completeness gap, not a correctness one: it can only leave a
reference reported, never wrongly suppress one, because a row is only ever
emitted for a `(path, nwo, ref)` the lockfile actually pins.
- **Ref forms the lockfile can't cover.** Only the resolved ref and its
major/major.minor forms are emitted. A `uses:` written with an unrelated tag
or a moving branch that the lockfile did not resolve from is not matched.
130 changes: 130 additions & 0 deletions actions/extractor/tools/lockfile-extension-generator/generator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
// Package main implements a generator that turns a repository's Actions lockfile
// (`.github/workflows/actions.lock`) into a CodeQL data extension populating the
// `pinnedByLockfileDataModel` extensible predicate consumed by the
// `actions/unpinned-tag` query.
//
// The generator is deliberately transport-agnostic: it parses the lockfile and
// emits the same `[workflow_path, nwo, ref]` rows the query already matches on.
// Today those rows are shipped as a data-extension model pack (applied at
// analysis time via `--model-packs`, exactly like `codeql/immutable-actions-list`).
// The same parsing core can later feed an extractor-native TRAP relation without
// changing the query.
//
// The lockfile format is owned by `github.com/github/actions-lockfile`; this
// generator parses the minimal, stable core it needs directly (see lockfile.go)
// so it has no dependency on that (currently private) module and builds anywhere
// the Go toolchain is available.
package main

import (
"fmt"
"sort"
"strings"
)

// row is a single `pinnedByLockfileDataModel` tuple: the reference
// `nwo`@`ref` in the workflow or composite action file at `workflowPath` is
// pinned by the repository's Actions lockfile.
type row struct {
workflowPath string
nwo string
ref string
}

// rowsFromLockfile parses lockfile `contents` and returns the deduplicated,
// sorted set of `pinnedByLockfileDataModel` rows it implies.
//
// A lockfile records the *resolved* ref for each dependency (the CLI prefers a
// full semver tag such as `v4.3.1`), but a workflow author usually writes a
// shorter, mutable tag such as `v4` or `v4.3` in `uses:`. The query matches on
// the ref exactly as written, so for every full-semver resolved ref we also
// emit its major-only (`v4`) and major.minor (`v4.3`) forms. This lets a
// `uses: owner/action@v4` be recognised as pinned by a lockfile entry that
// resolved to `v4.3.1`, which is the common real-world case.
func rowsFromLockfile(contents []byte) ([]row, error) {
doc, err := parseLockfile(contents)
if err != nil {
return nil, err
}

seen := make(map[row]struct{})
for workflowPath, pinKeys := range doc.Workflows {
for _, key := range pinKeys {
nwo, keyRef, ok := parsePin(key)
if !ok {
continue
}
// Prefer the resolved ref recorded in the dependency metadata; fall
// back to the ref embedded in the pin key.
ref := keyRef
if dep, ok := doc.Dependencies[key]; ok && dep.Ref != "" {
ref = dep.Ref
}
for _, r := range refVariants(ref) {
seen[row{workflowPath: workflowPath, nwo: nwo, ref: r}] = struct{}{}
}
}
}

rows := make([]row, 0, len(seen))
for r := range seen {
rows = append(rows, r)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].workflowPath != rows[j].workflowPath {
return rows[i].workflowPath < rows[j].workflowPath
}
if rows[i].nwo != rows[j].nwo {
return rows[i].nwo < rows[j].nwo
}
return rows[i].ref < rows[j].ref
})
return rows, nil
}

// refVariants returns every ref string a workflow author might have written in
// `uses:` that the resolved `ref` covers. For a full semver tag (e.g. `v4.3.1`)
// this is the tag itself plus its major.minor (`v4.3`) and major-only (`v4`)
// forms; for anything else (a branch, a partial tag, a SHA) it is just the ref.
func refVariants(ref string) []string {
sv, ok := parseSemVer(ref)
if !ok || !sv.isFull() {
return []string{ref}
}
variants := []string{ref}
if minor := sv.minorTag(); minor != ref {
variants = append(variants, minor)
}
if major := sv.majorTag(); major != ref {
variants = append(variants, major)
}
return variants
}

// renderExtension serialises `rows` as a CodeQL data-extension YAML document that
// adds to the `pinnedByLockfileDataModel` extensible predicate in
// `codeql/actions-all`.
func renderExtension(rows []row) string {
var b strings.Builder
b.WriteString("# Generated by lockfile-extension-generator from .github/workflows/actions.lock.\n")
b.WriteString("# Do not edit by hand; regenerate from the repository's Actions lockfile.\n")
b.WriteString("extensions:\n")
b.WriteString(" - addsTo:\n")
b.WriteString(" pack: codeql/actions-all\n")
b.WriteString(" extensible: pinnedByLockfileDataModel\n")
// `data` must be a YAML sequence. When there are no rows we must emit an
// explicit empty list (`data: []`); a bare `data:` parses as null, which
// CodeQL's extension loader rejects (`resolve extensions-by-pack` fails and
// aborts the whole analysis). Zero rows is reachable whenever a lockfile is
// present but pins no repo-level actions (e.g. only sub-path actions such as
// `github/codeql-action/init@v3`, which `parsePin` intentionally skips).
if len(rows) == 0 {
b.WriteString(" data: []\n")
return b.String()
}
b.WriteString(" data:\n")
for _, r := range rows {
fmt.Fprintf(&b, " - [%q, %q, %q]\n", r.workflowPath, r.nwo, r.ref)
}
return b.String()
}
Loading
Loading