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.
@@ -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
+ }