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,170 @@
1
+ /**
2
+ * Проверка 2: Postinstall / lifecycle hooks
3
+ *
4
+ * Обнаруживает пакеты в node_modules, которые содержат
5
+ * потенциально опасные lifecycle-скрипты:
6
+ * - preinstall, install, postinstall
7
+ * - preuninstall, uninstall, postuninstall
8
+ * - prepare, prepublish
9
+ *
10
+ * Вектор атаки: через tag rewrite вредоносный package.json
11
+ * получает postinstall hook, который автоматически выполняется
12
+ * при npm install, собирая env vars, SSH-ключи, токены.
13
+ */
14
+
15
+ import { readFile, readdir } from 'node:fs/promises';
16
+ import { join, basename } from 'node:path';
17
+ import { logger } from '../utils/logger.js';
18
+
19
+ const DANGEROUS_HOOKS = [
20
+ 'preinstall',
21
+ 'install',
22
+ 'postinstall',
23
+ 'preuninstall',
24
+ 'uninstall',
25
+ 'postuninstall',
26
+ 'prepare',
27
+ ];
28
+
29
+ // Паттерны подозрительного кода в lifecycle-скриптах
30
+ const SUSPICIOUS_PATTERNS = [
31
+ { pattern: /curl\s+.*https?:\/\//, label: 'HTTP exfiltration (curl)' },
32
+ { pattern: /wget\s+.*https?:\/\//, label: 'HTTP exfiltration (wget)' },
33
+ { pattern: /https?\.request|https?\.get|fetch\(/, label: 'Network request in script' },
34
+ { pattern: /process\.env/, label: 'Доступ к переменным окружения' },
35
+ { pattern: /\.ssh\//, label: 'Доступ к SSH-ключам' },
36
+ { pattern: /\.bashrc|\.zshrc|\.profile/, label: 'Модификация shell profile' },
37
+ { pattern: /\.npmrc/, label: 'Модификация npm config' },
38
+ { pattern: /\.gitconfig/, label: 'Доступ к git config' },
39
+ { pattern: /child_process|exec\(|execSync|spawn/, label: 'Порождение процессов' },
40
+ { pattern: /eval\(|Function\(/, label: 'Динамическое выполнение кода' },
41
+ { pattern: /Buffer\.from\(.*,\s*['"]base64['"]/, label: 'Base64 decode (обфускация)' },
42
+ { pattern: /\\x[0-9a-f]{2}/i, label: 'Hex-encoded strings' },
43
+ { pattern: /require\(['"]https?['"]\)/, label: 'Импорт http(s) модуля' },
44
+ { pattern: /os\.userInfo|os\.hostname/, label: 'Сбор информации о системе' },
45
+ { pattern: /fs\.appendFileSync|fs\.writeFileSync/, label: 'Запись в файловую систему' },
46
+ { pattern: /dns\.resolve|dns\.lookup/, label: 'DNS exfiltration' },
47
+ ];
48
+
49
+ /**
50
+ * @param {string} cwd
51
+ * @param {object} config
52
+ * @returns {Promise<import('../index.js').Finding[]>}
53
+ */
54
+ export async function checkPostinstallHooks(cwd, config) {
55
+ /** @type {import('../index.js').Finding[]} */
56
+ const findings = [];
57
+
58
+ const nmDir = join(cwd, 'node_modules');
59
+ let modules;
60
+ try {
61
+ modules = await readdir(nmDir, { withFileTypes: true });
62
+ } catch {
63
+ logger.info(' node_modules не найден — пропускаем');
64
+ return findings;
65
+ }
66
+
67
+ const allowList = new Set(config.allowedLifecyclePackages || []);
68
+ let checkedCount = 0;
69
+ let hookCount = 0;
70
+
71
+ // Обходим top-level и scoped пакеты
72
+ const packageDirs = [];
73
+ for (const entry of modules) {
74
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
75
+
76
+ if (entry.name.startsWith('@')) {
77
+ // Scoped packages
78
+ try {
79
+ const scoped = await readdir(join(nmDir, entry.name), { withFileTypes: true });
80
+ for (const sub of scoped) {
81
+ if (sub.isDirectory()) {
82
+ packageDirs.push(join(entry.name, sub.name));
83
+ }
84
+ }
85
+ } catch { /* skip */ }
86
+ } else {
87
+ packageDirs.push(entry.name);
88
+ }
89
+ }
90
+
91
+ for (const relPath of packageDirs) {
92
+ const pkgJsonPath = join(nmDir, relPath, 'package.json');
93
+ let pkgJson;
94
+ try {
95
+ pkgJson = JSON.parse(await readFile(pkgJsonPath, 'utf8'));
96
+ } catch { continue; }
97
+
98
+ checkedCount++;
99
+ const scripts = pkgJson.scripts || {};
100
+
101
+ for (const hook of DANGEROUS_HOOKS) {
102
+ if (!scripts[hook]) continue;
103
+
104
+ hookCount++;
105
+ const pkgName = pkgJson.name || relPath;
106
+ const scriptContent = scripts[hook];
107
+
108
+ if (allowList.has(pkgName)) {
109
+ logger.debug(` ${pkgName}: ${hook} — в allow-list, пропускаем`);
110
+ continue;
111
+ }
112
+
113
+ // Определяем severity на основе подозрительных паттернов
114
+ const suspiciousMatches = [];
115
+ for (const { pattern, label } of SUSPICIOUS_PATTERNS) {
116
+ if (pattern.test(scriptContent)) {
117
+ suspiciousMatches.push(label);
118
+ }
119
+ }
120
+
121
+ // Дополнительно проверяем файл, на который ссылается hook
122
+ const hookFile = extractHookFile(scriptContent, join(nmDir, relPath));
123
+ if (hookFile) {
124
+ try {
125
+ const hookCode = await readFile(hookFile, 'utf8');
126
+ for (const { pattern, label } of SUSPICIOUS_PATTERNS) {
127
+ if (pattern.test(hookCode) && !suspiciousMatches.includes(label)) {
128
+ suspiciousMatches.push(label);
129
+ }
130
+ }
131
+ } catch { /* файл не найден */ }
132
+ }
133
+
134
+ const severity = suspiciousMatches.length >= 3 ? 'critical' :
135
+ suspiciousMatches.length >= 1 ? 'high' : 'medium';
136
+
137
+ findings.push({
138
+ check: 'postinstall-hooks',
139
+ severity,
140
+ message: `Пакет "${pkgName}" содержит lifecycle hook: ${hook}`,
141
+ pkg: pkgName,
142
+ file: pkgJsonPath,
143
+ detail: suspiciousMatches.length
144
+ ? `Подозрительные паттерны: ${suspiciousMatches.join(', ')}\nСкрипт: ${scriptContent.slice(0, 200)}`
145
+ : `Скрипт: ${scriptContent.slice(0, 200)}`,
146
+ fix: `Проверьте содержимое скрипта вручную. Если доверяете — добавьте "${pkgName}" в allowedLifecyclePackages`,
147
+ });
148
+ }
149
+ }
150
+
151
+ logger.info(` Проверено пакетов: ${checkedCount}`);
152
+ logger.info(` Lifecycle hooks найдено: ${hookCount}`);
153
+
154
+ return findings;
155
+ }
156
+
157
+ /**
158
+ * Пытается определить файл, на который ссылается lifecycle-скрипт.
159
+ */
160
+ function extractHookFile(script, pkgDir) {
161
+ // "node install.js" → install.js
162
+ const nodeMatch = script.match(/node\s+([^\s;|&]+)/);
163
+ if (nodeMatch) return join(pkgDir, nodeMatch[1]);
164
+
165
+ // "./scripts/postinstall.sh"
166
+ const shMatch = script.match(/(?:sh|bash)\s+([^\s;|&]+)/);
167
+ if (shMatch) return join(pkgDir, shMatch[1]);
168
+
169
+ return null;
170
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Проверка 1: SHA Pinning
3
+ *
4
+ * Обнаруживает git-зависимости в package.json/package-lock.json,
5
+ * которые ссылаются на теги вместо полных SHA.
6
+ *
7
+ * Вектор атаки: Tag Rewrite — злоумышленник переписывает тег
8
+ * на вредоносный коммит из форка. При следующем npm install
9
+ * SHA в package-lock.json молча меняется.
10
+ *
11
+ * Также отслеживает drift: если baseline зафиксировал SHA,
12
+ * а package-lock.json теперь содержит другой — предупреждение.
13
+ */
14
+
15
+ import { readFile } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ import { logger } from '../utils/logger.js';
18
+ import { BaselineManager } from '../utils/baseline.js';
19
+
20
+ const GIT_DEP_RE = /^(?:git(?:\+(?:ssh|https?))?:\/\/|github:)/;
21
+ const GITHUB_SHORT_RE = /^([^/]+\/[^/#]+)(?:#(.+))?$/;
22
+ const SHA_FULL_RE = /^[0-9a-f]{40}$/;
23
+ const SHA_SHORT_RE = /^[0-9a-f]{7,40}$/;
24
+
25
+ /**
26
+ * @param {string} cwd
27
+ * @param {object} config
28
+ * @returns {Promise<import('../index.js').Finding[]>}
29
+ */
30
+ export async function checkSHAPinning(cwd, config) {
31
+ /** @type {import('../index.js').Finding[]} */
32
+ const findings = [];
33
+
34
+ // ── Анализ package.json ──────────────────────────────────────
35
+ let pkgJson;
36
+ try {
37
+ pkgJson = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
38
+ } catch {
39
+ logger.warn(' package.json не найден — пропускаем');
40
+ return findings;
41
+ }
42
+
43
+ const allDeps = {
44
+ ...pkgJson.dependencies,
45
+ ...pkgJson.devDependencies,
46
+ ...pkgJson.optionalDependencies,
47
+ };
48
+
49
+ // Находим git-зависимости с тегами (а не SHA)
50
+ for (const [name, spec] of Object.entries(allDeps)) {
51
+ if (typeof spec !== 'string') continue;
52
+
53
+ const gitInfo = parseGitDep(spec);
54
+ if (!gitInfo) continue; // не git-зависимость
55
+
56
+ if (gitInfo.ref && !SHA_FULL_RE.test(gitInfo.ref)) {
57
+ findings.push({
58
+ check: 'sha-pinning',
59
+ severity: 'high',
60
+ message: `Зависимость "${name}" ссылается на тег/ветку "${gitInfo.ref}" вместо SHA`,
61
+ pkg: name,
62
+ file: 'package.json',
63
+ detail: `spec: ${spec}`,
64
+ fix: `Замените на полный SHA коммита: "${name}": "${gitInfo.base}#<full-40-char-sha>"`,
65
+ });
66
+ }
67
+ }
68
+
69
+ // ── Анализ package-lock.json ─────────────────────────────────
70
+ let lockfile;
71
+ try {
72
+ lockfile = JSON.parse(await readFile(join(cwd, 'package-lock.json'), 'utf8'));
73
+ } catch {
74
+ logger.debug(' package-lock.json не найден');
75
+ return findings;
76
+ }
77
+
78
+ // Baseline для drift detection
79
+ const baseline = new BaselineManager(cwd, config.baselineFile);
80
+ await baseline.load();
81
+
82
+ const packages = lockfile.packages || {};
83
+ for (const [pkgPath, meta] of Object.entries(packages)) {
84
+ if (!meta.resolved || !meta.resolved.includes('git')) continue;
85
+
86
+ const resolvedSHA = extractSHAFromResolved(meta.resolved);
87
+ if (!resolvedSHA) continue;
88
+
89
+ const pkgName = pkgPath.replace('node_modules/', '');
90
+ if (!pkgName) continue;
91
+
92
+ // Drift detection: сравниваем с baseline
93
+ const baselineSHA = baseline.getDependencySHA(pkgName);
94
+ if (baselineSHA && baselineSHA !== resolvedSHA) {
95
+ findings.push({
96
+ check: 'sha-pinning',
97
+ severity: 'critical',
98
+ message: `SHA DRIFT: "${pkgName}" SHA изменился с момента baseline!`,
99
+ pkg: pkgName,
100
+ file: 'package-lock.json',
101
+ detail: `baseline: ${baselineSHA}\ncurrent: ${resolvedSHA}`,
102
+ fix: 'Проверьте git-зависимость вручную. Если изменение легитимно, обновите baseline: npx git-tag-guardian baseline',
103
+ });
104
+ }
105
+
106
+ // Проверяем что resolved содержит full SHA (не short)
107
+ if (resolvedSHA && !SHA_FULL_RE.test(resolvedSHA)) {
108
+ findings.push({
109
+ check: 'sha-pinning',
110
+ severity: 'medium',
111
+ message: `"${pkgName}" resolved содержит короткий SHA`,
112
+ pkg: pkgName,
113
+ file: 'package-lock.json',
114
+ detail: `resolved SHA: ${resolvedSHA}`,
115
+ });
116
+ }
117
+
118
+ logger.debug(` ${pkgName}: ${resolvedSHA?.slice(0, 12)}...`);
119
+ }
120
+
121
+ const gitDepCount = Object.entries(allDeps).filter(([, v]) => parseGitDep(v)).length;
122
+ if (gitDepCount === 0) {
123
+ logger.info(' Git-зависимости не обнаружены — OK');
124
+ } else {
125
+ logger.info(` Обнаружено git-зависимостей: ${gitDepCount}`);
126
+ }
127
+
128
+ return findings;
129
+ }
130
+
131
+ /**
132
+ * Парсит git-зависимость, возвращает { base, ref } или null.
133
+ */
134
+ function parseGitDep(spec) {
135
+ if (!spec || typeof spec !== 'string') return null;
136
+
137
+ // github:owner/repo#ref
138
+ if (spec.startsWith('github:')) {
139
+ const rest = spec.slice(7);
140
+ const match = rest.match(GITHUB_SHORT_RE);
141
+ if (match) return { base: `github:${match[1]}`, ref: match[2] || null };
142
+ }
143
+
144
+ // owner/repo#ref (GitHub shorthand)
145
+ if (/^[^@/][^/]*\/[^/#]+/.test(spec) && !spec.startsWith('@')) {
146
+ const match = spec.match(GITHUB_SHORT_RE);
147
+ if (match) return { base: match[1], ref: match[2] || null };
148
+ }
149
+
150
+ // git+https://... или git+ssh://...
151
+ if (GIT_DEP_RE.test(spec)) {
152
+ const hashIdx = spec.indexOf('#');
153
+ if (hashIdx !== -1) {
154
+ return { base: spec.slice(0, hashIdx), ref: spec.slice(hashIdx + 1) };
155
+ }
156
+ return { base: spec, ref: null };
157
+ }
158
+
159
+ return null;
160
+ }
161
+
162
+ /**
163
+ * Извлекает SHA из resolved поля package-lock.json.
164
+ * Например: "git+ssh://git@github.com/owner/repo.git#abc123..." → "abc123..."
165
+ */
166
+ function extractSHAFromResolved(resolved) {
167
+ if (!resolved) return null;
168
+ const hashIdx = resolved.lastIndexOf('#');
169
+ if (hashIdx === -1) return null;
170
+ const sha = resolved.slice(hashIdx + 1);
171
+ return SHA_SHORT_RE.test(sha) ? sha : null;
172
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Проверка 3: Tag Integrity через GitHub API
3
+ *
4
+ * Для каждой git-зависимости, ссылающейся на GitHub,
5
+ * запрашивает текущий SHA тега через API и сравнивает
6
+ * с baseline.
7
+ *
8
+ * Вектор атаки: Tag Rewrite — злоумышленник с push-доступом
9
+ * переписывает lightweight тег на SHA вредоносного коммита
10
+ * из fork network. GitHub API: PATCH /git/refs/tags/<tag>
11
+ * с { sha: "malicious_sha", force: true }.
12
+ */
13
+
14
+ import { readFile } from 'node:fs/promises';
15
+ import { join } from 'node:path';
16
+ import { logger } from '../utils/logger.js';
17
+ import { BaselineManager } from '../utils/baseline.js';
18
+
19
+ const GITHUB_REPO_RE = /github\.com[/:]([^/]+)\/([^/.#]+)/;
20
+
21
+ /**
22
+ * @param {string} cwd
23
+ * @param {object} config
24
+ * @param {string} githubToken
25
+ * @returns {Promise<import('../index.js').Finding[]>}
26
+ */
27
+ export async function checkTagIntegrity(cwd, config, githubToken) {
28
+ /** @type {import('../index.js').Finding[]} */
29
+ const findings = [];
30
+
31
+ let pkgJson;
32
+ try {
33
+ pkgJson = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
34
+ } catch {
35
+ return findings;
36
+ }
37
+
38
+ const allDeps = {
39
+ ...pkgJson.dependencies,
40
+ ...pkgJson.devDependencies,
41
+ ...pkgJson.optionalDependencies,
42
+ };
43
+
44
+ const baseline = new BaselineManager(cwd, config.baselineFile);
45
+ await baseline.load();
46
+
47
+ for (const [name, spec] of Object.entries(allDeps)) {
48
+ if (typeof spec !== 'string') continue;
49
+
50
+ const info = parseGitHubRef(spec);
51
+ if (!info || !info.tag) continue; // нет тега — ок (SHA pinned)
52
+
53
+ logger.debug(` Проверяем тег: ${info.owner}/${info.repo}#${info.tag}`);
54
+
55
+ try {
56
+ const remoteSHA = await fetchTagSHA(info.owner, info.repo, info.tag, githubToken);
57
+ if (!remoteSHA) {
58
+ findings.push({
59
+ check: 'tag-integrity',
60
+ severity: 'medium',
61
+ message: `Тег "${info.tag}" не найден для ${info.owner}/${info.repo}`,
62
+ pkg: name,
63
+ detail: `Возможно тег удалён или переименован`,
64
+ });
65
+ continue;
66
+ }
67
+
68
+ const repoTag = `${info.owner}/${info.repo}#${info.tag}`;
69
+ const baselineSHA = baseline.getTagSHA(repoTag);
70
+
71
+ if (baselineSHA && baselineSHA !== remoteSHA) {
72
+ findings.push({
73
+ check: 'tag-integrity',
74
+ severity: 'critical',
75
+ message: `TAG REWRITE DETECTED: ${repoTag} SHA изменился!`,
76
+ pkg: name,
77
+ detail: `baseline SHA: ${baselineSHA}\nremote SHA: ${remoteSHA}\nЭто может означать, что тег был переписан на вредоносный коммит.`,
78
+ fix: `1. НЕ запускайте npm install!\n2. Проверьте коммит ${remoteSHA} вручную через GitHub UI\n3. Свяжитесь с maintainer-ом репозитория\n4. Переключитесь на SHA-pinning: "${name}": "github:${info.owner}/${info.repo}#${baselineSHA}"`,
79
+ });
80
+ } else if (!baselineSHA) {
81
+ logger.info(` ${repoTag} → ${remoteSHA.slice(0, 12)}... (первое сканирование)`);
82
+ } else {
83
+ logger.info(` ${repoTag} → ${remoteSHA.slice(0, 12)}... (соответствует baseline)`);
84
+ }
85
+
86
+ // Дополнительно: проверяем, принадлежит ли коммит ветке main/master
87
+ try {
88
+ const commitInfo = await fetchCommitInfo(info.owner, info.repo, remoteSHA, githubToken);
89
+ if (commitInfo && commitInfo.isForkCommit) {
90
+ findings.push({
91
+ check: 'tag-integrity',
92
+ severity: 'critical',
93
+ message: `Тег "${info.tag}" указывает на коммит из ФОРКА!`,
94
+ pkg: name,
95
+ detail: `Коммит ${remoteSHA} не найден в default branch.\nЭто типичный признак tag rewrite attack.`,
96
+ fix: `Немедленно проверьте коммит вручную. Если подтвердится — это supply chain атака.`,
97
+ });
98
+ }
99
+ } catch {
100
+ // API limit или ошибка — не критично
101
+ }
102
+
103
+ } catch (err) {
104
+ logger.warn(` Ошибка проверки ${name}: ${err.message}`);
105
+ }
106
+ }
107
+
108
+ return findings;
109
+ }
110
+
111
+ /**
112
+ * Парсит GitHub-ссылку из dependency spec.
113
+ * @returns {{ owner: string, repo: string, tag: string|null } | null}
114
+ */
115
+ function parseGitHubRef(spec) {
116
+ if (!spec || typeof spec !== 'string') return null;
117
+
118
+ // "github:owner/repo#tag"
119
+ let rest = spec;
120
+ if (rest.startsWith('github:')) rest = rest.slice(7);
121
+ if (rest.startsWith('git+https://')) rest = rest.slice(12);
122
+ if (rest.startsWith('git+ssh://git@')) rest = rest.slice(14);
123
+
124
+ const match = rest.match(GITHUB_REPO_RE) || rest.match(/^([^/]+)\/([^/#]+)(?:#(.+))?$/);
125
+ if (!match) return null;
126
+
127
+ const owner = match[1];
128
+ const repo = match[2].replace(/\.git$/, '');
129
+ const hashIdx = spec.indexOf('#');
130
+ const ref = hashIdx !== -1 ? spec.slice(hashIdx + 1) : null;
131
+
132
+ // Если ref — полный SHA (40 hex), то это не тег
133
+ if (ref && /^[0-9a-f]{40}$/.test(ref)) return { owner, repo, tag: null };
134
+
135
+ return { owner, repo, tag: ref };
136
+ }
137
+
138
+ /**
139
+ * Запрашивает SHA тега через GitHub API.
140
+ */
141
+ async function fetchTagSHA(owner, repo, tag, token) {
142
+ const url = `https://api.github.com/repos/${owner}/${repo}/git/refs/tags/${tag}`;
143
+ const res = await fetch(url, {
144
+ headers: {
145
+ 'Authorization': `token ${token}`,
146
+ 'Accept': 'application/vnd.github+json',
147
+ 'User-Agent': 'git-tag-guardian/1.0',
148
+ },
149
+ });
150
+
151
+ if (res.status === 404) return null;
152
+ if (!res.ok) throw new Error(`GitHub API: ${res.status} ${res.statusText}`);
153
+
154
+ const data = await res.json();
155
+
156
+ // Annotated tag — нужно дереференсить
157
+ if (data.object?.type === 'tag') {
158
+ const tagUrl = data.object.url;
159
+ const tagRes = await fetch(tagUrl, {
160
+ headers: {
161
+ 'Authorization': `token ${token}`,
162
+ 'Accept': 'application/vnd.github+json',
163
+ 'User-Agent': 'git-tag-guardian/1.0',
164
+ },
165
+ });
166
+ if (tagRes.ok) {
167
+ const tagData = await tagRes.json();
168
+ return tagData.object?.sha || data.object.sha;
169
+ }
170
+ }
171
+
172
+ return data.object?.sha || null;
173
+ }
174
+
175
+ /**
176
+ * Проверяет, принадлежит ли коммит default branch.
177
+ * Если нет — возможно это коммит из форка (tag rewrite attack).
178
+ */
179
+ async function fetchCommitInfo(owner, repo, sha, token) {
180
+ // Проверяем, содержит ли default branch этот коммит
181
+ const url = `https://api.github.com/repos/${owner}/${repo}/commits/${sha}/branches-where-head`;
182
+ const res = await fetch(url, {
183
+ headers: {
184
+ 'Authorization': `token ${token}`,
185
+ 'Accept': 'application/vnd.github+json',
186
+ 'User-Agent': 'git-tag-guardian/1.0',
187
+ },
188
+ });
189
+
190
+ if (!res.ok) return null;
191
+
192
+ const branches = await res.json();
193
+ // Проверяем, есть ли main/master среди веток
194
+ const onDefaultBranch = branches.some(
195
+ b => b.name === 'main' || b.name === 'master'
196
+ );
197
+
198
+ // Дополнительная проверка: сравниваем author коммита с maintainer-ами
199
+ const commitUrl = `https://api.github.com/repos/${owner}/${repo}/commits/${sha}`;
200
+ const commitRes = await fetch(commitUrl, {
201
+ headers: {
202
+ 'Authorization': `token ${token}`,
203
+ 'Accept': 'application/vnd.github+json',
204
+ 'User-Agent': 'git-tag-guardian/1.0',
205
+ },
206
+ });
207
+
208
+ let isForkCommit = !onDefaultBranch;
209
+
210
+ if (commitRes.ok) {
211
+ const commitData = await commitRes.json();
212
+ // Если коммит не на default branch И автор не collaborator — высокий риск
213
+ if (!onDefaultBranch && commitData.author?.login) {
214
+ const collabUrl = `https://api.github.com/repos/${owner}/${repo}/collaborators/${commitData.author.login}`;
215
+ const collabRes = await fetch(collabUrl, {
216
+ headers: {
217
+ 'Authorization': `token ${token}`,
218
+ 'Accept': 'application/vnd.github+json',
219
+ 'User-Agent': 'git-tag-guardian/1.0',
220
+ },
221
+ });
222
+ if (collabRes.status === 404) {
223
+ isForkCommit = true; // автор не collaborator
224
+ }
225
+ }
226
+ }
227
+
228
+ return { isForkCommit, onDefaultBranch };
229
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Проверка 4: Tarball (.tar.gz) Integrity
3
+ *
4
+ * Для git-зависимостей, ссылающихся на GitHub через теги,
5
+ * скачивает .tar.gz и сверяет SHA256 с baseline.
6
+ *
7
+ * Вектор атаки: GitHub автоматически регенерирует .tar.gz
8
+ * после tag rewrite. URL остаётся прежним, но содержимое
9
+ * меняется (размер, хеш, новые файлы типа install.js).
10
+ */
11
+
12
+ import { createHash } from 'node:crypto';
13
+ import { readFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { logger } from '../utils/logger.js';
16
+ import { BaselineManager } from '../utils/baseline.js';
17
+
18
+ /**
19
+ * @param {string} cwd
20
+ * @param {object} config
21
+ * @returns {Promise<import('../index.js').Finding[]>}
22
+ */
23
+ export async function checkTarballIntegrity(cwd, config) {
24
+ /** @type {import('../index.js').Finding[]} */
25
+ const findings = [];
26
+
27
+ let pkgJson;
28
+ try {
29
+ pkgJson = JSON.parse(await readFile(join(cwd, 'package.json'), 'utf8'));
30
+ } catch {
31
+ return findings;
32
+ }
33
+
34
+ const allDeps = {
35
+ ...pkgJson.dependencies,
36
+ ...pkgJson.devDependencies,
37
+ };
38
+
39
+ const baseline = new BaselineManager(cwd, config.baselineFile);
40
+ await baseline.load();
41
+
42
+ for (const [name, spec] of Object.entries(allDeps)) {
43
+ if (typeof spec !== 'string') continue;
44
+
45
+ const tarballUrl = buildTarballUrl(spec);
46
+ if (!tarballUrl) continue;
47
+
48
+ logger.debug(` Проверяем tarball: ${name} → ${tarballUrl}`);
49
+
50
+ try {
51
+ const res = await fetch(tarballUrl, {
52
+ headers: { 'User-Agent': 'git-tag-guardian/1.0' },
53
+ redirect: 'follow',
54
+ });
55
+
56
+ if (!res.ok) {
57
+ logger.warn(` ${name}: tarball недоступен (${res.status})`);
58
+ continue;
59
+ }
60
+
61
+ const buffer = Buffer.from(await res.arrayBuffer());
62
+ const sha256 = createHash('sha256').update(buffer).digest('hex');
63
+ const size = buffer.length;
64
+
65
+ const baselineHash = baseline.getTarballHash(tarballUrl);
66
+ if (baselineHash && baselineHash !== sha256) {
67
+ findings.push({
68
+ check: 'tarball-integrity',
69
+ severity: 'critical',
70
+ message: `TARBALL CHANGED: "${name}" .tar.gz хеш изменился!`,
71
+ pkg: name,
72
+ detail: `URL: ${tarballUrl}\nbaseline: ${baselineHash}\ncurrent: ${sha256}\nsize: ${size} bytes\n\nТег мог быть переписан (tag rewrite attack).`,
73
+ fix: `1. Сравните содержимое tarball вручную\n2. Проверьте тег через GitHub API\n3. Обновите baseline если изменение легитимно`,
74
+ });
75
+ } else if (!baselineHash) {
76
+ logger.info(` ${name}: ${sha256.slice(0, 16)}... (${size} bytes) — записано в baseline`);
77
+ } else {
78
+ logger.info(` ${name}: OK (соответствует baseline)`);
79
+ }
80
+
81
+ } catch (err) {
82
+ logger.warn(` ${name}: ошибка скачивания tarball: ${err.message}`);
83
+ }
84
+ }
85
+
86
+ return findings;
87
+ }
88
+
89
+ /**
90
+ * Строит URL .tar.gz для GitHub-зависимости.
91
+ * github:owner/repo#tag → https://github.com/owner/repo/archive/refs/tags/tag.tar.gz
92
+ */
93
+ function buildTarballUrl(spec) {
94
+ if (!spec || typeof spec !== 'string') return null;
95
+
96
+ let rest = spec;
97
+ if (rest.startsWith('github:')) rest = rest.slice(7);
98
+ else if (rest.includes('github.com')) {
99
+ const m = rest.match(/github\.com[/:]([^/]+\/[^/.#]+)/);
100
+ if (m) rest = m[1];
101
+ else return null;
102
+ } else {
103
+ return null;
104
+ }
105
+
106
+ // owner/repo#ref
107
+ const hashIdx = rest.indexOf('#');
108
+ if (hashIdx === -1) return null;
109
+
110
+ const ownerRepo = rest.slice(0, hashIdx);
111
+ const ref = rest.slice(hashIdx + 1);
112
+
113
+ // Только для тегов (не для полных SHA)
114
+ if (/^[0-9a-f]{40}$/.test(ref)) return null;
115
+
116
+ return `https://github.com/${ownerRepo}/archive/refs/tags/${ref}.tar.gz`;
117
+ }