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/src/index.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-tag-guardian — основной модуль
|
|
3
|
+
*
|
|
4
|
+
* Защита репозиториев Node.js/Bun от:
|
|
5
|
+
* 1. Git Tag Rewrite Attack (подмена SHA тега через форк)
|
|
6
|
+
* 2. Postinstall backdoor injection
|
|
7
|
+
* 3. SHA drift в package-lock.json
|
|
8
|
+
* 4. .tar.gz integrity violation
|
|
9
|
+
* 5. GitHub Actions tag pinning (рекомендация на SHA)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { checkSHAPinning } from './checks/sha-pinning.js';
|
|
13
|
+
import { checkPostinstallHooks } from './checks/postinstall-hooks.js';
|
|
14
|
+
import { checkTagIntegrity } from './checks/tag-integrity.js';
|
|
15
|
+
import { checkTarballIntegrity } from './checks/tarball-integrity.js';
|
|
16
|
+
import { checkActionsPinning } from './checks/actions-pinning.js';
|
|
17
|
+
import { loadConfig } from './utils/config.js';
|
|
18
|
+
import { logger } from './utils/logger.js';
|
|
19
|
+
import { BaselineManager } from './utils/baseline.js';
|
|
20
|
+
|
|
21
|
+
export {
|
|
22
|
+
checkSHAPinning,
|
|
23
|
+
checkPostinstallHooks,
|
|
24
|
+
checkTagIntegrity,
|
|
25
|
+
checkTarballIntegrity,
|
|
26
|
+
checkActionsPinning,
|
|
27
|
+
loadConfig,
|
|
28
|
+
BaselineManager,
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Запуск полного аудита
|
|
33
|
+
* @param {object} opts
|
|
34
|
+
* @param {string} opts.cwd — корень проекта
|
|
35
|
+
* @param {string} [opts.configPath] — путь к .guardian.yml
|
|
36
|
+
* @param {string} [opts.githubToken] — GitHub PAT для API-проверок
|
|
37
|
+
* @param {boolean} [opts.ci] — режим CI (exit code != 0 при проблемах)
|
|
38
|
+
* @returns {Promise<AuditReport>}
|
|
39
|
+
*/
|
|
40
|
+
export async function runAudit(opts = {}) {
|
|
41
|
+
const cwd = opts.cwd || process.cwd();
|
|
42
|
+
const config = await loadConfig(opts.configPath || cwd);
|
|
43
|
+
const githubToken = opts.githubToken || process.env.GITHUB_TOKEN || null;
|
|
44
|
+
|
|
45
|
+
logger.info(`git-tag-guardian v1.0.0 — аудит: ${cwd}`);
|
|
46
|
+
logger.info('─'.repeat(60));
|
|
47
|
+
|
|
48
|
+
/** @type {Finding[]} */
|
|
49
|
+
const findings = [];
|
|
50
|
+
|
|
51
|
+
// ── 1. SHA Pinning ───────────────────────────────────────────
|
|
52
|
+
if (config.checks.shaPinning !== false) {
|
|
53
|
+
logger.section('SHA Pinning (package-lock.json)');
|
|
54
|
+
const res = await checkSHAPinning(cwd, config);
|
|
55
|
+
findings.push(...res);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── 2. Postinstall hooks ─────────────────────────────────────
|
|
59
|
+
if (config.checks.postinstallHooks !== false) {
|
|
60
|
+
logger.section('Postinstall / lifecycle hooks');
|
|
61
|
+
const res = await checkPostinstallHooks(cwd, config);
|
|
62
|
+
findings.push(...res);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── 3. Tag Integrity (GitHub API) ────────────────────────────
|
|
66
|
+
if (config.checks.tagIntegrity !== false && githubToken) {
|
|
67
|
+
logger.section('Tag Integrity (GitHub API)');
|
|
68
|
+
const res = await checkTagIntegrity(cwd, config, githubToken);
|
|
69
|
+
findings.push(...res);
|
|
70
|
+
} else if (config.checks.tagIntegrity !== false && !githubToken) {
|
|
71
|
+
logger.warn('Tag Integrity check пропущен — нет GITHUB_TOKEN');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── 4. Tarball Integrity ─────────────────────────────────────
|
|
75
|
+
if (config.checks.tarballIntegrity !== false) {
|
|
76
|
+
logger.section('Tarball (.tar.gz) Integrity');
|
|
77
|
+
const res = await checkTarballIntegrity(cwd, config);
|
|
78
|
+
findings.push(...res);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── 5. GitHub Actions Pinning ────────────────────────────────
|
|
82
|
+
if (config.checks.actionsPinning !== false) {
|
|
83
|
+
logger.section('GitHub Actions Pinning');
|
|
84
|
+
const res = await checkActionsPinning(cwd, config);
|
|
85
|
+
findings.push(...res);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Итог ─────────────────────────────────────────────────────
|
|
89
|
+
const report = buildReport(findings, cwd);
|
|
90
|
+
printReport(report);
|
|
91
|
+
return report;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @typedef {'critical'|'high'|'medium'|'low'|'info'} Severity */
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @typedef {object} Finding
|
|
98
|
+
* @property {string} check — имя проверки
|
|
99
|
+
* @property {Severity} severity
|
|
100
|
+
* @property {string} message
|
|
101
|
+
* @property {string} [file]
|
|
102
|
+
* @property {string} [pkg]
|
|
103
|
+
* @property {string} [detail]
|
|
104
|
+
* @property {string} [fix] — рекомендуемое исправление
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @typedef {object} AuditReport
|
|
109
|
+
* @property {string} cwd
|
|
110
|
+
* @property {string} date
|
|
111
|
+
* @property {number} total
|
|
112
|
+
* @property {Record<Severity, number>} counts
|
|
113
|
+
* @property {Finding[]} findings
|
|
114
|
+
* @property {boolean} pass
|
|
115
|
+
*/
|
|
116
|
+
|
|
117
|
+
function buildReport(findings, cwd) {
|
|
118
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
119
|
+
for (const f of findings) counts[f.severity]++;
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
cwd,
|
|
123
|
+
date: new Date().toISOString(),
|
|
124
|
+
total: findings.length,
|
|
125
|
+
counts,
|
|
126
|
+
findings,
|
|
127
|
+
pass: counts.critical === 0 && counts.high === 0,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function printReport(report) {
|
|
132
|
+
logger.info('');
|
|
133
|
+
logger.info('═'.repeat(60));
|
|
134
|
+
logger.info(' AUDIT REPORT');
|
|
135
|
+
logger.info('═'.repeat(60));
|
|
136
|
+
logger.info(` Date: ${report.date}`);
|
|
137
|
+
logger.info(` Project: ${report.cwd}`);
|
|
138
|
+
logger.info(` Findings: ${report.total}`);
|
|
139
|
+
logger.info('');
|
|
140
|
+
|
|
141
|
+
const sev = report.counts;
|
|
142
|
+
if (sev.critical) logger.error(` CRITICAL: ${sev.critical}`);
|
|
143
|
+
if (sev.high) logger.warn(` HIGH: ${sev.high}`);
|
|
144
|
+
if (sev.medium) logger.warn(` MEDIUM: ${sev.medium}`);
|
|
145
|
+
if (sev.low) logger.info(` LOW: ${sev.low}`);
|
|
146
|
+
if (sev.info) logger.info(` INFO: ${sev.info}`);
|
|
147
|
+
|
|
148
|
+
logger.info('');
|
|
149
|
+
|
|
150
|
+
for (const f of report.findings) {
|
|
151
|
+
const icon =
|
|
152
|
+
f.severity === 'critical' ? '[CRIT]' :
|
|
153
|
+
f.severity === 'high' ? '[HIGH]' :
|
|
154
|
+
f.severity === 'medium' ? '[MED] ' :
|
|
155
|
+
f.severity === 'low' ? '[LOW] ' : '[INFO]';
|
|
156
|
+
|
|
157
|
+
logger.info(` ${icon} [${f.check}] ${f.message}`);
|
|
158
|
+
if (f.file) logger.info(` file: ${f.file}`);
|
|
159
|
+
if (f.pkg) logger.info(` pkg: ${f.pkg}`);
|
|
160
|
+
if (f.detail) logger.info(` detail: ${f.detail}`);
|
|
161
|
+
if (f.fix) logger.info(` fix: ${f.fix}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
logger.info('');
|
|
165
|
+
logger.info('═'.repeat(60));
|
|
166
|
+
if (report.pass) {
|
|
167
|
+
logger.info(' RESULT: PASS');
|
|
168
|
+
} else {
|
|
169
|
+
logger.error(' RESULT: FAIL — обнаружены critical/high проблемы');
|
|
170
|
+
}
|
|
171
|
+
logger.info('═'.repeat(60));
|
|
172
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baseline Manager — сохранение и верификация baseline SHA
|
|
3
|
+
* для git-зависимостей и тегов.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { logger } from './logger.js';
|
|
9
|
+
|
|
10
|
+
export class BaselineManager {
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} cwd — корень проекта
|
|
13
|
+
* @param {string} [filename] — имя файла baseline
|
|
14
|
+
*/
|
|
15
|
+
constructor(cwd, filename = '.guardian-baseline.json') {
|
|
16
|
+
this.path = join(cwd, filename);
|
|
17
|
+
this.data = null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async load() {
|
|
21
|
+
try {
|
|
22
|
+
const raw = await readFile(this.path, 'utf8');
|
|
23
|
+
this.data = JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
this.data = {
|
|
26
|
+
version: 1,
|
|
27
|
+
created: new Date().toISOString(),
|
|
28
|
+
updated: new Date().toISOString(),
|
|
29
|
+
dependencies: {}, // pkg -> { sha, tag, resolved }
|
|
30
|
+
tags: {}, // owner/repo#tag -> { sha, verified }
|
|
31
|
+
tarballs: {}, // url -> { sha256, size, files }
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return this.data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async save() {
|
|
38
|
+
if (!this.data) throw new Error('Baseline not loaded');
|
|
39
|
+
this.data.updated = new Date().toISOString();
|
|
40
|
+
await writeFile(this.path, JSON.stringify(this.data, null, 2) + '\n', 'utf8');
|
|
41
|
+
logger.success(`Baseline сохранён: ${this.path}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Получить сохранённый SHA зависимости */
|
|
45
|
+
getDependencySHA(pkgName) {
|
|
46
|
+
return this.data?.dependencies?.[pkgName]?.sha ?? null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Записать SHA зависимости */
|
|
50
|
+
setDependency(pkgName, info) {
|
|
51
|
+
if (!this.data) return;
|
|
52
|
+
this.data.dependencies[pkgName] = {
|
|
53
|
+
...info,
|
|
54
|
+
recorded: new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Получить сохранённый SHA тега */
|
|
59
|
+
getTagSHA(repoTag) {
|
|
60
|
+
return this.data?.tags?.[repoTag]?.sha ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Записать SHA тега */
|
|
64
|
+
setTag(repoTag, info) {
|
|
65
|
+
if (!this.data) return;
|
|
66
|
+
this.data.tags[repoTag] = {
|
|
67
|
+
...info,
|
|
68
|
+
recorded: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Получить сохранённый hash tarball */
|
|
73
|
+
getTarballHash(url) {
|
|
74
|
+
return this.data?.tarballs?.[url]?.sha256 ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Записать hash tarball */
|
|
78
|
+
setTarball(url, info) {
|
|
79
|
+
if (!this.data) return;
|
|
80
|
+
this.data.tarballs[url] = {
|
|
81
|
+
...info,
|
|
82
|
+
recorded: new Date().toISOString(),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Загрузка конфигурации .guardian.yml / .guardian.json
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
checks: {
|
|
10
|
+
shaPinning: true,
|
|
11
|
+
postinstallHooks: true,
|
|
12
|
+
tagIntegrity: true,
|
|
13
|
+
tarballIntegrity: true,
|
|
14
|
+
actionsPinning: true,
|
|
15
|
+
},
|
|
16
|
+
// Пакеты, которым разрешены lifecycle-скрипты
|
|
17
|
+
allowedLifecyclePackages: [],
|
|
18
|
+
// Известные SHA для git-зависимостей — baseline
|
|
19
|
+
baselineFile: '.guardian-baseline.json',
|
|
20
|
+
// Severity override: package → severity
|
|
21
|
+
severityOverrides: {},
|
|
22
|
+
// GitHub Actions: разрешенные owner/action без SHA pin
|
|
23
|
+
trustedActions: [
|
|
24
|
+
'actions/*', // официальные github actions
|
|
25
|
+
],
|
|
26
|
+
// Минимальный severity для CI fail
|
|
27
|
+
failOnSeverity: 'high',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Ищет .guardian.yml или .guardian.json в cwd,
|
|
32
|
+
* мержит с дефолтами.
|
|
33
|
+
*/
|
|
34
|
+
export async function loadConfig(cwdOrPath) {
|
|
35
|
+
const candidates = [
|
|
36
|
+
join(cwdOrPath, '.guardian.yml'),
|
|
37
|
+
join(cwdOrPath, '.guardian.yaml'),
|
|
38
|
+
join(cwdOrPath, '.guardian.json'),
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// Если cwdOrPath — конкретный файл
|
|
42
|
+
if (cwdOrPath.endsWith('.yml') || cwdOrPath.endsWith('.yaml') || cwdOrPath.endsWith('.json')) {
|
|
43
|
+
candidates.unshift(cwdOrPath);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const candidate of candidates) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = await readFile(candidate, 'utf8');
|
|
49
|
+
let parsed;
|
|
50
|
+
|
|
51
|
+
if (candidate.endsWith('.json')) {
|
|
52
|
+
parsed = JSON.parse(raw);
|
|
53
|
+
} else {
|
|
54
|
+
// Упрощенный YAML-парсер для плоских структур
|
|
55
|
+
// (без внешних зависимостей — zero-dependency дизайн)
|
|
56
|
+
parsed = parseSimpleYaml(raw);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return deepMerge(structuredClone(DEFAULTS), parsed);
|
|
60
|
+
} catch {
|
|
61
|
+
// файл не найден — пробуем следующий
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Конфиг не найден — дефолты
|
|
66
|
+
return structuredClone(DEFAULTS);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Минимальный YAML-парсер (поддерживает вложенные объекты, массивы, строки, bool)
|
|
71
|
+
* Не нужна внешняя зависимость — zero-dep design.
|
|
72
|
+
*/
|
|
73
|
+
function parseSimpleYaml(raw) {
|
|
74
|
+
const result = {};
|
|
75
|
+
const lines = raw.split('\n');
|
|
76
|
+
const stack = [{ indent: -1, obj: result }];
|
|
77
|
+
|
|
78
|
+
for (const line of lines) {
|
|
79
|
+
const trimmed = line.replace(/#.*$/, '').trimEnd();
|
|
80
|
+
if (!trimmed) continue;
|
|
81
|
+
|
|
82
|
+
const indent = line.search(/\S/);
|
|
83
|
+
const match = trimmed.match(/^(\s*)([^:]+):\s*(.*)$/);
|
|
84
|
+
|
|
85
|
+
if (!match) {
|
|
86
|
+
// Array item
|
|
87
|
+
const arrMatch = trimmed.match(/^(\s*)-\s+(.+)$/);
|
|
88
|
+
if (arrMatch) {
|
|
89
|
+
const parent = stack[stack.length - 1];
|
|
90
|
+
const keys = Object.keys(parent.obj);
|
|
91
|
+
const lastKey = keys[keys.length - 1];
|
|
92
|
+
if (lastKey && Array.isArray(parent.obj[lastKey])) {
|
|
93
|
+
parent.obj[lastKey].push(parseValue(arrMatch[2].trim()));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const key = match[2].trim();
|
|
100
|
+
const val = match[3].trim();
|
|
101
|
+
|
|
102
|
+
// Поднимаемся по стеку до правильного уровня вложенности
|
|
103
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
104
|
+
stack.pop();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parent = stack[stack.length - 1].obj;
|
|
108
|
+
|
|
109
|
+
if (val === '') {
|
|
110
|
+
// Вложенный объект или массив
|
|
111
|
+
parent[key] = {};
|
|
112
|
+
stack.push({ indent, obj: parent[key] });
|
|
113
|
+
} else if (val === '[]') {
|
|
114
|
+
parent[key] = [];
|
|
115
|
+
} else {
|
|
116
|
+
parent[key] = parseValue(val);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseValue(val) {
|
|
124
|
+
if (val === 'true') return true;
|
|
125
|
+
if (val === 'false') return false;
|
|
126
|
+
if (val === 'null') return null;
|
|
127
|
+
if (/^\d+$/.test(val)) return parseInt(val, 10);
|
|
128
|
+
if (/^\d+\.\d+$/.test(val)) return parseFloat(val);
|
|
129
|
+
// Убираем кавычки
|
|
130
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
131
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
132
|
+
return val.slice(1, -1);
|
|
133
|
+
}
|
|
134
|
+
return val;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function deepMerge(target, source) {
|
|
138
|
+
for (const key of Object.keys(source)) {
|
|
139
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
140
|
+
if (!target[key]) target[key] = {};
|
|
141
|
+
deepMerge(target[key], source[key]);
|
|
142
|
+
} else {
|
|
143
|
+
target[key] = source[key];
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return target;
|
|
147
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Минимальный logger с цветами (работает в Node.js и Bun)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const NO_COLOR = !!process.env.NO_COLOR || process.argv.includes('--no-color');
|
|
6
|
+
|
|
7
|
+
const c = {
|
|
8
|
+
reset: NO_COLOR ? '' : '\x1b[0m',
|
|
9
|
+
red: NO_COLOR ? '' : '\x1b[31m',
|
|
10
|
+
green: NO_COLOR ? '' : '\x1b[32m',
|
|
11
|
+
yellow: NO_COLOR ? '' : '\x1b[33m',
|
|
12
|
+
blue: NO_COLOR ? '' : '\x1b[34m',
|
|
13
|
+
cyan: NO_COLOR ? '' : '\x1b[36m',
|
|
14
|
+
gray: NO_COLOR ? '' : '\x1b[90m',
|
|
15
|
+
bold: NO_COLOR ? '' : '\x1b[1m',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const logger = {
|
|
19
|
+
info(msg) { console.log(`${c.cyan}${msg}${c.reset}`); },
|
|
20
|
+
warn(msg) { console.log(`${c.yellow}${msg}${c.reset}`); },
|
|
21
|
+
error(msg) { console.error(`${c.red}${msg}${c.reset}`); },
|
|
22
|
+
success(msg) { console.log(`${c.green}${msg}${c.reset}`); },
|
|
23
|
+
debug(msg) { if (process.env.DEBUG) console.log(`${c.gray}[debug] ${msg}${c.reset}`); },
|
|
24
|
+
section(title) {
|
|
25
|
+
console.log('');
|
|
26
|
+
console.log(`${c.bold}${c.blue}── ${title} ${'─'.repeat(Math.max(0, 50 - title.length))}${c.reset}`);
|
|
27
|
+
},
|
|
28
|
+
};
|