@indigoai-us/hq-cli 5.345.85 → 5.345.87
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/CHANGELOG.md +30 -0
- package/dist/command-catalog.generated.d.ts +42 -1
- package/dist/command-catalog.generated.js +51 -1
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/bot.d.ts +60 -12
- package/dist/commands/bot.js +349 -98
- package/dist/commands/scan.d.ts +23 -0
- package/dist/commands/scan.js +59 -0
- package/dist/commands/storage-core.d.ts +2 -0
- package/dist/commands/storage-core.js +12 -1
- package/dist/commands/storage-offload.d.ts +10 -0
- package/dist/commands/storage-offload.js +4 -0
- package/dist/commands/storage.d.ts +6 -0
- package/dist/commands/storage.js +25 -6
- package/dist/lib/bot/api.d.ts +15 -0
- package/dist/lib/bot/api.js +11 -0
- package/dist/lib/bot/config.d.ts +6 -0
- package/dist/lib/bot/display-name.d.ts +64 -0
- package/dist/lib/bot/display-name.js +117 -0
- package/dist/lib/bot/index.d.ts +2 -0
- package/dist/lib/bot/index.js +2 -0
- package/dist/lib/bot/lock.d.ts +84 -0
- package/dist/lib/bot/lock.js +313 -0
- package/dist/lib/secret-scan/detect.d.ts +27 -0
- package/dist/lib/secret-scan/detect.js +129 -0
- package/dist/lib/secret-scan/gitleaks-rules.generated.d.ts +6 -0
- package/dist/lib/secret-scan/gitleaks-rules.generated.js +3602 -0
- package/dist/lib/secret-scan/go-regex.d.ts +22 -0
- package/dist/lib/secret-scan/go-regex.js +250 -0
- package/dist/lib/secret-scan/rules.d.ts +31 -0
- package/dist/lib/secret-scan/rules.js +84 -0
- package/dist/lib/secret-scan/staged.d.ts +17 -0
- package/dist/lib/secret-scan/staged.js +112 -0
- package/dist/lib/secret-scan/types.d.ts +34 -0
- package/dist/lib/secret-scan/types.js +3 -0
- package/package.json +1 -1
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One writer per bot at a time.
|
|
3
|
+
*
|
|
4
|
+
* `hq bot create`, `adopt`, `restore`, `rm` and `set` all change the same
|
|
5
|
+
* bot's folder, and an adopt also rotates the bot's machine secret. Two of
|
|
6
|
+
* them running at once for the same bot (the desktop's automatic restore and
|
|
7
|
+
* its first-run create, a minute after sign-in) used to race on a
|
|
8
|
+
* check-then-write of bot.json: the loser either threw "already set up on this
|
|
9
|
+
* computer" or, worse, wrote a credential the winner had already rotated away.
|
|
10
|
+
*
|
|
11
|
+
* The lock is a file under the bots folder, `~/.hq/bots/.locks/<name>.lock`,
|
|
12
|
+
* created with O_CREAT|O_EXCL. It sits beside the bot's folder rather than in
|
|
13
|
+
* it: a folder that exists only to hold a lock would read as "a different bot
|
|
14
|
+
* already has this name" to the code that picks a restored bot's folder, and
|
|
15
|
+
* as "something is left here" to `hq bot rm`. Its body names the holder (pid,
|
|
16
|
+
* host, start time, a random token), so:
|
|
17
|
+
* - a holder whose process is gone, or that has held it for longer than
|
|
18
|
+
* {@link BOT_LOCK_STALE_MS}, is reclaimed;
|
|
19
|
+
* - release only removes the lock it took (the token must match), so a
|
|
20
|
+
* reclaim by another process never makes this process remove someone
|
|
21
|
+
* else's lock.
|
|
22
|
+
*
|
|
23
|
+
* The lock is re-entrant within one async call chain (AsyncLocalStorage):
|
|
24
|
+
* create holds it and calls the shared adopt path, which takes it again
|
|
25
|
+
* without waiting on itself. Two separate chains in one process still
|
|
26
|
+
* serialize, exactly like two processes.
|
|
27
|
+
*/
|
|
28
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
30
|
+
import * as fs from "node:fs";
|
|
31
|
+
import * as os from "node:os";
|
|
32
|
+
import * as path from "node:path";
|
|
33
|
+
import { botsRoot } from "./paths.js";
|
|
34
|
+
/** Not a valid bot name (it starts with a dot), so no bot can collide with it. */
|
|
35
|
+
export const BOT_LOCKS_DIRNAME = ".locks";
|
|
36
|
+
/** A holder older than this is treated as crashed, whatever its pid says. */
|
|
37
|
+
export const BOT_LOCK_STALE_MS = 10 * 60 * 1000;
|
|
38
|
+
/** How long a second writer waits for the first before giving up. */
|
|
39
|
+
export const BOT_LOCK_WAIT_MS = 3 * 60 * 1000;
|
|
40
|
+
/** A lock file with no readable body yet: its creator may still be writing it. */
|
|
41
|
+
const UNREADABLE_GRACE_MS = 5_000;
|
|
42
|
+
export class BotLockBusyError extends Error {
|
|
43
|
+
botName;
|
|
44
|
+
holder;
|
|
45
|
+
expected = true;
|
|
46
|
+
constructor(botName, holder) {
|
|
47
|
+
super(`Another hq bot command is changing "${botName}" right now${holder?.command ? ` (${holder.command})` : ""}. Try again in a moment.`);
|
|
48
|
+
this.botName = botName;
|
|
49
|
+
this.holder = holder;
|
|
50
|
+
this.name = "BotLockBusyError";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function botLockPath(name, root) {
|
|
54
|
+
return path.join(root ?? botsRoot(), BOT_LOCKS_DIRNAME, `${name}.lock`);
|
|
55
|
+
}
|
|
56
|
+
/** Alive unless the OS says there is no such process. EPERM is a live process we may not signal. */
|
|
57
|
+
function defaultIsPidAlive(pid) {
|
|
58
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
59
|
+
return false;
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
return err.code === "EPERM";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function parseHolder(raw) {
|
|
69
|
+
try {
|
|
70
|
+
const v = JSON.parse(raw);
|
|
71
|
+
if (typeof v.pid !== "number" || typeof v.startedAt !== "string" || typeof v.token !== "string")
|
|
72
|
+
return null;
|
|
73
|
+
return {
|
|
74
|
+
pid: v.pid,
|
|
75
|
+
host: typeof v.host === "string" ? v.host : "",
|
|
76
|
+
startedAt: v.startedAt,
|
|
77
|
+
token: v.token,
|
|
78
|
+
command: typeof v.command === "string" ? v.command : "",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Why an existing lock may be taken over, or null while its holder is live.
|
|
87
|
+
* Exported for tests.
|
|
88
|
+
*/
|
|
89
|
+
export function botLockStaleReason(raw, mtimeMs, opts) {
|
|
90
|
+
const holder = parseHolder(raw);
|
|
91
|
+
if (!holder) {
|
|
92
|
+
// Created but not yet written, or torn by a crash. Give a live creator a
|
|
93
|
+
// moment to finish writing before calling it abandoned.
|
|
94
|
+
return opts.now - mtimeMs > UNREADABLE_GRACE_MS ? "unreadable lock" : null;
|
|
95
|
+
}
|
|
96
|
+
const started = Date.parse(holder.startedAt);
|
|
97
|
+
if (Number.isNaN(started))
|
|
98
|
+
return "unreadable start time";
|
|
99
|
+
if (opts.now - started > opts.staleMs)
|
|
100
|
+
return `held since ${holder.startedAt}`;
|
|
101
|
+
// ~/.hq/bots is per machine, so the pid is this machine's. The host name is
|
|
102
|
+
// not compared: macOS changes it with the network, which would leave a
|
|
103
|
+
// crashed holder blocking for the whole stale age.
|
|
104
|
+
if (!opts.isPidAlive(holder.pid))
|
|
105
|
+
return `holder pid ${holder.pid} has exited`;
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
const heldStorage = new AsyncLocalStorage();
|
|
109
|
+
/** Locks this process holds, so a hard exit (process.exit in a failure path) still lets go. */
|
|
110
|
+
const heldByProcess = new Map();
|
|
111
|
+
let exitHookInstalled = false;
|
|
112
|
+
function installExitHook() {
|
|
113
|
+
if (exitHookInstalled)
|
|
114
|
+
return;
|
|
115
|
+
exitHookInstalled = true;
|
|
116
|
+
process.on("exit", releaseAllHeldBotLocks);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Let go of every lock this process holds. Runs on process exit, so a command
|
|
120
|
+
* that ends through process.exit (every `hq bot` failure does) frees its bot
|
|
121
|
+
* at once instead of leaving it to the stale-holder reclaim.
|
|
122
|
+
*/
|
|
123
|
+
export function releaseAllHeldBotLocks() {
|
|
124
|
+
for (const [file, token] of [...heldByProcess])
|
|
125
|
+
releaseFile(file, token);
|
|
126
|
+
}
|
|
127
|
+
function releaseFile(file, token) {
|
|
128
|
+
// Moved aside before it is judged, so a lock someone else took after
|
|
129
|
+
// reclaiming ours (a very long hold) is put back rather than deleted.
|
|
130
|
+
const aside = `${file}.release-${process.pid}-${randomUUID()}`;
|
|
131
|
+
try {
|
|
132
|
+
fs.renameSync(file, aside);
|
|
133
|
+
const raw = fs.readFileSync(aside, "utf8");
|
|
134
|
+
if (parseHolder(raw)?.token !== token) {
|
|
135
|
+
try {
|
|
136
|
+
fs.linkSync(aside, file);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* a newer holder is already in place */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* already gone, or unreadable: nothing of ours to remove */
|
|
145
|
+
}
|
|
146
|
+
fs.rmSync(aside, { force: true });
|
|
147
|
+
heldByProcess.delete(file);
|
|
148
|
+
// Leave the bots folder as it was when no lock is held. A concurrent
|
|
149
|
+
// acquirer that loses its folder here gets ENOENT and makes it again.
|
|
150
|
+
try {
|
|
151
|
+
fs.rmdirSync(path.dirname(file));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
/* other locks are held, or it is already gone */
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function tryCreate(file, body) {
|
|
158
|
+
let fd;
|
|
159
|
+
try {
|
|
160
|
+
fd = fs.openSync(file, "wx", 0o600);
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
const code = err.code;
|
|
164
|
+
if (code === "EEXIST")
|
|
165
|
+
return false;
|
|
166
|
+
// Windows refuses to open a file that is pending deletion (a release in
|
|
167
|
+
// progress) with EPERM/EACCES rather than EEXIST: that is contention too.
|
|
168
|
+
if (process.platform === "win32" && (code === "EPERM" || code === "EACCES"))
|
|
169
|
+
return false;
|
|
170
|
+
if (code === "ENOENT") {
|
|
171
|
+
// The locks folder went away between mkdir and open (a wiped ~/.hq).
|
|
172
|
+
// Make it again and let the caller retry.
|
|
173
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
fs.writeSync(fd, body);
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
fs.closeSync(fd);
|
|
183
|
+
}
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Remove a stale lock, but only the one that was judged stale: it is renamed
|
|
188
|
+
* aside first, and if what was renamed is not what was read (a new holder got
|
|
189
|
+
* in between), it is put back.
|
|
190
|
+
*/
|
|
191
|
+
function reclaim(file, staleRaw) {
|
|
192
|
+
// Another waiter may have reclaimed it and taken its own lock since we read
|
|
193
|
+
// it: only the lock judged stale is ever moved. Either way the file changed,
|
|
194
|
+
// so the caller may try again at once.
|
|
195
|
+
try {
|
|
196
|
+
if (fs.readFileSync(file, "utf8") !== staleRaw)
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
const aside = `${file}.stale-${process.pid}-${randomUUID()}`;
|
|
203
|
+
try {
|
|
204
|
+
fs.renameSync(file, aside);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
// Gone already (someone else reclaimed or released it): try again. Any
|
|
208
|
+
// other refusal leaves the stale lock in place: wait before trying again.
|
|
209
|
+
return err.code === "ENOENT";
|
|
210
|
+
}
|
|
211
|
+
let moved = "";
|
|
212
|
+
try {
|
|
213
|
+
moved = fs.readFileSync(aside, "utf8");
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
/* treat as different */
|
|
217
|
+
}
|
|
218
|
+
if (moved !== staleRaw) {
|
|
219
|
+
// A live holder's lock: restore it. link fails if yet another holder now
|
|
220
|
+
// exists, which still leaves exactly one lock in place.
|
|
221
|
+
try {
|
|
222
|
+
fs.linkSync(aside, file);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
try {
|
|
226
|
+
fs.writeFileSync(file, moved, { flag: "wx", mode: 0o600 });
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
/* another holder is in place */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
fs.rmSync(aside, { force: true });
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
/** Take the bot's lock (waiting for a live holder), run `fn`, and let go. */
|
|
237
|
+
export async function withBotLock(name, fn, opts = {}) {
|
|
238
|
+
const current = heldStorage.getStore();
|
|
239
|
+
const depth = current?.names.get(name) ?? 0;
|
|
240
|
+
if (depth > 0) {
|
|
241
|
+
current.names.set(name, depth + 1);
|
|
242
|
+
try {
|
|
243
|
+
return await fn();
|
|
244
|
+
}
|
|
245
|
+
finally {
|
|
246
|
+
current.names.set(name, depth);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const file = botLockPath(name, opts.root);
|
|
250
|
+
const now = opts.now ?? Date.now;
|
|
251
|
+
const isPidAlive = opts.isPidAlive ?? defaultIsPidAlive;
|
|
252
|
+
const staleMs = opts.staleMs ?? BOT_LOCK_STALE_MS;
|
|
253
|
+
const waitMs = opts.waitMs ?? BOT_LOCK_WAIT_MS;
|
|
254
|
+
const pollMs = opts.pollMs ?? 150;
|
|
255
|
+
const host = os.hostname();
|
|
256
|
+
const token = randomUUID();
|
|
257
|
+
const body = JSON.stringify({
|
|
258
|
+
pid: process.pid,
|
|
259
|
+
host,
|
|
260
|
+
startedAt: new Date(now()).toISOString(),
|
|
261
|
+
token,
|
|
262
|
+
command: opts.command ?? "",
|
|
263
|
+
});
|
|
264
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
265
|
+
const deadline = now() + waitMs;
|
|
266
|
+
let waited = false;
|
|
267
|
+
let lastHolder = null;
|
|
268
|
+
for (;;) {
|
|
269
|
+
if (tryCreate(file, body))
|
|
270
|
+
break;
|
|
271
|
+
let raw;
|
|
272
|
+
let mtimeMs = now();
|
|
273
|
+
try {
|
|
274
|
+
raw = fs.readFileSync(file, "utf8");
|
|
275
|
+
mtimeMs = fs.statSync(file).mtimeMs;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
raw = null; // released between our create and our read: just retry
|
|
279
|
+
}
|
|
280
|
+
if (raw !== null) {
|
|
281
|
+
lastHolder = parseHolder(raw);
|
|
282
|
+
const why = botLockStaleReason(raw, mtimeMs, { now: now(), staleMs, isPidAlive, host });
|
|
283
|
+
// Retry at once only when the stale lock is really gone. A reclaim that
|
|
284
|
+
// could not move it (Windows refusing a rename, a read-only folder) falls
|
|
285
|
+
// through to the deadline and the poll wait, so this never spins.
|
|
286
|
+
if (why) {
|
|
287
|
+
opts.onReclaim?.(lastHolder, why);
|
|
288
|
+
if (reclaim(file, raw))
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (!waited) {
|
|
292
|
+
waited = true;
|
|
293
|
+
opts.onWait?.(lastHolder);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (now() >= deadline)
|
|
297
|
+
throw new BotLockBusyError(name, lastHolder);
|
|
298
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
299
|
+
}
|
|
300
|
+
installExitHook();
|
|
301
|
+
heldByProcess.set(file, token);
|
|
302
|
+
const held = { names: new Map(current?.names ?? []) };
|
|
303
|
+
held.names.set(name, 1);
|
|
304
|
+
try {
|
|
305
|
+
return await heldStorage.run(held, fn);
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
// Work that `fn` started and left running no longer holds the lock.
|
|
309
|
+
held.names.set(name, 0);
|
|
310
|
+
releaseFile(file, token);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
//# sourceMappingURL=lock.js.map
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret detection over text fragments, following gitleaks' detection model:
|
|
3
|
+
* global path allowlist, per-rule path filter, keyword prefilter, regex match,
|
|
4
|
+
* secret-group selection, Shannon-entropy floor, allowlists (OR/AND; target
|
|
5
|
+
* secret, match, or line; stopwords), and the inline `gitleaks:allow` marker.
|
|
6
|
+
*
|
|
7
|
+
* Findings carry rule, file and line only. The secret value never leaves this
|
|
8
|
+
* module.
|
|
9
|
+
*/
|
|
10
|
+
import { type CompiledRuleSet } from "./rules.js";
|
|
11
|
+
import type { SecretFinding } from "./types.js";
|
|
12
|
+
/** Inline markers that suppress a finding on that line. */
|
|
13
|
+
export declare const ALLOW_MARKERS: readonly ["gitleaks:allow", "hq-secret-scan:allow"];
|
|
14
|
+
export interface Fragment {
|
|
15
|
+
file: string;
|
|
16
|
+
text: string;
|
|
17
|
+
/** 1-based line number of the first line of `text` in the file. */
|
|
18
|
+
startLine: number;
|
|
19
|
+
}
|
|
20
|
+
/** Shannon entropy in bits per character (gitleaks' definition). */
|
|
21
|
+
export declare function shannonEntropy(data: string): number;
|
|
22
|
+
/** True when the global allowlist excludes this path from scanning. */
|
|
23
|
+
export declare function pathIgnored(file: string, rules?: CompiledRuleSet): boolean;
|
|
24
|
+
/** Path-only rules (e.g. a committed .p12 keystore); for binary or whole-file adds. */
|
|
25
|
+
export declare function scanPath(file: string, rules?: CompiledRuleSet): SecretFinding[];
|
|
26
|
+
export declare function scanFragment(frag: Fragment, rules?: CompiledRuleSet): SecretFinding[];
|
|
27
|
+
//# sourceMappingURL=detect.d.ts.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret detection over text fragments, following gitleaks' detection model:
|
|
3
|
+
* global path allowlist, per-rule path filter, keyword prefilter, regex match,
|
|
4
|
+
* secret-group selection, Shannon-entropy floor, allowlists (OR/AND; target
|
|
5
|
+
* secret, match, or line; stopwords), and the inline `gitleaks:allow` marker.
|
|
6
|
+
*
|
|
7
|
+
* Findings carry rule, file and line only. The secret value never leaves this
|
|
8
|
+
* module.
|
|
9
|
+
*/
|
|
10
|
+
import { compiledRules } from "./rules.js";
|
|
11
|
+
/** Inline markers that suppress a finding on that line. */
|
|
12
|
+
export const ALLOW_MARKERS = ["gitleaks:allow", "hq-secret-scan:allow"];
|
|
13
|
+
/** Shannon entropy in bits per character (gitleaks' definition). */
|
|
14
|
+
export function shannonEntropy(data) {
|
|
15
|
+
if (!data)
|
|
16
|
+
return 0;
|
|
17
|
+
const counts = new Map();
|
|
18
|
+
for (const ch of data)
|
|
19
|
+
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
20
|
+
const len = Buffer.byteLength(data, "utf8");
|
|
21
|
+
let entropy = 0;
|
|
22
|
+
for (const count of counts.values()) {
|
|
23
|
+
const freq = count / len;
|
|
24
|
+
entropy -= freq * Math.log2(freq);
|
|
25
|
+
}
|
|
26
|
+
return entropy;
|
|
27
|
+
}
|
|
28
|
+
function allowed(al, c) {
|
|
29
|
+
const target = al.target === "match" ? c.match : al.target === "line" ? c.line : c.secret;
|
|
30
|
+
const checks = [];
|
|
31
|
+
if (al.paths.length)
|
|
32
|
+
checks.push(al.paths.some((p) => p.test(c.file)));
|
|
33
|
+
if (al.regexes.length)
|
|
34
|
+
checks.push(al.regexes.some((r) => r.test(target)));
|
|
35
|
+
if (al.stopwords.length) {
|
|
36
|
+
const lower = c.secret.toLowerCase();
|
|
37
|
+
checks.push(al.stopwords.some((s) => lower.includes(s)));
|
|
38
|
+
}
|
|
39
|
+
if (!checks.length)
|
|
40
|
+
return false;
|
|
41
|
+
return al.condition === "AND" ? checks.every(Boolean) : checks.some(Boolean);
|
|
42
|
+
}
|
|
43
|
+
function lineBounds(text, start, end) {
|
|
44
|
+
const from = text.lastIndexOf("\n", start - 1) + 1;
|
|
45
|
+
const nl = text.indexOf("\n", Math.max(end - 1, start));
|
|
46
|
+
const to = nl < 0 ? text.length : nl;
|
|
47
|
+
let offset = 0;
|
|
48
|
+
for (let i = 0; i < from; i++)
|
|
49
|
+
if (text.charCodeAt(i) === 10)
|
|
50
|
+
offset++;
|
|
51
|
+
return { line: text.slice(from, to), offset };
|
|
52
|
+
}
|
|
53
|
+
/** True when the global allowlist excludes this path from scanning. */
|
|
54
|
+
export function pathIgnored(file, rules = compiledRules()) {
|
|
55
|
+
return rules.global.paths.some((p) => p.test(file));
|
|
56
|
+
}
|
|
57
|
+
/** Path-only rules (e.g. a committed .p12 keystore); for binary or whole-file adds. */
|
|
58
|
+
export function scanPath(file, rules = compiledRules()) {
|
|
59
|
+
if (pathIgnored(file, rules))
|
|
60
|
+
return [];
|
|
61
|
+
return rules.rules
|
|
62
|
+
.filter((r) => !r.regex && r.path?.test(file))
|
|
63
|
+
.map((r) => ({ ruleId: r.id, description: r.description, file, line: 1 }));
|
|
64
|
+
}
|
|
65
|
+
export function scanFragment(frag, rules = compiledRules()) {
|
|
66
|
+
const { file, text, startLine } = frag;
|
|
67
|
+
if (!text || pathIgnored(file, rules))
|
|
68
|
+
return [];
|
|
69
|
+
const lower = text.toLowerCase();
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
const findings = [];
|
|
72
|
+
for (const rule of rules.rules) {
|
|
73
|
+
if (!rule.regex)
|
|
74
|
+
continue;
|
|
75
|
+
if (rule.path && !rule.path.test(file))
|
|
76
|
+
continue;
|
|
77
|
+
if (rule.keywords.length && !rule.keywords.some((k) => lower.includes(k)))
|
|
78
|
+
continue;
|
|
79
|
+
const re = rule.regex;
|
|
80
|
+
re.lastIndex = 0;
|
|
81
|
+
let m;
|
|
82
|
+
while ((m = re.exec(text)) !== null) {
|
|
83
|
+
if (m[0].length === 0) {
|
|
84
|
+
re.lastIndex++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
let secret = m[0];
|
|
88
|
+
if (m.length > 1) {
|
|
89
|
+
if (rule.secretGroup && rule.secretGroup > 0)
|
|
90
|
+
secret = m[rule.secretGroup] ?? "";
|
|
91
|
+
else
|
|
92
|
+
secret = m.slice(1).find((g) => g !== undefined && g.length > 0) ?? m[0];
|
|
93
|
+
}
|
|
94
|
+
if (!secret)
|
|
95
|
+
continue;
|
|
96
|
+
if (rule.entropy !== undefined && rule.entropy !== 0 && shannonEntropy(secret) <= rule.entropy)
|
|
97
|
+
continue;
|
|
98
|
+
const { line, offset } = lineBounds(text, m.index, m.index + m[0].length);
|
|
99
|
+
if (ALLOW_MARKERS.some((mk) => line.includes(mk)))
|
|
100
|
+
continue;
|
|
101
|
+
const cand = { file, secret, match: m[0], line };
|
|
102
|
+
if (allowed(rules.global, cand))
|
|
103
|
+
continue;
|
|
104
|
+
if (rule.allowlists.some((al) => allowed(al, cand)))
|
|
105
|
+
continue;
|
|
106
|
+
const lineNo = startLine + offset;
|
|
107
|
+
const key = `${rule.id}:${lineNo}`;
|
|
108
|
+
if (seen.has(key))
|
|
109
|
+
continue;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
findings.push({ ruleId: rule.id, description: rule.description, file, line: lineNo });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return dedupeByLine(findings).sort((a, b) => a.line - b.line || a.ruleId.localeCompare(b.ruleId));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* One line, one report: when a provider-specific rule fired on a line, drop the
|
|
118
|
+
* catch-all `generic-api-key` and HQ fallback (`hq-*`) findings for that line,
|
|
119
|
+
* as gitleaks does for its generic rule.
|
|
120
|
+
*/
|
|
121
|
+
function dedupeByLine(findings) {
|
|
122
|
+
const fallback = (id) => id === "generic-api-key" || id.startsWith("hq-");
|
|
123
|
+
const specific = new Set(findings.filter((f) => !fallback(f.ruleId)).map((f) => f.line));
|
|
124
|
+
const out = findings.filter((f) => !fallback(f.ruleId) || !specific.has(f.line));
|
|
125
|
+
// Between the two fallbacks, prefer the HQ rule (names the provider).
|
|
126
|
+
const hqLines = new Set(out.filter((f) => f.ruleId.startsWith("hq-")).map((f) => f.line));
|
|
127
|
+
return out.filter((f) => f.ruleId !== "generic-api-key" || !hqLines.has(f.line));
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=detect.js.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { GitleaksConfig } from "./types.js";
|
|
2
|
+
export declare const GITLEAKS_UPSTREAM_VERSION = "v8.30.1";
|
|
3
|
+
/** Upstream license; shipped with the derived rules as MIT requires. */
|
|
4
|
+
export declare const GITLEAKS_LICENSE = "MIT License\n\nCopyright (c) 2019 Zachary Rice\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n";
|
|
5
|
+
export declare const GITLEAKS_CONFIG: GitleaksConfig;
|
|
6
|
+
//# sourceMappingURL=gitleaks-rules.generated.d.ts.map
|