git-push-time 1.0.0 → 1.0.1
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/README.md +18 -8
- package/bin/index.js +372 -79
- package/lib/fix.js +260 -0
- package/lib/git.js +93 -3
- package/lib/npm.js +82 -0
- package/lib/secrets.js +87 -0
- package/lib/ui.js +409 -0
- package/package.json +3 -2
package/lib/fix.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { spawnSync, execSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { findRiskyStagedFiles } from './git.js';
|
|
5
|
+
import { scanStagedContentForSecrets } from './secrets.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Danh sách phần mở rộng của file mã nguồn / tài liệu.
|
|
9
|
+
* KHÔNG BAO GIỜ tự động thêm các file này vào .gitignore vì sẽ làm mất code dự án!
|
|
10
|
+
*/
|
|
11
|
+
const CODE_EXTENSIONS = new Set([
|
|
12
|
+
'.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx',
|
|
13
|
+
'.py', '.java', '.go', '.rs', '.c', '.cpp', '.cc', '.h', '.hpp',
|
|
14
|
+
'.cs', '.php', '.rb', '.html', '.htm', '.css', '.scss', '.sass',
|
|
15
|
+
'.less', '.vue', '.svelte', '.md', '.markdown', '.txt', '.sh',
|
|
16
|
+
'.bash', '.zsh', '.bat', '.cmd', '.ps1', '.sql'
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
function isCodeOrDocFile(filePath) {
|
|
20
|
+
const base = path.basename(filePath).toLowerCase();
|
|
21
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
22
|
+
if (base === 'package.json' || base === 'package-lock.json' || base === 'tsconfig.json') return true;
|
|
23
|
+
return CODE_EXTENSIONS.has(ext);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Tự fix an toàn khi phát hiện secret:
|
|
28
|
+
* 1. git rm --cached -r -f <file> (gỡ khỏi commit, giữ nguyên file trên máy)
|
|
29
|
+
* 2. Thêm rule tương ứng vào .gitignore (chỉ áp dụng cho file cấu hình secret, KHÔNG ignore file code)
|
|
30
|
+
* 3. Tự động stage lại .gitignore để sẵn sàng commit
|
|
31
|
+
*
|
|
32
|
+
* @param {string[]} files danh sách file cần gỡ (vd ['.env', '.npmrc'])
|
|
33
|
+
* @param {string} cwd
|
|
34
|
+
* @returns {{ fixed: string[], alreadyClean: string[], failed: {file:string, reason:string}[], gitignoreAdded: string[], codeFilesWithSecrets: string[] }}
|
|
35
|
+
*/
|
|
36
|
+
export function autoFixStagedSecrets(files = [], cwd = process.cwd()) {
|
|
37
|
+
const unique = [...new Set((files || []).map((f) => String(f).trim()).filter(Boolean))];
|
|
38
|
+
const result = {
|
|
39
|
+
fixed: [],
|
|
40
|
+
alreadyClean: [],
|
|
41
|
+
failed: [],
|
|
42
|
+
gitignoreAdded: [],
|
|
43
|
+
codeFilesWithSecrets: []
|
|
44
|
+
};
|
|
45
|
+
if (unique.length === 0) return result;
|
|
46
|
+
|
|
47
|
+
for (const file of unique) {
|
|
48
|
+
const normalized = file.replace(/\\/g, '/');
|
|
49
|
+
let ok = false;
|
|
50
|
+
let errReason = '';
|
|
51
|
+
|
|
52
|
+
// 1. Thử gỡ khỏi stage bằng git rm --cached -r -f
|
|
53
|
+
const r = spawnSync('git', ['rm', '--cached', '-r', '-f', '--', normalized], { cwd, encoding: 'utf-8' });
|
|
54
|
+
if (r.status === 0) {
|
|
55
|
+
ok = true;
|
|
56
|
+
} else {
|
|
57
|
+
const msg = String(r.stderr || r.stdout || '');
|
|
58
|
+
// Thử fallback bằng git restore --staged nếu git rm báo pathspec/not matched
|
|
59
|
+
const rRestore = spawnSync('git', ['restore', '--staged', '--', normalized], { cwd, encoding: 'utf-8' });
|
|
60
|
+
if (rRestore.status === 0) {
|
|
61
|
+
ok = true;
|
|
62
|
+
} else if (/did not match|pathspec/i.test(msg)) {
|
|
63
|
+
result.alreadyClean.push(file);
|
|
64
|
+
continue;
|
|
65
|
+
} else {
|
|
66
|
+
errReason = msg.trim().split('\n')[0] || 'unknown error';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (ok) {
|
|
71
|
+
result.fixed.push(file);
|
|
72
|
+
} else if (errReason) {
|
|
73
|
+
result.failed.push({ file, reason: errReason });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 2. Phân loại và tạo rule .gitignore cho các file config secret (bỏ qua file code)
|
|
78
|
+
const rules = [];
|
|
79
|
+
for (const f of unique) {
|
|
80
|
+
const normalized = f.replace(/\\/g, '/');
|
|
81
|
+
const base = path.basename(normalized);
|
|
82
|
+
|
|
83
|
+
if (isCodeOrDocFile(normalized)) {
|
|
84
|
+
// File mã nguồn có dính secret: gỡ khỏi stage nhưng không cho vào .gitignore
|
|
85
|
+
result.codeFilesWithSecrets.push(f);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (/^\.env(\..+)?$/i.test(base)) {
|
|
90
|
+
rules.push('.env*');
|
|
91
|
+
} else if (base.toLowerCase() === '.npmrc') {
|
|
92
|
+
rules.push('.npmrc');
|
|
93
|
+
} else if (/\.pem$|\.key$/i.test(base)) {
|
|
94
|
+
rules.push('*.pem\n*.key');
|
|
95
|
+
} else if (/id_rsa|id_ed25519|id_ecdsa|id_dsa/i.test(base)) {
|
|
96
|
+
rules.push('id_rsa*\nid_ed25519*\nid_ecdsa*');
|
|
97
|
+
} else if (/secrets?\.json$|credentials?\.json$|service-account.*\.json$/i.test(base)) {
|
|
98
|
+
rules.push('*secret*.json\n*credential*.json');
|
|
99
|
+
} else if (/\.pfx$|\.p12$/i.test(base)) {
|
|
100
|
+
rules.push('*.pfx\n*.p12');
|
|
101
|
+
} else {
|
|
102
|
+
rules.push(normalized);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
107
|
+
let existing = '';
|
|
108
|
+
try {
|
|
109
|
+
if (fs.existsSync(gitignorePath)) existing = fs.readFileSync(gitignorePath, 'utf-8');
|
|
110
|
+
} catch { existing = ''; }
|
|
111
|
+
|
|
112
|
+
const existingLines = new Set(existing.split(/\r?\n/).map((l) => l.trim()));
|
|
113
|
+
const toAdd = [];
|
|
114
|
+
for (const chunk of rules) {
|
|
115
|
+
for (const line of String(chunk).split(/\r?\n/)) {
|
|
116
|
+
const t = line.trim();
|
|
117
|
+
if (t && !existingLines.has(t)) {
|
|
118
|
+
toAdd.push(t);
|
|
119
|
+
existingLines.add(t);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (toAdd.length > 0) {
|
|
125
|
+
try {
|
|
126
|
+
const prefix = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
127
|
+
fs.appendFileSync(gitignorePath, prefix + toAdd.join('\n') + '\n', 'utf-8');
|
|
128
|
+
result.gitignoreAdded = toAdd;
|
|
129
|
+
|
|
130
|
+
// Tự động stage ngay .gitignore để không bị sót khi commit
|
|
131
|
+
spawnSync('git', ['add', '.gitignore'], { cwd });
|
|
132
|
+
} catch {
|
|
133
|
+
// Bỏ qua lỗi ghi file
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Kiểm tra còn gì để commit sau khi auto-fix không */
|
|
141
|
+
export function hasStagedChanges(cwd = process.cwd()) {
|
|
142
|
+
try {
|
|
143
|
+
const out = execSync('git diff --cached --name-only', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
144
|
+
return out.trim().length > 0;
|
|
145
|
+
} catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Quét các file secret phổ biến trong thư mục làm việc nhưng chưa được ignore trong .gitignore
|
|
152
|
+
*/
|
|
153
|
+
export function findRiskyWorkingTreeFiles(cwd = process.cwd()) {
|
|
154
|
+
const denyNames = [
|
|
155
|
+
'.env', '.env.local', '.env.development', '.env.production',
|
|
156
|
+
'.npmrc', 'secrets.json', 'secret.json', 'credentials.json',
|
|
157
|
+
'id_rsa', 'id_ed25519'
|
|
158
|
+
];
|
|
159
|
+
const found = [];
|
|
160
|
+
for (const name of denyNames) {
|
|
161
|
+
const fullPath = path.join(cwd, name);
|
|
162
|
+
if (fs.existsSync(fullPath)) {
|
|
163
|
+
const r = spawnSync('git', ['check-ignore', '-q', name], { cwd });
|
|
164
|
+
if (r.status !== 0) {
|
|
165
|
+
found.push(name);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return found;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Tìm upstream ref của branch hiện tại (VD: origin/main hoặc origin/master)
|
|
174
|
+
*/
|
|
175
|
+
export function getUpstreamRef(branch = 'main', cwd = process.cwd()) {
|
|
176
|
+
try {
|
|
177
|
+
const upstream = execSync(`git rev-parse --abbrev-ref ${branch}@{u}`, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
178
|
+
if (upstream) return upstream;
|
|
179
|
+
} catch {}
|
|
180
|
+
|
|
181
|
+
const candidates = [`origin/${branch}`, 'origin/main', 'origin/master'];
|
|
182
|
+
for (const ref of candidates) {
|
|
183
|
+
try {
|
|
184
|
+
execSync(`git rev-parse --verify ${ref}`, { cwd, stdio: 'ignore' });
|
|
185
|
+
return ref;
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Lấy danh sách các commit unpushed (chưa đẩy lên remote)
|
|
193
|
+
*/
|
|
194
|
+
export function getUnpushedCommits(branch = 'main', cwd = process.cwd()) {
|
|
195
|
+
const upstream = getUpstreamRef(branch, cwd);
|
|
196
|
+
if (!upstream) {
|
|
197
|
+
try {
|
|
198
|
+
const log = execSync('git log --oneline', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
199
|
+
return log.trim().split(/\r?\n/).filter(Boolean);
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
try {
|
|
205
|
+
const log = execSync(`git log ${upstream}..HEAD --oneline`, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
206
|
+
return log.trim().split(/\r?\n/).filter(Boolean);
|
|
207
|
+
} catch {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Squash các commit chưa push thành 1 commit duy nhất sạch sẽ,
|
|
214
|
+
* loại bỏ hoàn toàn dấu vết secret ở các commit trung gian (ví dụ commit cũ chứa .npmrc hay .env).
|
|
215
|
+
*/
|
|
216
|
+
export function squashUnpushedCommits(branch = 'main', commitMsg = 'Clean commit without secrets', dateFormatted = null, cwd = process.cwd()) {
|
|
217
|
+
const upstream = getUpstreamRef(branch, cwd);
|
|
218
|
+
try {
|
|
219
|
+
if (upstream) {
|
|
220
|
+
const r1 = spawnSync('git', ['reset', '--soft', upstream], { cwd, encoding: 'utf-8' });
|
|
221
|
+
if (r1.status !== 0) {
|
|
222
|
+
throw new Error(r1.stderr || 'Không thể reset về upstream');
|
|
223
|
+
}
|
|
224
|
+
} else {
|
|
225
|
+
// Chưa có upstream (repo mới hoàn toàn)
|
|
226
|
+
const rootCommit = execSync('git rev-list --max-parents=0 HEAD', { cwd, encoding: 'utf-8' }).trim().split(/\s+/)[0];
|
|
227
|
+
if (rootCommit) {
|
|
228
|
+
spawnSync('git', ['reset', '--soft', rootCommit], { cwd });
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Quét lại và loại bỏ bất kỳ secret file nào còn sót trong staged
|
|
233
|
+
const risky = findRiskyStagedFiles(cwd);
|
|
234
|
+
const findings = scanStagedContentForSecrets(cwd);
|
|
235
|
+
const allSecretFiles = [...new Set([...risky, ...findings.map((f) => f.file)])];
|
|
236
|
+
if (allSecretFiles.length > 0) {
|
|
237
|
+
autoFixStagedSecrets(allSecretFiles, cwd);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Đảm bảo .gitignore được stage
|
|
241
|
+
spawnSync('git', ['add', '.gitignore'], { cwd });
|
|
242
|
+
|
|
243
|
+
// Tạo commit mới sạch
|
|
244
|
+
const env = { ...process.env };
|
|
245
|
+
if (dateFormatted) {
|
|
246
|
+
env.GIT_AUTHOR_DATE = dateFormatted;
|
|
247
|
+
env.GIT_COMMITTER_DATE = dateFormatted;
|
|
248
|
+
}
|
|
249
|
+
const rCommit = spawnSync('git', ['commit', '-m', commitMsg], { cwd, env, encoding: 'utf-8' });
|
|
250
|
+
if (rCommit.status !== 0) {
|
|
251
|
+
const msg = rCommit.stderr || rCommit.stdout || '';
|
|
252
|
+
if (!msg.includes('nothing to commit')) {
|
|
253
|
+
throw new Error(msg || 'Lỗi khi tạo commit squash');
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { ok: true };
|
|
257
|
+
} catch (err) {
|
|
258
|
+
return { ok: false, error: err.message };
|
|
259
|
+
}
|
|
260
|
+
}
|
package/lib/git.js
CHANGED
|
@@ -132,7 +132,7 @@ export function commitWithCustomDate(message, dateFormatted, cwd = process.cwd()
|
|
|
132
132
|
* Đẩy code lên Git remote repo
|
|
133
133
|
* @param {string} branch Tên branch (VD: main)
|
|
134
134
|
* @param {boolean} force Có sử dụng --force hay không
|
|
135
|
-
* @param {string} cwd
|
|
135
|
+
* @param {string} cwd
|
|
136
136
|
*/
|
|
137
137
|
export function pushToRemote(branch = 'main', force = false, cwd = process.cwd()) {
|
|
138
138
|
const args = ['push', '-u', 'origin', branch];
|
|
@@ -147,7 +147,97 @@ export function pushToRemote(branch = 'main', force = false, cwd = process.cwd()
|
|
|
147
147
|
});
|
|
148
148
|
|
|
149
149
|
if (result.status !== 0) {
|
|
150
|
-
const
|
|
151
|
-
|
|
150
|
+
const raw = (result.stderr || result.stdout || 'Lỗi không xác định khi git push').trim();
|
|
151
|
+
const parsed = parsePushError(raw);
|
|
152
|
+
const err = new Error(raw);
|
|
153
|
+
err.code = parsed.code;
|
|
154
|
+
err.details = parsed;
|
|
155
|
+
throw err;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Phân loại lỗi khi `git push` thất bại để CLI hiển thị hướng dẫn thân thiện.
|
|
161
|
+
* Đặc biệt xử lý GitHub Push Protection (GH013) khi push dính secret.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} rawOutput stderr/stdout từ git push
|
|
164
|
+
* @returns {{ code: string, secretTypes: string[], files: string[], unblockUrl: string | null, resolveUrl: string | null }}
|
|
165
|
+
*/
|
|
166
|
+
export function parsePushError(rawOutput = '') {
|
|
167
|
+
const text = String(rawOutput || '');
|
|
168
|
+
const lower = text.toLowerCase();
|
|
169
|
+
|
|
170
|
+
const isSecretBlocked =
|
|
171
|
+
/gh0?13/.test(text) ||
|
|
172
|
+
lower.includes('push protection') ||
|
|
173
|
+
lower.includes('push cannot contain secrets') ||
|
|
174
|
+
lower.includes('secret scanning') ||
|
|
175
|
+
lower.includes('repository rule violations') ||
|
|
176
|
+
(lower.includes('blocked') && lower.includes('secret')) ||
|
|
177
|
+
(lower.includes('detected') && lower.includes('secret'));
|
|
178
|
+
|
|
179
|
+
if (isSecretBlocked) {
|
|
180
|
+
// VD: "—— npm Access Token ——" hoặc "— Generic API Key —"
|
|
181
|
+
const secretTypes = [];
|
|
182
|
+
const typeRegex = /[—\-]{2,}\s*([A-Za-z0-9 _.'()/+-]+?(?:token|key|secret|password|private key)[A-Za-z0-9 _.'()/+-]*?)\s*[—\-]{2,}/gi;
|
|
183
|
+
let m;
|
|
184
|
+
while ((m = typeRegex.exec(text)) !== null) {
|
|
185
|
+
const name = m[1].trim();
|
|
186
|
+
if (name && !secretTypes.includes(name)) secretTypes.push(name);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// VD: "- commit abc123: .npmrc:1" hoặc "file: .env"
|
|
190
|
+
const files = [];
|
|
191
|
+
const fileRegex = /(?:commit\s+[0-9a-f]{4,40}\s*:\s*)([^\s:]+\.[A-Za-z0-9_.-]+)/gi;
|
|
192
|
+
while ((m = fileRegex.exec(text)) !== null) {
|
|
193
|
+
if (!files.includes(m[1])) files.push(m[1]);
|
|
194
|
+
}
|
|
195
|
+
// fallback: tìm tên file khả nghi trong output
|
|
196
|
+
const fallbackRegex = /(^|\s)(`?\.?(env|npmrc|id_rsa|id_ed25519|.*\.pem|.*\.key))\b/gi;
|
|
197
|
+
while ((m = fallbackRegex.exec(text)) !== null) {
|
|
198
|
+
const f = m[2].replace(/`/g, '');
|
|
199
|
+
if (!files.includes(f)) files.push(f);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const unblockMatch = text.match(/https:\/\/github\.com\/[^\s) '"]*unblock-secret[^\s) '"]*/i);
|
|
203
|
+
const resolveMatch = text.match(/https:\/\/docs\.github\.com\/[^\s) '"]*push-protection[^\s) '"]*/i);
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
code: 'SECRET_BLOCKED',
|
|
207
|
+
secretTypes,
|
|
208
|
+
files,
|
|
209
|
+
unblockUrl: unblockMatch ? unblockMatch[0] : null,
|
|
210
|
+
resolveUrl: resolveMatch ? resolveMatch[0] : 'https://docs.github.com/code-security/secret-scanning/pushing-a-branch-blocked-by-push-protection'
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (lower.includes('authentication failed') || lower.includes('could not read username') || lower.includes('invalid username or password') || lower.includes('permission denied')) {
|
|
215
|
+
return { code: 'AUTH_FAILED', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
|
|
216
|
+
}
|
|
217
|
+
if (lower.includes('non-fast-forward') || lower.includes('failed to push some refs') || lower.includes('fetch first') || lower.includes('rejected')) {
|
|
218
|
+
return { code: 'NON_FAST_FORWARD', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
|
|
219
|
+
}
|
|
220
|
+
if (lower.includes('repository not found') || lower.includes('could not resolve host') || lower.includes('network') || lower.includes('unable to access')) {
|
|
221
|
+
return { code: 'REMOTE_OR_NETWORK', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
|
|
222
|
+
}
|
|
223
|
+
return { code: 'UNKNOWN', secretTypes: [], files: [], unblockUrl: null, resolveUrl: null };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Quét nhanh staged files để cảnh báo TRƯỚC khi commit/push.
|
|
228
|
+
* Chỉ cảnh báo, không chặn — tránh false-positive.
|
|
229
|
+
* @param {string} cwd
|
|
230
|
+
* @returns {string[]} danh sách file khả nghi
|
|
231
|
+
*/
|
|
232
|
+
export function findRiskyStagedFiles(cwd = process.cwd()) {
|
|
233
|
+
const denyList = [/^\.env(\..+)?$/, /^\.npmrc$/, /_rsa$/, /_ed25519$/, /\.pem$/, /\.key$/, /secrets?\.json$/i];
|
|
234
|
+
try {
|
|
235
|
+
const out = execSync('git diff --cached --name-only', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
236
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean).filter((f) => {
|
|
237
|
+
const base = path.basename(f);
|
|
238
|
+
return denyList.some((re) => re.test(base) || re.test(f));
|
|
239
|
+
});
|
|
240
|
+
} catch {
|
|
241
|
+
return [];
|
|
152
242
|
}
|
|
153
243
|
}
|
package/lib/npm.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { spawnSync, execSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
|
|
5
|
+
export function checkNpmInstalled() {
|
|
6
|
+
try {
|
|
7
|
+
execSync('npm --version', { stdio: 'ignore' });
|
|
8
|
+
return true;
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getPackageInfo(cwd = process.cwd()) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8');
|
|
17
|
+
const pkg = JSON.parse(raw);
|
|
18
|
+
return { name: pkg.name || '(chưa đặt tên)', version: pkg.version || '0.0.0', hasPublishConfig: !!pkg.publishConfig };
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Đang login npm chưa? (không in token ra) */
|
|
25
|
+
export function checkNpmLogin(cwd = process.cwd()) {
|
|
26
|
+
const r = spawnSync('npm', ['whoami'], { cwd, encoding: 'utf-8' });
|
|
27
|
+
if (r.status === 0) return { loggedIn: true, user: r.stdout.trim() };
|
|
28
|
+
return { loggedIn: false, user: null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cảnh báo nếu .npmrc chứa token đang bị track trong git.
|
|
33
|
+
* Chỉ kiểm tra tên file, KHÔNG đọc/in token.
|
|
34
|
+
*/
|
|
35
|
+
export function isNpmrcTracked(cwd = process.cwd()) {
|
|
36
|
+
try {
|
|
37
|
+
const out = execSync('git ls-files', { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
38
|
+
return out.split('\n').map((s) => s.trim()).includes('.npmrc');
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Publish lên npm. Token lấy từ `npm login` hoặc env NPM_TOKEN,
|
|
46
|
+
* KHÔNG nhận token qua flag để tránh lộ trong shell history.
|
|
47
|
+
*/
|
|
48
|
+
export function publishToNpm({ access = 'public', dryRun = false, cwd = process.cwd() } = {}) {
|
|
49
|
+
const args = ['publish'];
|
|
50
|
+
if (access) args.push('--access', access);
|
|
51
|
+
if (dryRun) args.push('--dry-run');
|
|
52
|
+
|
|
53
|
+
const env = { ...process.env };
|
|
54
|
+
// Ưu tiên NPM_TOKEN từ env nếu có (CI). Không log giá trị này.
|
|
55
|
+
if (env.NPM_TOKEN && !env.NODE_AUTH_TOKEN) {
|
|
56
|
+
env.NODE_AUTH_TOKEN = env.NPM_TOKEN;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const r = spawnSync('npm', args, { cwd, encoding: 'utf-8', env });
|
|
60
|
+
const output = ((r.stdout || '') + '\n' + (r.stderr || '')).trim();
|
|
61
|
+
if (r.status === 0) {
|
|
62
|
+
return { ok: true, output };
|
|
63
|
+
}
|
|
64
|
+
return { ok: false, output, parsed: parseNpmError(output) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseNpmError(output = '') {
|
|
68
|
+
const lower = output.toLowerCase();
|
|
69
|
+
if (lower.includes('eneedauth') || lower.includes('need auth') || lower.includes('401') || lower.includes('you must be logged in')) {
|
|
70
|
+
return { code: 'NEED_AUTH', hint: 'Chưa login npm. Chạy: npm login (hoặc set env NPM_TOKEN khi chạy CI)' };
|
|
71
|
+
}
|
|
72
|
+
if (lower.includes('e403') || lower.includes('403 forbidden')) {
|
|
73
|
+
return { code: 'FORBIDDEN', hint: 'Không có quyền publish tên này (bị trùng tên hoặc chưa được add collaborator). Đổi name trong package.json hoặc xin quyền.' };
|
|
74
|
+
}
|
|
75
|
+
if (lower.includes('epublishconflict') || lower.includes('cannot publish over previously published')) {
|
|
76
|
+
return { code: 'VERSION_EXISTS', hint: 'Version này đã tồn tại. Tăng version: npm version patch rồi publish lại.' };
|
|
77
|
+
}
|
|
78
|
+
if (lower.includes('e400') || lower.includes('invalid package name') || lower.includes('invalid name')) {
|
|
79
|
+
return { code: 'BAD_NAME', hint: 'Tên package chưa hợp lệ trong package.json (chữ thường, không dấu cách).' };
|
|
80
|
+
}
|
|
81
|
+
return { code: 'UNKNOWN', hint: 'Xem log npm phía trên để biết chi tiết.' };
|
|
82
|
+
}
|
package/lib/secrets.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
// Pattern quét secret phổ biến. Giữ ở mức precision cao để tránh false-positive.
|
|
4
|
+
export const SECRET_PATTERNS = [
|
|
5
|
+
{ id: 'npm-token', label: 'npm Access Token', regex: /npm_[A-Za-z0-9]{8,}/ },
|
|
6
|
+
{ id: 'npm-authtoken', label: '_authToken trong .npmrc', regex: /_authToken\s*=\s*\S+/i },
|
|
7
|
+
{ id: 'github-token', label: 'GitHub Token (ghp_/gho_/github_pat_)', regex: /\b(ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{40,})\b/ },
|
|
8
|
+
{ id: 'aws-key', label: 'AWS Access Key (AKIA...)', regex: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
9
|
+
{ id: 'aws-secret', label: 'AWS Secret Key', regex: /aws_secret_access_key\s*=\s*\S+/i },
|
|
10
|
+
{ id: 'private-key', label: 'Private Key (PEM)', regex: /-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ },
|
|
11
|
+
{ id: 'openai-key', label: 'OpenAI API Key', regex: /\bsk-(proj-)?[A-Za-z0-9_-]{20,}\b/ },
|
|
12
|
+
{ id: 'slack-token', label: 'Slack Token', regex: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
13
|
+
{ id: 'google-key', label: 'Google API Key', regex: /\bAIza[0-9A-Za-z_-]{30,}\b/ },
|
|
14
|
+
{ id: 'generic-password', label: 'Password gán cứng', regex: /\b(password|passwd|pwd)\s*[:=]\s*['"][^'"]{4,}['"]/i },
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const SKIP_DIRS = ['node_modules/', '.git/', 'dist/', 'build/', '.next/'];
|
|
18
|
+
const SKIP_FILES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml'];
|
|
19
|
+
const MAX_FILE_BYTES = 512 * 1024; // bỏ qua file staged > 512KB
|
|
20
|
+
const MAX_MATCHES_PER_FILE = 3;
|
|
21
|
+
|
|
22
|
+
function listStagedFiles(cwd) {
|
|
23
|
+
try {
|
|
24
|
+
const out = execSync('git diff --cached --name-only --diff-filter=ACM', {
|
|
25
|
+
cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
|
|
26
|
+
});
|
|
27
|
+
return out.split('\n').map((s) => s.trim()).filter(Boolean);
|
|
28
|
+
} catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readStagedContent(file, cwd) {
|
|
34
|
+
try {
|
|
35
|
+
// Đọc nội dung đã stage (git show :path), không phải file ngoài disk
|
|
36
|
+
const content = execSync(`git show :"${file.replace(/"/g, '')}"`, {
|
|
37
|
+
cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: MAX_FILE_BYTES * 2
|
|
38
|
+
});
|
|
39
|
+
if (content.length > MAX_FILE_BYTES) return null; // file quá lớn, bỏ qua
|
|
40
|
+
if (content.includes('\0')) return null; // file binary
|
|
41
|
+
return content;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function redactPreview(line) {
|
|
48
|
+
const s = line.trim().slice(0, 120);
|
|
49
|
+
// Che giữa secret, chỉ chừa 4 ký tự đầu/cuối
|
|
50
|
+
return s.replace(/([A-Za-z0-9_.-]{4})[A-Za-z0-9_.-]{6,}([A-Za-z0-9_.-]{2})?/g, '$1•••$2');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Quét nội dung các file đã stage để tìm secret.
|
|
55
|
+
* @param {string} cwd
|
|
56
|
+
* @returns {{ file: string, type: string, line: number, preview: string }[]}
|
|
57
|
+
*/
|
|
58
|
+
export function scanStagedContentForSecrets(cwd = process.cwd()) {
|
|
59
|
+
const findings = [];
|
|
60
|
+
const files = listStagedFiles(cwd).filter((f) => {
|
|
61
|
+
if (SKIP_DIRS.some((d) => f.startsWith(d))) return false;
|
|
62
|
+
if (SKIP_FILES.includes(f.split('/').pop())) return false;
|
|
63
|
+
return true;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
for (const file of files.slice(0, 100)) { // giới hạn 100 file để nhanh
|
|
67
|
+
const content = readStagedContent(file, cwd);
|
|
68
|
+
if (!content) continue;
|
|
69
|
+
const lines = content.split('\n');
|
|
70
|
+
let matchesInFile = 0;
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const line = lines[i];
|
|
73
|
+
if (!line || line.length > 2000) continue;
|
|
74
|
+
// Bỏ qua comment ví dụ trong docs? vẫn báo để user tự quyết
|
|
75
|
+
for (const p of SECRET_PATTERNS) {
|
|
76
|
+
if (p.regex.test(line)) {
|
|
77
|
+
findings.push({ file, type: p.label, line: i + 1, preview: redactPreview(line) });
|
|
78
|
+
matchesInFile++;
|
|
79
|
+
break; // mỗi dòng chỉ báo 1 lần
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (matchesInFile >= MAX_MATCHES_PER_FILE) break;
|
|
83
|
+
if (findings.length >= 20) return findings; // chặn spam
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return findings;
|
|
87
|
+
}
|