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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ concurrency:
cancel-in-progress: true

jobs:
supply-chain:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Enforce dependency pins
run: node scripts/check-dependency-pins.mjs

changes:
runs-on: ubuntu-latest
permissions:
Expand Down
13 changes: 13 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,16 @@ Live destructive MCP tools (`check_once`, `force_update`, `update_hosts`,
Tokens, passwords, `Authorization` headers, usernames, and OAuth client IDs
are redacted from log context and history messages. Prefer provider-specific
env vars over putting secrets in URLs.

## Dependency pinning

Supply-chain inputs are immutable in CI and release builds:

- Third-party GitHub Actions use full 40-character commit SHAs.
- Docker base images and container actions use SHA-256 digests.
- pnpm dependency resolutions carry SHA-256 or SHA-512 integrity values, and
workflow installs use `--frozen-lockfile`.

Run `pnpm run deps:check` locally to validate the policy. The `supply-chain`
CI job runs on every pull request and push to `main`, independent of path
filters, and is a required branch-protection check.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,11 @@
"config:check": "node --env-file-if-exists=.env dist/app.js --check-config",
"scaffold:provider": "node scripts/scaffold-provider.mjs",
"docs:check": "vp test tests/docs.test.ts",
"deps:check": "node scripts/check-dependency-pins.mjs",
"lean:check": "fallow dead-code --fail-on-issues",
"audit": "fallow audit",
"deadcode": "fallow dead-code",
"verify": "vp check && node scripts/test-parallel.mjs && vp run build && fallow dead-code --fail-on-issues",
"verify": "vp check && node scripts/check-dependency-pins.mjs && node scripts/test-parallel.mjs && vp run build && fallow dead-code --fail-on-issues",
"fmt": "vp fmt",
"lint": "vp lint",
"prepare": "vp config"
Expand Down
108 changes: 108 additions & 0 deletions scripts/check-dependency-pins.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env node

import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';

const root = path.resolve(process.argv[2] ?? process.cwd());
const failures = [];

function fail(file, line, message) {
failures.push(`${path.relative(root, file)}:${line}: ${message}`);
}

async function filesBelow(directory) {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
const nested = await Promise.all(
entries.map((entry) => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? filesBelow(target) : [target];
}),
);
return nested.flat();
}

async function checkActions() {
const files = [
...(await filesBelow(path.join(root, '.github', 'workflows'))),
...(await filesBelow(path.join(root, '.github', 'actions'))),
].filter((file) => /\.ya?ml$/u.test(file));

for (const file of files) {
const lines = (await readFile(file, 'utf8')).split('\n');
lines.forEach((line, index) => {
const reference = line.match(/^\s*uses:\s*([^\s#]+)/u)?.[1];
if (!reference || reference.startsWith('./')) return;
if (reference.startsWith('docker://')) {
if (!/@sha256:[a-f0-9]{64}$/u.test(reference)) {
fail(file, index + 1, `container action is not pinned by digest: ${reference}`);
Comment on lines +34 to +38
}
return;
}
if (!/@[a-f0-9]{40}$/u.test(reference)) {
fail(file, index + 1, `action is not pinned to a full commit SHA: ${reference}`);
}
});
}
}

async function checkDockerfile() {
const file = path.join(root, 'Dockerfile');
const lines = (await readFile(file, 'utf8')).split('\n');
const stages = new Set();

for (const line of lines) {
const stage = line.match(/^\s*FROM\s+\S+(?:\s+AS\s+(\S+))?/iu)?.[1];
if (stage) stages.add(stage);
Comment on lines +55 to +56
}

lines.forEach((line, index) => {
const match = line.match(/^\s*FROM\s+(?:--\S+\s+)*(\S+)/iu);
if (!match) return;
const image = match[1];
if (image === 'scratch' || stages.has(image)) return;
if (!/@sha256:[a-f0-9]{64}$/u.test(image)) {
fail(file, index + 1, `base image is not pinned by digest: ${image}`);
}
});
}

async function checkLockfile() {
const file = path.join(root, 'pnpm-lock.yaml');
const lines = (await readFile(file, 'utf8')).split('\n');
let resolutionCount = 0;

for (let index = 0; index < lines.length; index += 1) {
if (!/^\s{4}resolution:/u.test(lines[index])) continue;
resolutionCount += 1;
let end = index + 1;
while (end < lines.length && !/^\s{4}\S/u.test(lines[end])) end += 1;
const block = lines.slice(index, end).join('\n');
if (!/integrity:\s+sha(?:256|512)-[A-Za-z0-9+/=]+/u.test(block)) {
fail(file, index + 1, 'dependency resolution has no SHA integrity value');
}
}

if (resolutionCount === 0) fail(file, 1, 'no dependency resolutions found');
}

async function checkFrozenInstalls() {
const files = await filesBelow(path.join(root, '.github', 'workflows'));
for (const file of files.filter((entry) => /\.ya?ml$/u.test(entry))) {
const lines = (await readFile(file, 'utf8')).split('\n');
lines.forEach((line, index) => {
if (/\bpnpm install\b/u.test(line) && !/--frozen-lockfile\b/u.test(line)) {
fail(file, index + 1, 'CI dependency install must use --frozen-lockfile');
}
Comment on lines +94 to +96
});
}
}

await Promise.all([checkActions(), checkDockerfile(), checkLockfile(), checkFrozenInstalls()]);

if (failures.length > 0) {
console.error(`Dependency pin policy failed:\n${failures.map((item) => `- ${item}`).join('\n')}`);
process.exitCode = 1;
} else {
console.log('Dependency pin policy passed.');
}