git-tag-guardian 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.guardian.yml +38 -0
- package/README.md +280 -0
- package/bin/guardian.js +408 -0
- package/package.json +44 -0
- package/src/checks/actions-pinning.js +136 -0
- package/src/checks/postinstall-hooks.js +170 -0
- package/src/checks/sha-pinning.js +172 -0
- package/src/checks/tag-integrity.js +229 -0
- package/src/checks/tarball-integrity.js +117 -0
- package/src/index.js +172 -0
- package/src/utils/baseline.js +85 -0
- package/src/utils/config.js +147 -0
- package/src/utils/logger.js +28 -0
package/bin/guardian.js
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* git-tag-guardian CLI
|
|
5
|
+
*
|
|
6
|
+
* Команды:
|
|
7
|
+
* audit — полный аудит проекта
|
|
8
|
+
* baseline — создать/обновить baseline SHA
|
|
9
|
+
* verify — быстрая проверка (SHA drift + postinstall)
|
|
10
|
+
* hooks-install — установить git hooks (pre-commit, post-merge)
|
|
11
|
+
* help — справка
|
|
12
|
+
*
|
|
13
|
+
* Примеры:
|
|
14
|
+
* npx git-tag-guardian audit
|
|
15
|
+
* npx git-tag-guardian audit --ci
|
|
16
|
+
* npx git-tag-guardian baseline
|
|
17
|
+
* GITHUB_TOKEN=ghp_xxx npx git-tag-guardian audit
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { runAudit, checkSHAPinning, checkPostinstallHooks, BaselineManager } from '../src/index.js';
|
|
21
|
+
import { loadConfig } from '../src/utils/config.js';
|
|
22
|
+
import { logger } from '../src/utils/logger.js';
|
|
23
|
+
import { readFile, writeFile, mkdir, chmod } from 'node:fs/promises';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
|
|
27
|
+
const args = process.argv.slice(2);
|
|
28
|
+
const command = args[0] || 'help';
|
|
29
|
+
const flags = parseFlags(args.slice(1));
|
|
30
|
+
|
|
31
|
+
async function main() {
|
|
32
|
+
switch (command) {
|
|
33
|
+
case 'audit':
|
|
34
|
+
return await cmdAudit();
|
|
35
|
+
case 'baseline':
|
|
36
|
+
return await cmdBaseline();
|
|
37
|
+
case 'verify':
|
|
38
|
+
return await cmdVerify();
|
|
39
|
+
case 'hooks-install':
|
|
40
|
+
return await cmdHooksInstall();
|
|
41
|
+
case 'help':
|
|
42
|
+
case '--help':
|
|
43
|
+
case '-h':
|
|
44
|
+
return cmdHelp();
|
|
45
|
+
default:
|
|
46
|
+
logger.error(`Неизвестная команда: ${command}`);
|
|
47
|
+
cmdHelp();
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── audit ──────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
async function cmdAudit() {
|
|
55
|
+
const cwd = flags.cwd || process.cwd();
|
|
56
|
+
const report = await runAudit({
|
|
57
|
+
cwd,
|
|
58
|
+
githubToken: flags.token || process.env.GITHUB_TOKEN,
|
|
59
|
+
ci: flags.ci || !!process.env.CI,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// В CI-режиме — exit code
|
|
63
|
+
if (flags.ci || process.env.CI) {
|
|
64
|
+
if (!report.pass) {
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Сохранение отчёта в JSON
|
|
70
|
+
if (flags.output) {
|
|
71
|
+
await writeFile(flags.output, JSON.stringify(report, null, 2), 'utf8');
|
|
72
|
+
logger.info(`Отчёт сохранён: ${flags.output}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── baseline ───────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
async function cmdBaseline() {
|
|
79
|
+
const cwd = flags.cwd || process.cwd();
|
|
80
|
+
const config = await loadConfig(cwd);
|
|
81
|
+
|
|
82
|
+
logger.info('git-tag-guardian — создание baseline');
|
|
83
|
+
logger.info('─'.repeat(60));
|
|
84
|
+
|
|
85
|
+
const baseline = new BaselineManager(cwd, config.baselineFile);
|
|
86
|
+
await baseline.load();
|
|
87
|
+
|
|
88
|
+
// 1. Записываем SHA из package-lock.json
|
|
89
|
+
let lockfile;
|
|
90
|
+
try {
|
|
91
|
+
lockfile = JSON.parse(await readFile(join(cwd, 'package-lock.json'), 'utf8'));
|
|
92
|
+
} catch {
|
|
93
|
+
logger.warn('package-lock.json не найден');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (lockfile?.packages) {
|
|
97
|
+
let count = 0;
|
|
98
|
+
for (const [pkgPath, meta] of Object.entries(lockfile.packages)) {
|
|
99
|
+
if (!meta.resolved || !meta.resolved.includes('git')) continue;
|
|
100
|
+
const sha = extractSHA(meta.resolved);
|
|
101
|
+
if (!sha) continue;
|
|
102
|
+
|
|
103
|
+
const pkgName = pkgPath.replace('node_modules/', '');
|
|
104
|
+
if (!pkgName) continue;
|
|
105
|
+
|
|
106
|
+
baseline.setDependency(pkgName, {
|
|
107
|
+
sha,
|
|
108
|
+
resolved: meta.resolved,
|
|
109
|
+
version: meta.version,
|
|
110
|
+
});
|
|
111
|
+
count++;
|
|
112
|
+
logger.info(` dep: ${pkgName} → ${sha.slice(0, 12)}...`);
|
|
113
|
+
}
|
|
114
|
+
logger.info(` Зависимостей записано: ${count}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Записываем SHA тегов через GitHub API (если есть токен)
|
|
118
|
+
const token = flags.token || process.env.GITHUB_TOKEN;
|
|
119
|
+
if (token) {
|
|
120
|
+
let pkgJson;
|
|
121
|
+
try {
|
|
122
|
+
pkgJson = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
|
|
123
|
+
} catch { /* skip */ }
|
|
124
|
+
|
|
125
|
+
if (pkgJson) {
|
|
126
|
+
const allDeps = {
|
|
127
|
+
...pkgJson.dependencies,
|
|
128
|
+
...pkgJson.devDependencies,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (const [name, spec] of Object.entries(allDeps)) {
|
|
132
|
+
const info = parseGitHubRef(spec);
|
|
133
|
+
if (!info || !info.tag) continue;
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const sha = await fetchTagSHA(info.owner, info.repo, info.tag, token);
|
|
137
|
+
if (sha) {
|
|
138
|
+
const repoTag = `${info.owner}/${info.repo}#${info.tag}`;
|
|
139
|
+
baseline.setTag(repoTag, { sha, tag: info.tag });
|
|
140
|
+
logger.info(` tag: ${repoTag} → ${sha.slice(0, 12)}...`);
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
logger.warn(` tag: ${info.owner}/${info.repo}#${info.tag} — ошибка: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. Записываем хеши tarball
|
|
150
|
+
{
|
|
151
|
+
let pkgJson;
|
|
152
|
+
try {
|
|
153
|
+
pkgJson = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
|
|
154
|
+
} catch { /* skip */ }
|
|
155
|
+
|
|
156
|
+
if (pkgJson) {
|
|
157
|
+
const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
|
|
158
|
+
for (const [name, spec] of Object.entries(allDeps)) {
|
|
159
|
+
const url = buildTarballUrl(spec);
|
|
160
|
+
if (!url) continue;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch(url, {
|
|
164
|
+
headers: { 'User-Agent': 'git-tag-guardian/1.0' },
|
|
165
|
+
redirect: 'follow',
|
|
166
|
+
});
|
|
167
|
+
if (!res.ok) continue;
|
|
168
|
+
|
|
169
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
170
|
+
const sha256 = createHash('sha256').update(buffer).digest('hex');
|
|
171
|
+
|
|
172
|
+
baseline.setTarball(url, { sha256, size: buffer.length });
|
|
173
|
+
logger.info(` tarball: ${name} → ${sha256.slice(0, 16)}... (${buffer.length} bytes)`);
|
|
174
|
+
} catch { /* skip */ }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await baseline.save();
|
|
180
|
+
logger.success('Baseline создан! Добавьте его в git:');
|
|
181
|
+
logger.info(` git add ${config.baselineFile}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── verify ─────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
async function cmdVerify() {
|
|
187
|
+
const cwd = flags.cwd || process.cwd();
|
|
188
|
+
const config = await loadConfig(cwd);
|
|
189
|
+
|
|
190
|
+
logger.info('git-tag-guardian — быстрая проверка');
|
|
191
|
+
logger.info('─'.repeat(60));
|
|
192
|
+
|
|
193
|
+
const findings = [];
|
|
194
|
+
|
|
195
|
+
const sha = await checkSHAPinning(cwd, config);
|
|
196
|
+
findings.push(...sha);
|
|
197
|
+
|
|
198
|
+
const hooks = await checkPostinstallHooks(cwd, config);
|
|
199
|
+
findings.push(...hooks);
|
|
200
|
+
|
|
201
|
+
const critical = findings.filter(f => f.severity === 'critical');
|
|
202
|
+
const high = findings.filter(f => f.severity === 'high');
|
|
203
|
+
|
|
204
|
+
if (critical.length || high.length) {
|
|
205
|
+
logger.error(`\nОбнаружено проблем: ${critical.length} critical, ${high.length} high`);
|
|
206
|
+
for (const f of [...critical, ...high]) {
|
|
207
|
+
logger.error(` [${f.severity.toUpperCase()}] ${f.message}`);
|
|
208
|
+
}
|
|
209
|
+
if (flags.ci || process.env.CI) process.exit(1);
|
|
210
|
+
} else {
|
|
211
|
+
logger.success('\nБыстрая проверка пройдена!');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── hooks-install ──────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
async function cmdHooksInstall() {
|
|
218
|
+
const cwd = flags.cwd || process.cwd();
|
|
219
|
+
const hooksDir = join(cwd, '.git', 'hooks');
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
await mkdir(hooksDir, { recursive: true });
|
|
223
|
+
} catch { /* exists */ }
|
|
224
|
+
|
|
225
|
+
// pre-commit hook
|
|
226
|
+
const preCommit = `#!/bin/sh
|
|
227
|
+
# git-tag-guardian: pre-commit hook
|
|
228
|
+
# Проверяет SHA drift и postinstall hooks перед коммитом
|
|
229
|
+
|
|
230
|
+
echo "[git-tag-guardian] Проверка безопасности зависимостей..."
|
|
231
|
+
|
|
232
|
+
# Проверяем, изменились ли файлы зависимостей
|
|
233
|
+
CHANGED_FILES=$(git diff --cached --name-only)
|
|
234
|
+
NEEDS_CHECK=false
|
|
235
|
+
|
|
236
|
+
for f in $CHANGED_FILES; do
|
|
237
|
+
case "$f" in
|
|
238
|
+
package.json|package-lock.json|bun.lockb|yarn.lock)
|
|
239
|
+
NEEDS_CHECK=true
|
|
240
|
+
;;
|
|
241
|
+
esac
|
|
242
|
+
done
|
|
243
|
+
|
|
244
|
+
if [ "$NEEDS_CHECK" = "true" ]; then
|
|
245
|
+
npx git-tag-guardian verify 2>&1
|
|
246
|
+
if [ $? -ne 0 ]; then
|
|
247
|
+
echo ""
|
|
248
|
+
echo "[git-tag-guardian] BLOCKED: обнаружены проблемы безопасности!"
|
|
249
|
+
echo "[git-tag-guardian] Запустите 'npx git-tag-guardian audit' для деталей."
|
|
250
|
+
exit 1
|
|
251
|
+
fi
|
|
252
|
+
fi
|
|
253
|
+
|
|
254
|
+
echo "[git-tag-guardian] OK"
|
|
255
|
+
`;
|
|
256
|
+
|
|
257
|
+
// post-merge hook (после git pull)
|
|
258
|
+
const postMerge = `#!/bin/sh
|
|
259
|
+
# git-tag-guardian: post-merge hook
|
|
260
|
+
# Автоматическая проверка после git pull / merge
|
|
261
|
+
|
|
262
|
+
echo "[git-tag-guardian] Проверка после merge..."
|
|
263
|
+
|
|
264
|
+
# Проверяем, изменились ли файлы зависимостей
|
|
265
|
+
CHANGED=$(git diff-tree -r --name-only ORIG_HEAD HEAD 2>/dev/null)
|
|
266
|
+
|
|
267
|
+
for f in $CHANGED; do
|
|
268
|
+
case "$f" in
|
|
269
|
+
package.json|package-lock.json)
|
|
270
|
+
echo "[git-tag-guardian] Зависимости изменились — запускаю проверку..."
|
|
271
|
+
npx git-tag-guardian verify 2>&1
|
|
272
|
+
exit 0
|
|
273
|
+
;;
|
|
274
|
+
esac
|
|
275
|
+
done
|
|
276
|
+
`;
|
|
277
|
+
|
|
278
|
+
await writeFile(join(hooksDir, 'pre-commit'), preCommit, 'utf8');
|
|
279
|
+
await chmod(join(hooksDir, 'pre-commit'), 0o755);
|
|
280
|
+
logger.success('Установлен: .git/hooks/pre-commit');
|
|
281
|
+
|
|
282
|
+
await writeFile(join(hooksDir, 'post-merge'), postMerge, 'utf8');
|
|
283
|
+
await chmod(join(hooksDir, 'post-merge'), 0o755);
|
|
284
|
+
logger.success('Установлен: .git/hooks/post-merge');
|
|
285
|
+
|
|
286
|
+
logger.info('');
|
|
287
|
+
logger.info('Git hooks установлены! Теперь при каждом коммите и pull');
|
|
288
|
+
logger.info('будет автоматическая проверка безопасности зависимостей.');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ── help ───────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
function cmdHelp() {
|
|
294
|
+
console.log(`
|
|
295
|
+
git-tag-guardian v1.0.0 — защита от supply chain атак
|
|
296
|
+
|
|
297
|
+
КОМАНДЫ:
|
|
298
|
+
audit Полный аудит проекта (все 5 проверок)
|
|
299
|
+
baseline Создать/обновить baseline SHA зависимостей
|
|
300
|
+
verify Быстрая проверка (SHA drift + postinstall hooks)
|
|
301
|
+
hooks-install Установить git hooks (pre-commit, post-merge)
|
|
302
|
+
help Показать эту справку
|
|
303
|
+
|
|
304
|
+
ФЛАГИ:
|
|
305
|
+
--ci CI-режим (exit 1 при обнаружении проблем)
|
|
306
|
+
--cwd <path> Корень проекта (по умолчанию: текущая директория)
|
|
307
|
+
--token <tok> GitHub PAT для API-проверок (или GITHUB_TOKEN env)
|
|
308
|
+
--output <file> Сохранить отчёт в JSON файл
|
|
309
|
+
--no-color Отключить цвета
|
|
310
|
+
|
|
311
|
+
ПРОВЕРКИ:
|
|
312
|
+
1. SHA Pinning — git-зависимости должны использовать SHA, не теги
|
|
313
|
+
2. Postinstall Hooks — обнаружение lifecycle-скриптов в зависимостях
|
|
314
|
+
3. Tag Integrity — верификация SHA тегов через GitHub API
|
|
315
|
+
4. Tarball Integrity — проверка .tar.gz хешей
|
|
316
|
+
5. Actions Pinning — GitHub Actions должны использовать SHA
|
|
317
|
+
|
|
318
|
+
ПРИМЕРЫ:
|
|
319
|
+
npx git-tag-guardian audit
|
|
320
|
+
npx git-tag-guardian audit --ci --token ghp_xxx
|
|
321
|
+
npx git-tag-guardian baseline
|
|
322
|
+
npx git-tag-guardian verify
|
|
323
|
+
npx git-tag-guardian hooks-install
|
|
324
|
+
|
|
325
|
+
КОНФИГУРАЦИЯ:
|
|
326
|
+
Создайте .guardian.yml в корне проекта (опционально):
|
|
327
|
+
|
|
328
|
+
checks:
|
|
329
|
+
shaPinning: true
|
|
330
|
+
postinstallHooks: true
|
|
331
|
+
tagIntegrity: true
|
|
332
|
+
tarballIntegrity: true
|
|
333
|
+
actionsPinning: true
|
|
334
|
+
|
|
335
|
+
allowedLifecyclePackages:
|
|
336
|
+
- electron
|
|
337
|
+
- sharp
|
|
338
|
+
|
|
339
|
+
trustedActions:
|
|
340
|
+
- actions/*
|
|
341
|
+
`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── Utils ──────────────────────────────────────────────────────
|
|
345
|
+
|
|
346
|
+
function parseFlags(args) {
|
|
347
|
+
const flags = {};
|
|
348
|
+
for (let i = 0; i < args.length; i++) {
|
|
349
|
+
const arg = args[i];
|
|
350
|
+
if (arg === '--ci') flags.ci = true;
|
|
351
|
+
else if (arg === '--no-color') process.env.NO_COLOR = '1';
|
|
352
|
+
else if (arg === '--cwd' && args[i + 1]) flags.cwd = args[++i];
|
|
353
|
+
else if (arg === '--token' && args[i + 1]) flags.token = args[++i];
|
|
354
|
+
else if (arg === '--output' && args[i + 1]) flags.output = args[++i];
|
|
355
|
+
}
|
|
356
|
+
return flags;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function extractSHA(resolved) {
|
|
360
|
+
if (!resolved) return null;
|
|
361
|
+
const idx = resolved.lastIndexOf('#');
|
|
362
|
+
if (idx === -1) return null;
|
|
363
|
+
const sha = resolved.slice(idx + 1);
|
|
364
|
+
return /^[0-9a-f]{7,40}$/.test(sha) ? sha : null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function parseGitHubRef(spec) {
|
|
368
|
+
if (!spec || typeof spec !== 'string') return null;
|
|
369
|
+
let rest = spec;
|
|
370
|
+
if (rest.startsWith('github:')) rest = rest.slice(7);
|
|
371
|
+
const match = rest.match(/^([^/]+)\/([^/#]+)(?:#(.+))?$/);
|
|
372
|
+
if (!match) return null;
|
|
373
|
+
const ref = match[3];
|
|
374
|
+
if (ref && /^[0-9a-f]{40}$/.test(ref)) return null; // SHA pinned
|
|
375
|
+
return { owner: match[1], repo: match[2], tag: ref };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function fetchTagSHA(owner, repo, tag, token) {
|
|
379
|
+
const url = `https://api.github.com/repos/${owner}/${repo}/git/refs/tags/${tag}`;
|
|
380
|
+
const res = await fetch(url, {
|
|
381
|
+
headers: {
|
|
382
|
+
'Authorization': `token ${token}`,
|
|
383
|
+
'Accept': 'application/vnd.github+json',
|
|
384
|
+
'User-Agent': 'git-tag-guardian/1.0',
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
if (!res.ok) return null;
|
|
388
|
+
const data = await res.json();
|
|
389
|
+
return data.object?.sha || null;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function buildTarballUrl(spec) {
|
|
393
|
+
if (!spec || typeof spec !== 'string') return null;
|
|
394
|
+
let rest = spec;
|
|
395
|
+
if (rest.startsWith('github:')) rest = rest.slice(7);
|
|
396
|
+
else return null;
|
|
397
|
+
const hashIdx = rest.indexOf('#');
|
|
398
|
+
if (hashIdx === -1) return null;
|
|
399
|
+
const ownerRepo = rest.slice(0, hashIdx);
|
|
400
|
+
const ref = rest.slice(hashIdx + 1);
|
|
401
|
+
if (/^[0-9a-f]{40}$/.test(ref)) return null;
|
|
402
|
+
return `https://github.com/${ownerRepo}/archive/refs/tags/${ref}.tar.gz`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
main().catch(err => {
|
|
406
|
+
logger.error(`Ошибка: ${err.message}`);
|
|
407
|
+
process.exit(2);
|
|
408
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "git-tag-guardian",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-dependency supply chain defense for Node.js/Bun — detects git tag rewrite attacks, postinstall backdoors, SHA drift, tarball tampering and unpinned GitHub Actions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"git-tag-guardian": "./bin/guardian.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./src/index.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"audit": "node ./bin/guardian.js audit",
|
|
12
|
+
"baseline": "node ./bin/guardian.js baseline",
|
|
13
|
+
"verify": "node ./bin/guardian.js verify",
|
|
14
|
+
"hooks:install": "node ./bin/guardian.js hooks-install",
|
|
15
|
+
"test": "node --test ./tests/"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"security",
|
|
19
|
+
"supply-chain",
|
|
20
|
+
"tag-rewrite",
|
|
21
|
+
"git-tags",
|
|
22
|
+
"npm-audit",
|
|
23
|
+
"postinstall",
|
|
24
|
+
"sha-pinning"
|
|
25
|
+
],
|
|
26
|
+
"author": "CanisterWorm / TeamPCP",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/47-ron/git-tag-guardian.git"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18.0.0"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"bin/",
|
|
37
|
+
"src/",
|
|
38
|
+
"README.md",
|
|
39
|
+
".guardian.yml"
|
|
40
|
+
],
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Проверка 5: GitHub Actions Pinning
|
|
3
|
+
*
|
|
4
|
+
* Сканирует .github/workflows/*.yml на использование
|
|
5
|
+
* actions с тегами вместо SHA.
|
|
6
|
+
*
|
|
7
|
+
* Вектор атаки: Tag rewrite на action-репозитории позволяет
|
|
8
|
+
* подменить код action'а. При следующем запуске CI/CD
|
|
9
|
+
* выполнится вредоносный код с доступом к GITHUB_TOKEN,
|
|
10
|
+
* secrets, артефактам.
|
|
11
|
+
*
|
|
12
|
+
* Пример:
|
|
13
|
+
* ПЛОХО: uses: actions/checkout@v3
|
|
14
|
+
* ХОРОШО: uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { logger } from '../utils/logger.js';
|
|
20
|
+
|
|
21
|
+
const USES_RE = /uses:\s*["']?([^"'\s#]+)(?:#([^"'\s]+))?["']?/g;
|
|
22
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
23
|
+
const ACTION_RE = /^([^/]+)\/([^/@]+)(?:\/([^@]+))?@(.+)$/;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} cwd
|
|
27
|
+
* @param {object} config
|
|
28
|
+
* @returns {Promise<import('../index.js').Finding[]>}
|
|
29
|
+
*/
|
|
30
|
+
export async function checkActionsPinning(cwd, config) {
|
|
31
|
+
/** @type {import('../index.js').Finding[]} */
|
|
32
|
+
const findings = [];
|
|
33
|
+
|
|
34
|
+
const workflowsDir = join(cwd, '.github', 'workflows');
|
|
35
|
+
let files;
|
|
36
|
+
try {
|
|
37
|
+
files = await readdir(workflowsDir);
|
|
38
|
+
} catch {
|
|
39
|
+
logger.info(' .github/workflows/ не найден — пропускаем');
|
|
40
|
+
return findings;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const ymlFiles = files.filter(f => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
44
|
+
if (ymlFiles.length === 0) {
|
|
45
|
+
logger.info(' Workflow файлы не найдены');
|
|
46
|
+
return findings;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const trustedPatterns = (config.trustedActions || []).map(p => {
|
|
50
|
+
// Преобразуем "actions/*" → RegExp
|
|
51
|
+
const escaped = p.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
52
|
+
return new RegExp(`^${escaped}$`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
let totalActions = 0;
|
|
56
|
+
let unpinnedCount = 0;
|
|
57
|
+
|
|
58
|
+
for (const file of ymlFiles) {
|
|
59
|
+
const filePath = join(workflowsDir, file);
|
|
60
|
+
let content;
|
|
61
|
+
try {
|
|
62
|
+
content = await readFile(filePath, 'utf8');
|
|
63
|
+
} catch { continue; }
|
|
64
|
+
|
|
65
|
+
// Находим все "uses:" директивы
|
|
66
|
+
const lines = content.split('\n');
|
|
67
|
+
for (let i = 0; i < lines.length; i++) {
|
|
68
|
+
const line = lines[i];
|
|
69
|
+
const match = line.match(/uses:\s*["']?([^"'\s]+)["']?/);
|
|
70
|
+
if (!match) continue;
|
|
71
|
+
|
|
72
|
+
const fullRef = match[1];
|
|
73
|
+
const parsed = parseAction(fullRef);
|
|
74
|
+
if (!parsed) continue;
|
|
75
|
+
|
|
76
|
+
totalActions++;
|
|
77
|
+
|
|
78
|
+
// Проверяем, является ли ref полным SHA
|
|
79
|
+
if (SHA_RE.test(parsed.ref)) {
|
|
80
|
+
logger.debug(` ${file}:${i + 1}: ${fullRef} — SHA-pinned (OK)`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Проверяем trusted list
|
|
85
|
+
const actionId = `${parsed.owner}/${parsed.repo}`;
|
|
86
|
+
const isTrusted = trustedPatterns.some(p => p.test(actionId));
|
|
87
|
+
|
|
88
|
+
if (isTrusted) {
|
|
89
|
+
// Даже для trusted — низкий severity
|
|
90
|
+
findings.push({
|
|
91
|
+
check: 'actions-pinning',
|
|
92
|
+
severity: 'low',
|
|
93
|
+
message: `Action "${fullRef}" использует тег вместо SHA (trusted)`,
|
|
94
|
+
file: `.github/workflows/${file}:${i + 1}`,
|
|
95
|
+
detail: `Тег "${parsed.ref}" может быть переписан. Даже для trusted actions рекомендуется SHA pinning.`,
|
|
96
|
+
fix: `Замените на: uses: ${parsed.owner}/${parsed.repo}${parsed.path ? '/' + parsed.path : ''}@<40-char-sha> # ${parsed.ref}`,
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
unpinnedCount++;
|
|
100
|
+
findings.push({
|
|
101
|
+
check: 'actions-pinning',
|
|
102
|
+
severity: 'high',
|
|
103
|
+
message: `Action "${fullRef}" использует тег "${parsed.ref}" вместо SHA!`,
|
|
104
|
+
file: `.github/workflows/${file}:${i + 1}`,
|
|
105
|
+
detail: `Тег "${parsed.ref}" может быть переписан через tag rewrite attack.\nВредоносный action получит доступ к:\n - GITHUB_TOKEN\n - Всем secrets\n - Build артефактам\n - Возможность push в репозиторий`,
|
|
106
|
+
fix: `1. Найдите SHA тега: git ls-remote https://github.com/${parsed.owner}/${parsed.repo} refs/tags/${parsed.ref}\n2. Замените на: uses: ${parsed.owner}/${parsed.repo}${parsed.path ? '/' + parsed.path : ''}@<sha> # ${parsed.ref}`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
logger.info(` Workflow файлов: ${ymlFiles.length}`);
|
|
113
|
+
logger.info(` Actions найдено: ${totalActions}`);
|
|
114
|
+
logger.info(` Без SHA pinning: ${unpinnedCount}`);
|
|
115
|
+
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Парсит uses: reference.
|
|
121
|
+
* "owner/repo@ref" → { owner, repo, path, ref }
|
|
122
|
+
* "owner/repo/sub@ref" → { owner, repo, path: "sub", ref }
|
|
123
|
+
*/
|
|
124
|
+
function parseAction(fullRef) {
|
|
125
|
+
if (!fullRef || fullRef.startsWith('./') || fullRef.startsWith('docker://')) return null;
|
|
126
|
+
|
|
127
|
+
const match = fullRef.match(ACTION_RE);
|
|
128
|
+
if (!match) return null;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
owner: match[1],
|
|
132
|
+
repo: match[2],
|
|
133
|
+
path: match[3] || null,
|
|
134
|
+
ref: match[4],
|
|
135
|
+
};
|
|
136
|
+
}
|