git-push-time 1.0.3 → 1.1.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/.github/workflows/npm-publish.yml +33 -0
- package/README.md +215 -134
- package/bin/index.js +337 -1004
- package/lib/commands/edit-time.js +141 -0
- package/lib/commands/fix.js +113 -0
- package/lib/commands/github.js +439 -0
- package/lib/commands/pull.js +89 -0
- package/lib/commands/push.js +673 -0
- package/lib/config.js +220 -0
- package/lib/fix.js +286 -260
- package/lib/git.js +492 -452
- package/lib/github.js +322 -0
- package/lib/prompt-helpers.js +62 -0
- package/lib/secrets.js +92 -87
- package/lib/ui.js +804 -728
- package/lib/utils.js +120 -106
- package/package.json +35 -32
package/lib/github.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { getGitHubToken, saveGitHubToken } from './config.js';
|
|
3
|
+
|
|
4
|
+
const GITHUB_API_BASE = 'https://api.github.com';
|
|
5
|
+
const GITHUB_OAUTH_CLIENT_ID = '178c6fc778ccc68e1d6a';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mở URL trên trình duyệt web mặc định của hệ thống
|
|
9
|
+
* @param {string} url
|
|
10
|
+
*/
|
|
11
|
+
export function openBrowser(url) {
|
|
12
|
+
try {
|
|
13
|
+
const platform = process.platform;
|
|
14
|
+
if (platform === 'win32') {
|
|
15
|
+
spawn('cmd', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore' });
|
|
16
|
+
} else if (platform === 'darwin') {
|
|
17
|
+
spawn('open', [url], { detached: true, stdio: 'ignore' });
|
|
18
|
+
} else {
|
|
19
|
+
spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Khởi tạo GitHub OAuth Device Authorization Flow
|
|
29
|
+
* @returns {Promise<{ ok: boolean, data?: { device_code: string, user_code: string, verification_uri: string, expires_in: number, interval: number }, error?: string }>}
|
|
30
|
+
*/
|
|
31
|
+
export async function initiateDeviceFlow() {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch('https://github.com/login/device/code', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: {
|
|
36
|
+
'Accept': 'application/json',
|
|
37
|
+
'Content-Type': 'application/json'
|
|
38
|
+
},
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
client_id: GITHUB_OAUTH_CLIENT_ID,
|
|
41
|
+
scope: 'repo read:org gist'
|
|
42
|
+
})
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
const err = await res.json().catch(() => ({}));
|
|
47
|
+
return { ok: false, error: err.error_description || err.message || `Lỗi API (${res.status})` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const data = await res.json();
|
|
51
|
+
return { ok: true, data };
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return { ok: false, error: `Lỗi kết nối mạng: ${err.message}` };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Polling kiểm tra người dùng đã Authorize trên GitHub chưa
|
|
59
|
+
* @param {string} deviceCode
|
|
60
|
+
* @param {number} intervalSeconds
|
|
61
|
+
* @param {number} expiresInSeconds
|
|
62
|
+
* @param {(status: string) => void} onStatusUpdate
|
|
63
|
+
* @returns {Promise<{ ok: boolean, token?: string, error?: string }>}
|
|
64
|
+
*/
|
|
65
|
+
export async function pollDeviceToken(deviceCode, intervalSeconds = 5, expiresInSeconds = 900, onStatusUpdate = null) {
|
|
66
|
+
let interval = Math.max(2, intervalSeconds);
|
|
67
|
+
const startTime = Date.now();
|
|
68
|
+
const maxTime = expiresInSeconds * 1000;
|
|
69
|
+
let consecutiveNetErrors = 0;
|
|
70
|
+
|
|
71
|
+
while (Date.now() - startTime < maxTime) {
|
|
72
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1000));
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch('https://github.com/login/oauth/access_token', {
|
|
76
|
+
method: 'POST',
|
|
77
|
+
headers: {
|
|
78
|
+
'Accept': 'application/json',
|
|
79
|
+
'Content-Type': 'application/json'
|
|
80
|
+
},
|
|
81
|
+
body: JSON.stringify({
|
|
82
|
+
client_id: GITHUB_OAUTH_CLIENT_ID,
|
|
83
|
+
device_code: deviceCode,
|
|
84
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
|
|
85
|
+
})
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
consecutiveNetErrors = 0;
|
|
89
|
+
const data = await res.json();
|
|
90
|
+
|
|
91
|
+
if (data.access_token) {
|
|
92
|
+
return { ok: true, token: data.access_token };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (data.error === 'authorization_pending') {
|
|
96
|
+
if (onStatusUpdate) onStatusUpdate('pending');
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (data.error === 'slow_down') {
|
|
101
|
+
interval = (data.interval || interval) + 5;
|
|
102
|
+
if (onStatusUpdate) onStatusUpdate('slow_down');
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (data.error === 'expired_token') {
|
|
107
|
+
return { ok: false, error: 'Mã xác thực đã hết hạn. Vui lòng thử lại.' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (data.error === 'access_denied') {
|
|
111
|
+
return { ok: false, error: 'Bạn đã từ chối cấp quyền trên GitHub.' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { ok: false, error: data.error_description || data.error || 'Lỗi xác thực không xác định' };
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// Mạng chập chờn: thử lại vài lần, lỗi liên tục thì thoát sớm thay vì chờ hết 15 phút
|
|
117
|
+
consecutiveNetErrors++;
|
|
118
|
+
if (consecutiveNetErrors >= 5) {
|
|
119
|
+
return { ok: false, error: `Lỗi kết nối mạng lặp lại (${err.message}). Vui lòng kiểm tra mạng rồi thử lại.` };
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { ok: false, error: 'Hết thời gian chờ cấp quyền (Timeout).' };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Tạo headers chuẩn cho GitHub REST API
|
|
130
|
+
* @param {string} token
|
|
131
|
+
* @returns {Record<string, string>}
|
|
132
|
+
*/
|
|
133
|
+
function getApiHeaders(token) {
|
|
134
|
+
return {
|
|
135
|
+
'Accept': 'application/vnd.github+json',
|
|
136
|
+
'Authorization': `Bearer ${token}`,
|
|
137
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
138
|
+
'User-Agent': 'git-push-time-cli'
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Kiểm tra token và lấy thông tin tài khoản GitHub
|
|
144
|
+
* @param {string} token
|
|
145
|
+
* @returns {Promise<{ ok: boolean, user?: any, error?: string }>}
|
|
146
|
+
*/
|
|
147
|
+
export async function verifyAndFetchUser(token) {
|
|
148
|
+
if (!token || !token.trim()) {
|
|
149
|
+
return { ok: false, error: 'Token không được để trống.' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const res = await fetch(`${GITHUB_API_BASE}/user`, {
|
|
154
|
+
method: 'GET',
|
|
155
|
+
headers: getApiHeaders(token.trim())
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (res.status === 401) {
|
|
159
|
+
return { ok: false, error: 'Token không hợp lệ hoặc đã hết hạn (401 Unauthorized).' };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
const errData = await res.json().catch(() => ({}));
|
|
164
|
+
return { ok: false, error: errData.message || `Lỗi kết nối GitHub API (${res.status})` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const user = await res.json();
|
|
168
|
+
return { ok: true, user };
|
|
169
|
+
} catch (err) {
|
|
170
|
+
return { ok: false, error: `Lỗi kết nối mạng: ${err.message}` };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Lấy thông tin user hiện tại từ token đang kích hoạt
|
|
176
|
+
* @returns {Promise<{ authenticated: boolean, user?: any, source?: string, token?: string, error?: string }>}
|
|
177
|
+
*/
|
|
178
|
+
export async function getCurrentGitHubUser() {
|
|
179
|
+
const { token, source, user: cachedUser } = getGitHubToken();
|
|
180
|
+
if (!token) {
|
|
181
|
+
return { authenticated: false, error: 'Chưa kết nối tài khoản GitHub' };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Thử verify với API
|
|
185
|
+
const check = await verifyAndFetchUser(token);
|
|
186
|
+
if (!check.ok) {
|
|
187
|
+
// Nếu lỗi mạng nhưng có cache user
|
|
188
|
+
if (cachedUser) {
|
|
189
|
+
return { authenticated: true, user: cachedUser, token, source, offline: true };
|
|
190
|
+
}
|
|
191
|
+
return { authenticated: false, error: check.error, source };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Cập nhật lại cache user vào config — chỉ ghi khi có thay đổi để tránh I/O mỗi lần render menu
|
|
195
|
+
if (source === 'config') {
|
|
196
|
+
const cachedLogin = cachedUser?.login;
|
|
197
|
+
if (!cachedLogin || cachedLogin !== check.user?.login || JSON.stringify(cachedUser) !== JSON.stringify({
|
|
198
|
+
login: check.user.login,
|
|
199
|
+
name: check.user.name,
|
|
200
|
+
avatar_url: check.user.avatar_url,
|
|
201
|
+
email: check.user.email,
|
|
202
|
+
bio: check.user.bio,
|
|
203
|
+
public_repos: check.user.public_repos,
|
|
204
|
+
total_private_repos: check.user.total_private_repos,
|
|
205
|
+
html_url: check.user.html_url
|
|
206
|
+
})) {
|
|
207
|
+
saveGitHubToken(token, check.user);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { authenticated: true, user: check.user, token, source };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Lấy danh sách repository của user hiện tại
|
|
216
|
+
* @param {object} options
|
|
217
|
+
* @param {number} options.limit Số lượng repo tối đa
|
|
218
|
+
* @param {'all' | 'owner' | 'public' | 'private'} options.type
|
|
219
|
+
* @returns {Promise<{ ok: boolean, repos?: any[], error?: string }>}
|
|
220
|
+
*/
|
|
221
|
+
export async function listUserRepos({ limit = 30, type = 'owner' } = {}) {
|
|
222
|
+
const { token } = getGitHubToken();
|
|
223
|
+
if (!token) {
|
|
224
|
+
return { ok: false, error: 'Chưa có GitHub Token. Vui lòng kết nối tài khoản trước.' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
const url = new URL(`${GITHUB_API_BASE}/user/repos`);
|
|
229
|
+
url.searchParams.set('sort', 'updated');
|
|
230
|
+
url.searchParams.set('direction', 'desc');
|
|
231
|
+
url.searchParams.set('per_page', String(Math.min(100, Math.max(1, limit))));
|
|
232
|
+
url.searchParams.set('type', type);
|
|
233
|
+
|
|
234
|
+
const res = await fetch(url.toString(), {
|
|
235
|
+
method: 'GET',
|
|
236
|
+
headers: getApiHeaders(token)
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
const errData = await res.json().catch(() => ({}));
|
|
241
|
+
return { ok: false, error: errData.message || `Không thể tải danh sách repo (${res.status})` };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const repos = await res.json();
|
|
245
|
+
return { ok: true, repos };
|
|
246
|
+
} catch (err) {
|
|
247
|
+
return { ok: false, error: `Lỗi kết nối: ${err.message}` };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Tạo repository mới trên GitHub
|
|
253
|
+
* @param {object} params
|
|
254
|
+
* @param {string} params.name Tên repo (bắt buộc)
|
|
255
|
+
* @param {string} [params.description] Mô tả repo
|
|
256
|
+
* @param {boolean} [params.isPrivate] Repo riêng tư hay công khai
|
|
257
|
+
* @param {boolean} [params.autoInit] Có tự tạo README.md ban đầu không (mặc định false để push project có sẵn)
|
|
258
|
+
* @returns {Promise<{ ok: boolean, repo?: any, error?: string }>}
|
|
259
|
+
*/
|
|
260
|
+
export async function createGitHubRepo({ name, description = '', isPrivate = false, autoInit = false }) {
|
|
261
|
+
const { token } = getGitHubToken();
|
|
262
|
+
if (!token) {
|
|
263
|
+
return { ok: false, error: 'Chưa có GitHub Token. Vui lòng kết nối tài khoản trước.' };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const cleanName = String(name || '').trim();
|
|
267
|
+
if (!cleanName) {
|
|
268
|
+
return { ok: false, error: 'Tên repository không được để trống.' };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (!/^[a-zA-Z0-9_.-]+$/.test(cleanName)) {
|
|
272
|
+
return { ok: false, error: 'Tên repo chỉ được chứa chữ cái, số, dấu gạch nối (-), gạch dưới (_), hoặc dấu chấm (.).' };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const payload = {
|
|
277
|
+
name: cleanName,
|
|
278
|
+
description: (description || '').trim(),
|
|
279
|
+
private: Boolean(isPrivate),
|
|
280
|
+
auto_init: Boolean(autoInit)
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const res = await fetch(`${GITHUB_API_BASE}/user/repos`, {
|
|
284
|
+
method: 'POST',
|
|
285
|
+
headers: {
|
|
286
|
+
...getApiHeaders(token),
|
|
287
|
+
'Content-Type': 'application/json'
|
|
288
|
+
},
|
|
289
|
+
body: JSON.stringify(payload)
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
if (res.status === 422) {
|
|
293
|
+
const errData = await res.json().catch(() => ({}));
|
|
294
|
+
const msg = (errData.errors && errData.errors[0]?.message) || errData.message || 'Tên repository đã tồn tại hoặc không hợp lệ.';
|
|
295
|
+
return { ok: false, error: `Không thể tạo repo: ${msg}` };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (!res.ok) {
|
|
299
|
+
const errData = await res.json().catch(() => ({}));
|
|
300
|
+
return { ok: false, error: errData.message || `Lỗi từ GitHub API (${res.status})` };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const repo = await res.json();
|
|
304
|
+
return {
|
|
305
|
+
ok: true,
|
|
306
|
+
repo: {
|
|
307
|
+
id: repo.id,
|
|
308
|
+
name: repo.name,
|
|
309
|
+
fullName: repo.full_name,
|
|
310
|
+
isPrivate: repo.private,
|
|
311
|
+
description: repo.description || '',
|
|
312
|
+
htmlUrl: repo.html_url,
|
|
313
|
+
cloneUrl: repo.clone_url,
|
|
314
|
+
sshUrl: repo.ssh_url,
|
|
315
|
+
defaultBranch: repo.default_branch || 'main',
|
|
316
|
+
createdAt: repo.created_at
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
} catch (err) {
|
|
320
|
+
return { ok: false, error: `Lỗi kết nối khi tạo repo: ${err.message}` };
|
|
321
|
+
}
|
|
322
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { input, select } from '@inquirer/prompts';
|
|
2
|
+
|
|
3
|
+
export const BACK = '__back__';
|
|
4
|
+
export const BACK_TOKENS = new Set(['..', '/back', '<<']);
|
|
5
|
+
|
|
6
|
+
export function isBackInput(v) {
|
|
7
|
+
return BACK_TOKENS.has(String(v || '').trim());
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Prompt select có sẵn lựa chọn « Quay lại
|
|
12
|
+
* @param {object} params
|
|
13
|
+
* @returns {Promise<any>}
|
|
14
|
+
*/
|
|
15
|
+
export async function selectWithBack({ message, choices, pageSize, ...rest }) {
|
|
16
|
+
const hasBack = (choices || []).some((c) => c && c.value === BACK);
|
|
17
|
+
const finalChoices = hasBack
|
|
18
|
+
? choices
|
|
19
|
+
: [
|
|
20
|
+
...choices,
|
|
21
|
+
{ name: '« Quay lại (bước trước)', value: BACK, description: 'Về bước trước / menu chính' }
|
|
22
|
+
];
|
|
23
|
+
return await select({
|
|
24
|
+
message,
|
|
25
|
+
choices: finalChoices,
|
|
26
|
+
pageSize: pageSize || Math.min(12, finalChoices.length + 2),
|
|
27
|
+
...rest
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Prompt input có hướng dẫn gõ ".." để quay lại
|
|
33
|
+
* @param {object} params
|
|
34
|
+
* @returns {Promise<string>}
|
|
35
|
+
*/
|
|
36
|
+
export async function inputWithBack({ message, validate, ...rest }) {
|
|
37
|
+
const hinted = `${message} [gõ ".." để quay lại]`;
|
|
38
|
+
const wrappedValidate = (val) => {
|
|
39
|
+
if (isBackInput(val)) return true;
|
|
40
|
+
if (validate) return validate(val);
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
const val = await input({ message: hinted, validate: wrappedValidate, ...rest });
|
|
44
|
+
if (isBackInput(val)) return BACK;
|
|
45
|
+
return val;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hỏi Tiếp tục / Quay lại menu / Thoát sau mỗi tác vụ hoặc khi gặp lỗi.
|
|
50
|
+
* @param {string} message
|
|
51
|
+
* @returns {Promise<'continue' | 'back' | 'exit'>}
|
|
52
|
+
*/
|
|
53
|
+
export async function promptContinueOrBack(message = 'Bạn muốn tiếp tục hay quay lại?') {
|
|
54
|
+
return await select({
|
|
55
|
+
message,
|
|
56
|
+
choices: [
|
|
57
|
+
{ name: 'Tiếp tục', value: 'continue', description: 'Đi tiếp bước kế tiếp' },
|
|
58
|
+
{ name: 'Quay lại bảng điều khiển', value: 'back', description: 'Về màn hình chọn hành động' },
|
|
59
|
+
{ name: 'Thoát', value: 'exit', description: 'Dừng, không làm gì' }
|
|
60
|
+
]
|
|
61
|
+
});
|
|
62
|
+
}
|
package/lib/secrets.js
CHANGED
|
@@ -1,87 +1,92 @@
|
|
|
1
|
-
import {
|
|
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
|
|
25
|
-
cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return
|
|
42
|
-
|
|
43
|
-
return null;
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
1
|
+
import { spawnSync } 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 r = spawnSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACM'], {
|
|
25
|
+
cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
|
|
26
|
+
});
|
|
27
|
+
if (r.status !== 0) return [];
|
|
28
|
+
return (r.stdout || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readStagedContent(file, cwd) {
|
|
35
|
+
try {
|
|
36
|
+
// Dùng spawnSync dạng mảng args (không qua shell) để tránh injection
|
|
37
|
+
// khi tên file chứa ký tự đặc biệt (", $, ;, &, |, ...).
|
|
38
|
+
const r = spawnSync('git', ['show', `:${file}`], {
|
|
39
|
+
cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: MAX_FILE_BYTES * 2
|
|
40
|
+
});
|
|
41
|
+
if (r.status !== 0) return null;
|
|
42
|
+
const content = r.stdout || '';
|
|
43
|
+
if (content.length > MAX_FILE_BYTES) return null; // file quá lớn, bỏ qua
|
|
44
|
+
if (content.includes('\0')) return null; // file binary
|
|
45
|
+
return content;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function redactPreview(line) {
|
|
52
|
+
const s = line.trim().slice(0, 120);
|
|
53
|
+
// Che giữa secret, chỉ chừa 4 ký tự đầu/cuối
|
|
54
|
+
return s.replace(/([A-Za-z0-9_.-]{4})[A-Za-z0-9_.-]{6,}([A-Za-z0-9_.-]{2})?/g, '$1•••$2');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Quét nội dung các file đã stage để tìm secret.
|
|
59
|
+
* @param {string} cwd
|
|
60
|
+
* @returns {{ file: string, type: string, line: number, preview: string }[]}
|
|
61
|
+
*/
|
|
62
|
+
export function scanStagedContentForSecrets(cwd = process.cwd()) {
|
|
63
|
+
const findings = [];
|
|
64
|
+
const files = listStagedFiles(cwd).filter((f) => {
|
|
65
|
+
const normalized = String(f).replace(/\\/g, '/');
|
|
66
|
+
if (SKIP_DIRS.some((d) => normalized.startsWith(d) || normalized.includes('/' + d))) return false;
|
|
67
|
+
if (SKIP_FILES.includes(normalized.split('/').pop())) return false;
|
|
68
|
+
return true;
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
for (const file of files.slice(0, 100)) { // giới hạn 100 file để nhanh
|
|
72
|
+
const content = readStagedContent(file, cwd);
|
|
73
|
+
if (!content) continue;
|
|
74
|
+
const lines = content.split('\n');
|
|
75
|
+
let matchesInFile = 0;
|
|
76
|
+
for (let i = 0; i < lines.length; i++) {
|
|
77
|
+
const line = lines[i];
|
|
78
|
+
if (!line || line.length > 2000) continue;
|
|
79
|
+
// Bỏ qua comment ví dụ trong docs? vẫn báo để user tự quyết
|
|
80
|
+
for (const p of SECRET_PATTERNS) {
|
|
81
|
+
if (p.regex.test(line)) {
|
|
82
|
+
findings.push({ file, type: p.label, line: i + 1, preview: redactPreview(line) });
|
|
83
|
+
matchesInFile++;
|
|
84
|
+
break; // mỗi dòng chỉ báo 1 lần
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (matchesInFile >= MAX_MATCHES_PER_FILE) break;
|
|
88
|
+
if (findings.length >= 20) return findings; // chặn spam
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return findings;
|
|
92
|
+
}
|