mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
Сторонние 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:
@@ -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');
|
||||
}
|
||||
@@ -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);",
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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('результат модели полон, суммы сходятся, паспорт совпал');
|
||||
}
|
||||
Reference in New Issue
Block a user