Сторонние Actions закреплены SHA, права выданы по job, граница проверяется фикстурами

Три вещи, которые аудит 12.09 назвал в §10.

**Перемещаемые ссылки.** `home-assistant/actions/hassfest@master` и
`hacs/action@main` — это произвольный будущий коммит чужой ветки, а ревьюера с
Read/Write/Bash запускал перемещаемый major `anthropics/claude-code-action@v1`.
Все 116 `uses:` в девяти воркфлоу закреплены полным SHA с комментарием-версией;
`scripts/action-pins.mjs` это проверяет, а предполётный вердикт Validate —
исполняет. Локальная переиспользуемая workflow пина не требует и исключена
явно.

**Права.** Один блок `permissions` на весь конвейер выдавал `issues: write` и
OIDC каждой стадии, включая единственную недоверенную — работу модели. Теперь
права выдаются по job: модели только чтение и OIDC для самой
`claude-code-action`, писать в issue умеют детерминированные стадии.

**Граница.** Разбор запечатанного результата переехал из inline-shell в
`scripts/review-result-gate.mjs` — не ради красоты, а потому что в YAML его
нельзя прогнать ни одним отрицательным случаем. Проверяются те же вещи, что и
раньше, и в том же объёме: точный набор файлов, контрольные суммы, схема
паспорта и совпадение КАЖДОГО из семнадцати полей с тем, что посчитала
детерминированная стадия. Сверху — пятнадцать враждебных фикстур: неполный
набор, лишний файл, подменённое содержимое, чужой run и попытка, устаревший
material_sha и tree, чужие задача, этап, раунд и ветка, вердикт вне словаря,
пустой документ, manifest не о тех файлах, неразбираемый JSON.

Настоящих секретов и привилегированных операций фикстуры не трогают.

Issue: #556
User-Visible: no
This commit is contained in:
Codex
2026-09-13 16:24:10 +00:00
committed by claude[bot]
parent ad886b7f3c
commit b35551884f
17 changed files with 680 additions and 162 deletions
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env node
/**
* Все сторонние Actions закреплены полным immutable SHA (#556).
*
* Перемещаемая ссылка в `uses:` — это доверие чужому владельцу тега здесь и
* сейчас, а не коду, который читали. До этой правки конвейер брал
* `home-assistant/actions/hassfest@master` и `hacs/action@main` — то есть
* произвольный будущий коммит чужой ветки, — а ревьюера с Read/Write/Bash
* запускал `anthropics/claude-code-action@v1`, перемещаемый major.
*
* Проверка механическая и потому не врёт: `uses:` обязан быть либо локальным
* (`./.github/…`), либо `<owner>/<repo>[/<path>]@<40 hex>` с комментарием, где
* записана человекочитаемая версия — то, что при обновлении сверяет человек.
*
* node scripts/action-pins.mjs # проверить
* node scripts/action-pins.mjs --list # что и к чему закреплено
*
* Обновление пина: посмотреть, что сейчас стоит за тегом
* `gh api repos/<owner>/<repo>/commits/<tag> -q .sha`, прочитать дельту от
* закреплённого SHA и заменить обе части — SHA и комментарий — одним коммитом.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
export const WORKFLOW_DIR = '.github/workflows';
const USES = /^\s*(?:-\s*)?uses:\s*(\S+)(.*)$/;
/** Локальная переиспользуемая workflow — не сторонний код, пина не требует. */
export const isLocal = (spec) => spec.startsWith('./');
export const isPinned = (spec) => /^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/.test(spec);
/** Комментарий обязателен: без него человек не знает, какую версию он закрепил. */
export const hasVersionNote = (tail) => /#\s*\S/.test(tail);
export function auditWorkflowSource(file, source) {
const problems = [];
source.split('\n').forEach((line, index) => {
const match = USES.exec(line);
if (!match) return;
const [, spec, tail] = match;
const at = `${file}:${index + 1}`;
if (isLocal(spec)) return;
if (!isPinned(spec)) {
problems.push(`${at}: «${spec}» не закреплён полным SHA`);
return;
}
if (!hasVersionNote(tail)) {
problems.push(`${at}: «${spec}» без комментария с версией`);
}
});
return problems;
}
export function listWorkflows(root = ROOT) {
return readdirSync(resolve(root, WORKFLOW_DIR))
.filter((name) => name.endsWith('.yml') || name.endsWith('.yaml'))
.sort();
}
export function auditRepository(root = ROOT) {
const problems = [];
for (const name of listWorkflows(root)) {
const file = `${WORKFLOW_DIR}/${name}`;
problems.push(...auditWorkflowSource(file, readFileSync(resolve(root, file), 'utf8')));
}
return problems;
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
if (process.argv.includes('--list')) {
for (const name of listWorkflows()) {
const file = `${WORKFLOW_DIR}/${name}`;
for (const line of readFileSync(resolve(ROOT, file), 'utf8').split('\n')) {
const match = USES.exec(line);
if (match && !isLocal(match[1])) console.log(`${file}: ${match[1]}${match[2]}`);
}
}
process.exit(0);
}
const problems = auditRepository();
if (problems.length) {
for (const problem of problems) console.error(`::error::${problem}`);
console.error(`не закреплено: ${problems.length}`);
process.exit(1);
}
console.log('все сторонние Actions закреплены полным SHA');
}
+43 -6
View File
@@ -8000,13 +8000,50 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'review-integration-skips-evidence-checksum',
guard: 'node --test --test-name-pattern="#551" test/review-doc-guard.test.mjs',
because: '#551: artifact между моделью и привилегированной интеграцией — вход доверенной '
+ 'стадии; без checksum неполный или подменённый документ можно опубликовать и слить',
guard: 'node --test test/review-result-gate.test.mjs',
because: '#551/#556: artifact между моделью и привилегированной интеграцией — вход '
+ 'доверенной стадии; без сверки контрольных сумм подменённый документ или вердикт '
+ 'публикуется и сливается. Проверка живёт в `review-result-gate.mjs` с тех пор, как '
+ 'её стало можно прогнать враждебными фикстурами',
patches: [{
file: '.github/workflows/process.yml',
find: ' (cd "$dir" && sha256sum -c manifest.sha256)\n test -s "$dir/review-document.md"',
replace: ' # mutant: result contents are trusted\n test -s "$dir/review-document.md"',
file: 'scripts/review-result-gate.mjs',
find: ' if (digest !== row.hash) problems.push(`контрольная сумма не сходится: ${row.name}`);',
replace: ' void digest; // mutant: содержимое artifact принимается на веру',
}],
},
{
id: 'review-gate-accepts-a-foreign-passport',
guard: 'node --test test/review-result-gate.test.mjs',
because: '#556: паспорт — единственное, чем привилегированная стадия отличает свой '
+ 'результат от чужого. Без посполевой сверки чужой run, устаревший material_sha или '
+ 'другой этап проходят как свои',
patches: [{
file: 'scripts/review-result-gate.mjs',
find: " if (String(got) !== String(want)) {",
replace: " if (false && String(got) !== String(want)) {",
}],
},
{
id: 'review-gate-tolerates-an-extra-file',
guard: 'node --test test/review-result-gate.test.mjs',
because: '#556: набор файлов задан точно, а не «не меньше»: лишний файл в artifact — это '
+ 'уже не тот artifact, который запечатала модель',
patches: [{
file: 'scripts/review-result-gate.mjs',
find: " if (actual.join(',') !== wanted.join(',')) {",
replace: " if (wanted.some((name) => !actual.includes(name))) {",
}],
},
{
id: 'action-pins-accept-a-moving-ref',
guard: 'node --test test/action-pins.test.mjs',
because: '#556: перемещаемая ссылка в `uses:` — доверие чужому владельцу тега здесь и '
+ 'сейчас, а не коду, который читали; `@master` и `@main` у hassfest и hacs означали '
+ 'произвольный будущий коммит чужой ветки',
patches: [{
file: 'scripts/action-pins.mjs',
find: "export const isPinned = (spec) => /^[\\w.-]+\\/[\\w./-]+@[0-9a-f]{40}$/.test(spec);",
replace: "export const isPinned = (spec) => /^[\\w.-]+\\/[\\w./-]+@\\S+$/.test(spec);",
}],
},
{
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env node
/**
* Граница доверия между стадией модели и привилегированной публикацией (#556).
*
* `model_review` — единственная недоверенная стадия конвейера: там исполняется
* чужой код с Read/Write/Bash. Всё, что она может передать дальше, — один
* artifact. Публикация разбора, перестановка метки и слияние в `dev` идут в
* другой job, на чистом checkout `dev`, и обязаны принимать этот artifact как
* недоверенный ввод: полный набор файлов, сходящиеся контрольные суммы и
* совпадение КАЖДОГО поля паспорта с тем, что посчитала детерминированная
* стадия `prepare`. Подменённый, неполный, устаревший или чужой результат
* отвергается fail-closed.
*
* Проверка вынесена из inline-shell в функцию именно ради враждебных фикстур:
* в YAML её нельзя прогнать ни одним отрицательным случаем.
*
* node scripts/review-result-gate.mjs --dir=<путь> # поля ожидания из env
*/
import { createHash } from 'node:crypto';
import { readFileSync, readdirSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/** Ровно эти файлы и ни одного больше: лишний файл — это уже чужой artifact. */
export const REQUIRED_FILES = ['manifest.sha256', 'prepared.json', 'review-document.md', 'verdict.json'];
/** Паспорт материала. Каждое поле сверяется с outputs стадии `prepare`. */
export const PASSPORT_FIELDS = [
'run_id', 'run_attempt', 'issue', 'stage', 'cycle', 'branch',
'material_sha', 'material_tree', 'material_specs', 'material_issue_body',
'validate_result', 'validate_url', 'rebase_note', 'validated_note',
'spec_body_changed', 'spec_body_doc', 'spec_body_recorded',
];
export const VERDICTS = ['green', 'yellow', 'red'];
export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
/** Разбор строки `sha256sum`: «<hex> <имя>». */
export function parseManifest(text) {
const rows = [];
for (const line of String(text).split('\n')) {
if (!line.trim()) continue;
const match = /^([0-9a-f]{64})\s[\s*](.+)$/.exec(line);
if (!match) return null;
rows.push({ hash: match[1], name: match[2].trim() });
}
return rows;
}
/**
* @param {object} p
* @param {string[]} p.files имена файлов в каталоге artifact
* @param {(name:string)=>string} p.read содержимое файла
* @param {Record<string,string>} p.expected паспорт, посчитанный `prepare`
* @returns {string[]} причины отказа; пустой массив — принять
*/
export function reviewResultProblems({ files, read, expected }) {
const problems = [];
const actual = [...files].sort();
const wanted = [...REQUIRED_FILES].sort();
if (actual.join(',') !== wanted.join(',')) {
return [`набор файлов не тот: ожидалось ${wanted.join(', ')}, получено ${actual.join(', ') || '(пусто)'}`];
}
const manifest = parseManifest(read('manifest.sha256'));
if (!manifest) return ['manifest.sha256 не разобран'];
const covered = manifest.map((row) => row.name).sort();
const mustCover = REQUIRED_FILES.filter((name) => name !== 'manifest.sha256').sort();
if (covered.join(',') !== mustCover.join(',')) {
problems.push(`manifest покрывает не те файлы: ${covered.join(', ') || '(пусто)'}`);
}
for (const row of manifest) {
if (!REQUIRED_FILES.includes(row.name)) continue;
const digest = sha256(read(row.name));
if (digest !== row.hash) problems.push(`контрольная сумма не сходится: ${row.name}`);
}
if (!String(read('review-document.md')).trim()) problems.push('документ ревью пуст');
let prepared;
try {
prepared = JSON.parse(read('prepared.json'));
} catch (error) {
return [...problems, `prepared.json не разобран: ${error.message}`];
}
if (prepared?.schema !== 1) problems.push('prepared.json: не та схема');
for (const field of PASSPORT_FIELDS) {
const want = expected[field] ?? '';
const got = prepared?.[field] ?? '';
if (String(got) !== String(want)) {
problems.push(`паспорт не совпал: ${field} = «${got}», ожидалось «${want}»`);
}
}
let verdict;
try {
verdict = JSON.parse(read('verdict.json'));
} catch (error) {
return [...problems, `verdict.json не разобран: ${error.message}`];
}
if (!VERDICTS.includes(verdict?.verdict)) problems.push(`verdict вне словаря: ${JSON.stringify(verdict?.verdict)}`);
if (typeof verdict?.high !== 'number') problems.push('verdict.high не число');
if (typeof verdict?.medium !== 'number') problems.push('verdict.medium не число');
if (typeof verdict?.summary !== 'string') problems.push('verdict.summary не строка');
return problems;
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
const dir = process.argv.find((a) => a.startsWith('--dir='))?.slice(6);
if (!dir) { console.error('usage: review-result-gate.mjs --dir=<путь>'); process.exit(2); }
const env = (name) => process.env[name.toUpperCase()] ?? '';
const expected = Object.fromEntries(PASSPORT_FIELDS.map((field) => [field, env(field)]));
expected.run_id = process.env.GITHUB_RUN_ID ?? '';
expected.run_attempt = process.env.GITHUB_RUN_ATTEMPT ?? '';
const problems = reviewResultProblems({
files: readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name),
read: (name) => readFileSync(resolve(dir, name), 'utf8'),
expected,
});
if (problems.length) {
for (const problem of problems) console.error(`::error::результат модели отвергнут — ${problem}`);
process.exit(1);
}
console.log('результат модели полон, суммы сходятся, паспорт совпал');
}