Skip to content
Merged
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
15 changes: 11 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]

permissions:
contents: read

jobs:
test-node:
name: Node ${{ matrix.node-version }} (${{ matrix.os }})
Expand All @@ -17,9 +20,11 @@ jobs:
node-version: ["22", "23"]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false

- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ matrix.node-version }}

Expand Down Expand Up @@ -47,9 +52,11 @@ jobs:
os: [ubuntu-latest, macos-latest]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false

- uses: oven-sh/setup-bun@v2
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest

Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/nix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ name: Nix
on:
workflow_call:

permissions:
contents: read

jobs:
build-flake:
name: Build flake (${{ matrix.os }})
Expand All @@ -13,9 +16,11 @@ jobs:
os: [ubuntu-latest, macos-latest]

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
persist-credentials: false

- uses: cachix/install-nix-action@v31
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31
with:
extra_nix_config: |
experimental-features = nix-command flakes
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,5 @@
- Extract a shared package only after two concrete consumers need the same stable interface. Keep shared packages product-neutral and keep indexing, embeddings, ranking, and CLI behavior here.
- Freeze shared interfaces before parallel lanes begin. Give manifests, lockfiles, generated files, native compatibility pins, and other convergence surfaces one owner while lanes edit disjoint paths.
- Keep QMD-specific skills under `skills/`; `.agents/skills/` contains the portable repository baseline.
- Do not change package manifests or locks for KB tooling. Run `bunx --bun github:hraness/kb#v0.15.1 refresh --root kb`, `bunx --bun github:hraness/kb#v0.15.1 check --root kb`, and `bunx --bun github:hraness/kb#v0.15.1 agents check --root kb --repo .` directly.
- Do not change package manifests or locks for KB tooling. Run `bunx --bun github:hraness/kb#v0.15.2 refresh --root kb`, `bunx --bun github:hraness/kb#v0.15.2 check --root kb`, and `bunx --bun github:hraness/kb#v0.15.2 agents check --root kb --repo .` directly.
- Run the documented Node and Bun test paths and package smoke before handing off source changes.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## [Unreleased]

### Security

- GitHub Actions workflows now pin every remote action to a reviewed full
commit, run with explicit read-only permissions without retained checkout
credentials, and reject mutable or dynamic action references. Repository KB
checks use the immutable 0.15.2 tool release.

### Fixed

- Embedding generation and legacy fingerprint adoption now tokenize documents
Expand Down
7 changes: 3 additions & 4 deletions kb/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ Git history is the maintenance log. Do not add generated backlink sections or a
Run the pinned KB tools directly without changing this upstream-derived repository's package manifests:

```sh
bunx --bun github:hraness/kb#v0.15.1 refresh --root kb
bunx --bun github:hraness/kb#v0.15.1 check --root kb
bunx --bun github:hraness/kb#v0.15.1 agents check --root kb --repo .
bunx --bun github:hraness/kb#v0.15.2 refresh --root kb
bunx --bun github:hraness/kb#v0.15.2 check --root kb
bunx --bun github:hraness/kb#v0.15.2 agents check --root kb --repo .
```

230 changes: 230 additions & 0 deletions test/workflow-actions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { describe, expect, test } from "vitest";
import { lstatSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseDocument } from "yaml";

const repoRoot = fileURLToPath(new URL("..", import.meta.url));
const workflowsRoot = join(repoRoot, ".github", "workflows");
const workflowPathPattern = /^\.\/\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/u;
const commitPattern = /^[0-9a-f]{40}$/u;
const remoteSegmentPattern = /^[A-Za-z0-9_.-]+$/u;

type JsonObject = Record<string, unknown>;

function object(value: unknown, label: string): JsonObject {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new TypeError(`${label} must be an object.`);
}
return value as JsonObject;
}

function parseWorkflow(source: string, label: string): JsonObject {
const document = parseDocument(source, { prettyErrors: true, uniqueKeys: true });
if (document.errors.length > 0) {
throw new TypeError(`${label} is not valid unambiguous YAML: ${document.errors[0]?.message ?? "unknown error"}`);
}
return object(document.toJS(), label);
}

function assertPinnedRemoteAction(value: string, label: string): void {
const separator = value.lastIndexOf("@");
if (separator <= 0 || !commitPattern.test(value.slice(separator + 1))) {
throw new TypeError(`${label} must use a full lowercase 40-character commit.`);
}
const segments = value.slice(0, separator).split("/");
if (
segments.length < 2
|| segments.some((segment) =>
segment === "." || segment === ".." || !remoteSegmentPattern.test(segment))
) {
throw new TypeError(`${label} has an invalid remote action path.`);
}
}

function semanticActionReferences(
workflows: ReadonlyMap<string, string>,
): readonly string[] {
const references: string[] = [];
for (const [path, source] of workflows) {
const root = parseWorkflow(source, path);
const jobs = object(root.jobs, `${path} jobs`);
for (const [jobName, jobValue] of Object.entries(jobs)) {
const job = object(jobValue, `${path} job ${jobName}`);
const reusable = job.uses;
if (reusable !== undefined) {
if (typeof reusable !== "string") {
throw new TypeError(`${path} job ${jobName} uses must be a string.`);
}
if (reusable.startsWith("./")) {
if (!workflowPathPattern.test(reusable) || !workflows.has(reusable)) {
throw new TypeError(`${path} job ${jobName} references an unknown local workflow.`);
}
} else {
assertPinnedRemoteAction(reusable, `${path} job ${jobName}`);
references.push(reusable);
}
}

const steps = job.steps;
if (steps === undefined) continue;
if (!Array.isArray(steps)) {
throw new TypeError(`${path} job ${jobName} steps must be an array.`);
}
for (const [stepIndex, stepValue] of steps.entries()) {
const step = object(stepValue, `${path} job ${jobName} step ${String(stepIndex)}`);
const action = step.uses;
if (action === undefined) continue;
if (typeof action !== "string") {
throw new TypeError(`${path} job ${jobName} step ${String(stepIndex)} uses must be a string.`);
}
if (action.startsWith("./")) {
throw new TypeError(`${path} job ${jobName} step ${String(stepIndex)} uses an unreviewed local action.`);
}
assertPinnedRemoteAction(action, `${path} job ${jobName} step ${String(stepIndex)}`);
references.push(action);
}
}
}
if (references.length === 0) {
throw new TypeError("At least one pinned remote action must be present.");
}
return references.toSorted();
}

function assertWorkflowCredentialBoundary(
workflows: ReadonlyMap<string, string>,
): void {
for (const [path, source] of workflows) {
const root = parseWorkflow(source, path);
const permissions = object(root.permissions, `${path} permissions`);
if (
Object.keys(permissions).length !== 1
|| permissions.contents !== "read"
) {
throw new TypeError(`${path} must declare only contents: read permissions.`);
}
const jobs = object(root.jobs, `${path} jobs`);
for (const [jobName, jobValue] of Object.entries(jobs)) {
const job = object(jobValue, `${path} job ${jobName}`);
if (job.permissions !== undefined) {
throw new TypeError(`${path} job ${jobName} must not override workflow permissions.`);
}
if (!Array.isArray(job.steps)) continue;
for (const [stepIndex, stepValue] of job.steps.entries()) {
const step = object(stepValue, `${path} job ${jobName} step ${String(stepIndex)}`);
if (
typeof step.uses !== "string"
|| !step.uses.toLowerCase().startsWith("actions/checkout@")
) {
continue;
}
const inputs = object(step.with, `${path} checkout inputs`);
if (inputs["persist-credentials"] !== false) {
throw new TypeError(`${path} checkout must disable persisted credentials.`);
}
}
}
}
}

function repositoryWorkflows(): ReadonlyMap<string, string> {
const workflows = new Map<string, string>();
for (const entry of readdirSync(workflowsRoot, { withFileTypes: true })) {
if (!/\.ya?ml$/u.test(entry.name)) continue;
const absolutePath = join(workflowsRoot, entry.name);
const stats = lstatSync(absolutePath);
if (
!entry.isFile()
|| !stats.isFile()
|| stats.isSymbolicLink()
|| stats.size > 1_048_576
) {
throw new TypeError(`${entry.name} must be a bounded regular workflow file.`);
}
workflows.set(`./.github/workflows/${entry.name}`, readFileSync(absolutePath, "utf8"));
}
return workflows;
}

describe("GitHub workflow action supply chain", () => {
test("pins every semantic remote action reference to a full commit", () => {
const workflows = repositoryWorkflows();
expect(semanticActionReferences(workflows)).toEqual([
"actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
"actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
"actions/checkout@11d5960a326750d5838078e36cf38b85af677262",
"actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020",
"cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24",
"oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6",
]);
expect(() => assertWorkflowCredentialBoundary(workflows)).not.toThrow();
});

test("rejects mutable, dynamic, and non-string semantic action references", () => {
for (const uses of ["actions/checkout@v4", "actions/checkout@${{ github.ref }}"] as const) {
expect(() => semanticActionReferences(new Map([
["./.github/workflows/ci.yml", `jobs:\n test:\n steps:\n - uses: ${uses}\n`],
]))).toThrow(/full lowercase 40-character commit/u);
}
expect(() => semanticActionReferences(new Map([
["./.github/workflows/ci.yml", "jobs:\n test:\n steps:\n - uses: 42\n"],
]))).toThrow(/uses must be a string/u);
});

test("requires every case-equivalent checkout step to discard persisted credentials", () => {
const commit = "1".repeat(40);
const workflow = (uses: string, inputs: string) => new Map([
[
"./.github/workflows/ci.yml",
`permissions:\n contents: read\njobs:\n test:\n steps:\n - uses: ${uses}\n${inputs}`,
],
]);

for (const [uses, inputs] of [
[`actions/checkout@${commit}`, ""],
[`Actions/Checkout@${commit}`, " with:\n persist-credentials: \"false\"\n"],
[`ACTIONS/CHECKOUT@${commit}`, " with:\n fetch-depth: 1\n"],
] as const) {
expect(() => assertWorkflowCredentialBoundary(workflow(uses, inputs))).toThrow(
/checkout inputs must be an object|disable persisted credentials/u,
);
}

expect(() => assertWorkflowCredentialBoundary(workflow(
`Actions/Checkout@${commit}`,
" with:\n persist-credentials: false\n",
))).not.toThrow();
});

test("checks local reusable workflows but ignores unrelated uses data", () => {
const commit = "1".repeat(40);
const workflows = new Map([
[
"./.github/workflows/ci.yml",
[
"env:",
" uses: unrelated-data",
"jobs:",
" nix:",
" uses: ./.github/workflows/nix.yml",
"",
].join("\n"),
],
[
"./.github/workflows/nix.yml",
`jobs:\n build:\n steps:\n - uses: actions/checkout@${commit}\n`,
],
]);
expect(semanticActionReferences(workflows)).toEqual([`actions/checkout@${commit}`]);

workflows.delete("./.github/workflows/nix.yml");
expect(() => semanticActionReferences(workflows)).toThrow(/unknown local workflow/u);
});

test("rejects a vacuous workflow set even when unrelated data contains uses", () => {
expect(() => semanticActionReferences(new Map([
["./.github/workflows/ci.yml", "env:\n uses: actions/checkout@v4\njobs:\n test:\n steps:\n - run: true\n"],
]))).toThrow(/At least one pinned remote action/u);
});
});
Loading