@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.7
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/package.json +12 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +204 -5
- package/src/cli/primitives.mjs +51 -1
- package/src/config/config.mjs +307 -14
- package/src/config/extension-defaults.yaml +39 -1
- package/src/github/comment-id-guard.mjs +158 -0
- package/src/github/copilot-helpers.mjs +145 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +13 -0
- package/src/loop/agent-stall.mjs +196 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +35 -2
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +40 -20
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +73 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +1034 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +38 -28
- package/src/security/secret-scan.mjs +330 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { runChild as _runChild } from "../cli/primitives.mjs";
|
|
2
|
-
import { parseJsonText } from "../github/review-threads.mjs";
|
|
3
2
|
import { resolveProjectSelector, findProject } from "./resolve-project.mjs";
|
|
3
|
+
import { ghGraphql } from "../github/gh.mjs";
|
|
4
4
|
|
|
5
5
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
6
6
|
|
|
@@ -30,32 +30,6 @@ function validateRepo(repo) {
|
|
|
30
30
|
return repo;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
// ── API helpers ──────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
async function ghGraphql(query, vars, env, runChild = _runChild) {
|
|
36
|
-
const fieldArgs = [];
|
|
37
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
38
|
-
fieldArgs.push("--field", `${key}=${value}`);
|
|
39
|
-
}
|
|
40
|
-
const result = await runChild(
|
|
41
|
-
"gh",
|
|
42
|
-
["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
|
|
43
|
-
env,
|
|
44
|
-
);
|
|
45
|
-
if (result.code !== 0) {
|
|
46
|
-
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
47
|
-
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
48
|
-
}
|
|
49
|
-
const payload = parseJsonText(result.stdout);
|
|
50
|
-
if (payload.errors && payload.errors.length > 0) {
|
|
51
|
-
throw Object.assign(
|
|
52
|
-
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
53
|
-
{ code: "GRAPHQL_ERROR" },
|
|
54
|
-
);
|
|
55
|
-
}
|
|
56
|
-
return payload;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
33
|
// ── GraphQL fragments ────────────────────────────────────────────────────
|
|
60
34
|
|
|
61
35
|
const GET_USER_ID = [
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { runChild as _runChild } from "../cli/primitives.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { runPickupRefinementGate } from "../loop/issue-refinement-artifact.mjs";
|
|
3
|
+
import { loadStateColumnMap, LOGICAL_COLUMN } from "../loop/queue-board-sync.mjs";
|
|
3
4
|
import { resolveProjectSelector, findProject, parseItemRef } from "./resolve-project.mjs";
|
|
5
|
+
import { ghGraphql } from "../github/gh.mjs";
|
|
4
6
|
|
|
5
7
|
// ── Validation ───────────────────────────────────────────────────────────
|
|
6
8
|
|
|
@@ -27,32 +29,6 @@ function validateRepo(repo) {
|
|
|
27
29
|
return repo;
|
|
28
30
|
}
|
|
29
31
|
|
|
30
|
-
// ── API helpers ──────────────────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
async function ghGraphql(query, vars, env, runChild = _runChild) {
|
|
33
|
-
const fieldArgs = [];
|
|
34
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
35
|
-
fieldArgs.push("--field", `${key}=${value}`);
|
|
36
|
-
}
|
|
37
|
-
const result = await runChild(
|
|
38
|
-
"gh",
|
|
39
|
-
["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
|
|
40
|
-
env,
|
|
41
|
-
);
|
|
42
|
-
if (result.code !== 0) {
|
|
43
|
-
const detail = result.stderr.trim() || `exit code ${result.code}`;
|
|
44
|
-
throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
|
|
45
|
-
}
|
|
46
|
-
const payload = parseJsonText(result.stdout);
|
|
47
|
-
if (payload.errors && payload.errors.length > 0) {
|
|
48
|
-
throw Object.assign(
|
|
49
|
-
new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
|
|
50
|
-
{ code: "GRAPHQL_ERROR" },
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
return payload;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
32
|
// ── GraphQL fragments ────────────────────────────────────────────────────
|
|
57
33
|
|
|
58
34
|
const GET_USER_ID = [
|
|
@@ -252,6 +228,7 @@ function statusOf(node) {
|
|
|
252
228
|
function classifyExitCode(err) {
|
|
253
229
|
if (err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT" || err.code === "INVALID_ITEM" ||
|
|
254
230
|
err.code === "INVALID_COLUMN" || err.code === "INVALID_ARGS") return 1;
|
|
231
|
+
if (err.code === "MISSING_REFINEMENT_ARTIFACT") return 4;
|
|
255
232
|
if (err.code === "PROJECT_NOT_FOUND" || err.code === "FIELD_NOT_FOUND" || err.code === "COLUMN_NOT_FOUND" ||
|
|
256
233
|
err.code === "ITEM_NOT_FOUND") return 3;
|
|
257
234
|
return 2;
|
|
@@ -259,7 +236,7 @@ function classifyExitCode(err) {
|
|
|
259
236
|
|
|
260
237
|
// ── Main logic ──────────────────────────────────────────────────────────
|
|
261
238
|
|
|
262
|
-
async function main(args, { env = process.env, runChild } = {}) {
|
|
239
|
+
async function main(args, { env = process.env, runChild, cwd = null } = {}) {
|
|
263
240
|
const child = runChild ?? _runChild;
|
|
264
241
|
const repo = validateRepo(args.repo);
|
|
265
242
|
const [owner, repoName] = repo.split("/");
|
|
@@ -365,6 +342,38 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
365
342
|
};
|
|
366
343
|
}
|
|
367
344
|
|
|
345
|
+
// 5b. QUEUE-ENQUEUE-REFINEMENT-GATE: moving an ISSUE into the pickup column
|
|
346
|
+
// must pass the same refinement gate `queue add` applies — a guard at one
|
|
347
|
+
// entry point but not its sibling is exactly the asymmetry this closes. Put
|
|
348
|
+
// here (in core, not the script wrapper) so every caller routing through core
|
|
349
|
+
// is covered — reconcile-queue.mjs included. reconcile is unaffected because
|
|
350
|
+
// it passes no cwd and never derives the pickup column. The column name is
|
|
351
|
+
// only derivable when cwd/config is supplied: an interactive `queue move` from
|
|
352
|
+
// a worktree passes cwd (gate fires), while headless callers (e.g.
|
|
353
|
+
// reconcile-queue.mjs) omit it and are unaffected — we simply never derive
|
|
354
|
+
// the column.
|
|
355
|
+
let refinement = null;
|
|
356
|
+
if (issueNumber !== null && typeof cwd === "string" && cwd.length > 0) {
|
|
357
|
+
const { columnNames, error: columnError } = loadStateColumnMap(cwd);
|
|
358
|
+
const pickupColumn = columnNames[LOGICAL_COLUMN.NEXT_UP];
|
|
359
|
+
if (pickupColumn && toColumn === pickupColumn) {
|
|
360
|
+
// Mirror queue add's fail-closed posture on a malformed `.devloops` when the
|
|
361
|
+
// interactive path supplied cwd AND the target is actually the pickup
|
|
362
|
+
// column: a config parse failure must never silently bypass the just-moved
|
|
363
|
+
// pickup refinement gate (the exact sibling asymmetry this issue closes).
|
|
364
|
+
// Scoped here (after the pickup-column guard) so an unrelated column move
|
|
365
|
+
// is not over-broadened into a hard CONFIG_ERROR failure.
|
|
366
|
+
if (columnError) {
|
|
367
|
+
throw Object.assign(
|
|
368
|
+
new Error(`could not resolve the pickup column (config read/parse error: ${columnError})`),
|
|
369
|
+
{ code: "CONFIG_ERROR" },
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
const decision = await runPickupRefinementGate({ issueNumber, repo, env, runChild: child, auto: false });
|
|
373
|
+
refinement = { refined: decision.action === "enqueue" };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
368
377
|
// 6. Update Status via mutation
|
|
369
378
|
const updatePayload = await ghGraphql(UPDATE_ITEM_FIELD, {
|
|
370
379
|
projectId: project.id,
|
|
@@ -388,6 +397,7 @@ async function main(args, { env = process.env, runChild } = {}) {
|
|
|
388
397
|
newColumn: toColumn,
|
|
389
398
|
unchanged: false,
|
|
390
399
|
},
|
|
400
|
+
...(refinement ? { refinement } : {}),
|
|
391
401
|
};
|
|
392
402
|
}
|
|
393
403
|
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-closed secret scan over the ADDED lines of a unified git diff.
|
|
3
|
+
*
|
|
4
|
+
* No external dependency (no gitleaks/trufflehog): this module is a small,
|
|
5
|
+
* self-contained detector set, deliberately kept auditable rather than
|
|
6
|
+
* outsourced. It is deterministic and side-effect free — it never reads or
|
|
7
|
+
* writes anything itself; callers (the CLI, the git hook) supply the diff
|
|
8
|
+
* text and act on the result.
|
|
9
|
+
*
|
|
10
|
+
* Three detector classes (see DETECTOR_CLASSES):
|
|
11
|
+
* - literal-credential: a known provider token PREFIX (ghp_, xoxb-, AKIA...,
|
|
12
|
+
* a PEM private-key header, ...), literal OR base64-encoded.
|
|
13
|
+
* - high-entropy: a long single-token run whose Shannon entropy is above a
|
|
14
|
+
* tuned threshold — catches a credential with no recognized prefix.
|
|
15
|
+
* - sink-pattern: a secret-NAMED variable (*TOKEN*, *SECRET*, ...) and an
|
|
16
|
+
* output sink (echo/printf/tee/base64/redirect/workflow `::` directive)
|
|
17
|
+
* on the SAME line — no literal secret value has to be present in the diff
|
|
18
|
+
* for this to fire; it is what catches a secret-named var piped into a
|
|
19
|
+
* logged/echoed stream.
|
|
20
|
+
*
|
|
21
|
+
* A finding NEVER carries the matched value — only file/line/detector-class
|
|
22
|
+
* and a canned, value-free reason string. A secret, once flagged, is treated
|
|
23
|
+
* as unrecoverable: there is no "show me the match" affordance anywhere in
|
|
24
|
+
* this module.
|
|
25
|
+
*
|
|
26
|
+
* Allowlisting: an inline `secret-scan:allow` marker plus a trailing reason
|
|
27
|
+
* (comment syntax is irrelevant — this is a plain substring test) on the SAME
|
|
28
|
+
* line exempts that one line from every detector. There is no global disable
|
|
29
|
+
* and no central baseline file — every exemption is visible in the diff it
|
|
30
|
+
* exempts.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export const DETECTOR_CLASSES = Object.freeze({
|
|
34
|
+
LITERAL_CREDENTIAL: "literal-credential",
|
|
35
|
+
HIGH_ENTROPY: "high-entropy",
|
|
36
|
+
SINK_PATTERN: "sink-pattern",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export const ALLOW_MARKER = "secret-scan:allow";
|
|
40
|
+
const ALLOW_RE = /secret-scan:allow\s+(\S.*?)\s*$/u;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Known provider token/credential PREFIX formats. Each pattern requires the
|
|
44
|
+
* full token shape (prefix + a plausible body length), not just the prefix
|
|
45
|
+
* alone — a bare mention of a prefix constant (as in this module's own
|
|
46
|
+
* source) is short of the required body length and never self-matches.
|
|
47
|
+
*/
|
|
48
|
+
const LITERAL_PATTERNS = [
|
|
49
|
+
{ name: "github-pat-classic", re: /\bghp_[A-Za-z0-9]{20,}\b/ },
|
|
50
|
+
{ name: "github-pat-fine-grained", re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ },
|
|
51
|
+
{ name: "github-oauth-token", re: /\bgho_[A-Za-z0-9]{20,}\b/ },
|
|
52
|
+
{ name: "github-user-to-server-token", re: /\bghu_[A-Za-z0-9]{20,}\b/ },
|
|
53
|
+
{ name: "github-server-to-server-token", re: /\bghs_[A-Za-z0-9]{20,}\b/ },
|
|
54
|
+
{ name: "github-refresh-token", re: /\bghr_[A-Za-z0-9]{20,}\b/ },
|
|
55
|
+
{ name: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
56
|
+
{ name: "aws-access-key-id", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
|
|
57
|
+
{ name: "pem-private-key", re: /-----BEGIN [A-Z0-9]+(?: [A-Z0-9]+)* PRIVATE KEY-----/ },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
/** True when `text` matches any known literal credential prefix format. */
|
|
61
|
+
function matchLiteralPattern(text) {
|
|
62
|
+
for (const pattern of LITERAL_PATTERNS) {
|
|
63
|
+
if (pattern.re.test(text)) return pattern.name;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A base64-shaped run long enough to plausibly carry an encoded credential.
|
|
69
|
+
// `g` (global) so every candidate run on the line is checked, not just the
|
|
70
|
+
// first.
|
|
71
|
+
const BASE64_CANDIDATE_RE = /[A-Za-z0-9+/]{20,}={0,2}/gu;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Whether `text` contains a base64-shaped substring that DECODES to a known
|
|
75
|
+
* literal credential format — the "value stored encoded" case a plain
|
|
76
|
+
* literal-prefix scan over the RAW diff text would miss.
|
|
77
|
+
*/
|
|
78
|
+
function matchBase64Credential(text) {
|
|
79
|
+
const candidates = text.match(BASE64_CANDIDATE_RE) ?? [];
|
|
80
|
+
for (const candidate of candidates) {
|
|
81
|
+
let decoded;
|
|
82
|
+
try {
|
|
83
|
+
decoded = Buffer.from(candidate, "base64").toString("utf8");
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const hit = matchLiteralPattern(decoded);
|
|
88
|
+
if (hit) return hit;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Shannon entropy in bits/character. Pure math, no I/O — exported so the
|
|
95
|
+
* threshold this module tunes is independently testable.
|
|
96
|
+
* @param {string} str
|
|
97
|
+
* @returns {number}
|
|
98
|
+
*/
|
|
99
|
+
export function shannonEntropy(str) {
|
|
100
|
+
if (str.length === 0) return 0;
|
|
101
|
+
const counts = new Map();
|
|
102
|
+
for (const ch of str) counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
103
|
+
let entropy = 0;
|
|
104
|
+
for (const count of counts.values()) {
|
|
105
|
+
const p = count / str.length;
|
|
106
|
+
entropy -= p * Math.log2(p);
|
|
107
|
+
}
|
|
108
|
+
return entropy;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A candidate is a single unbroken run (no whitespace, no `=`) of the
|
|
112
|
+
// characters a token/secret literal is typically made of. `=` is
|
|
113
|
+
// deliberately EXCLUDED from the run (unlike the base64-candidate pattern
|
|
114
|
+
// above, which keeps it for trailing padding): an ordinary `KEY=value`
|
|
115
|
+
// shell/env assignment is otherwise read as one inflated, higher-entropy
|
|
116
|
+
// candidate spanning both sides of the `=` — splitting there scores the key
|
|
117
|
+
// and the value separately, each far more likely to fall under the length or
|
|
118
|
+
// digit/letter floor below on its own. Tuned length/threshold: 20 chars is
|
|
119
|
+
// short enough to catch a real secret, long enough that ordinary identifiers
|
|
120
|
+
// rarely reach it; 4.3 bits/char sits between plain lowercase English (~4.0
|
|
121
|
+
// max, usually much lower) and true base64/hex randomness (~4.5-6). Both
|
|
122
|
+
// digit AND letter are required so a long plain word (no digits) never
|
|
123
|
+
// qualifies — false positives here cost only an allowlist line; a missed
|
|
124
|
+
// real secret costs a leak, so the length/threshold pair leans toward
|
|
125
|
+
// catching more, not fewer.
|
|
126
|
+
const ENTROPY_CANDIDATE_RE = /[A-Za-z0-9+/_.-]{20,}/gu;
|
|
127
|
+
const ENTROPY_MIN_LENGTH = 20;
|
|
128
|
+
const ENTROPY_THRESHOLD = 4.3;
|
|
129
|
+
|
|
130
|
+
function hasHighEntropyToken(text) {
|
|
131
|
+
const candidates = text.match(ENTROPY_CANDIDATE_RE) ?? [];
|
|
132
|
+
for (const candidate of candidates) {
|
|
133
|
+
if (candidate.length < ENTROPY_MIN_LENGTH) continue;
|
|
134
|
+
if (!/[0-9]/.test(candidate) || !/[A-Za-z]/.test(candidate)) continue;
|
|
135
|
+
if (shannonEntropy(candidate) >= ENTROPY_THRESHOLD) return true;
|
|
136
|
+
}
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// A variable/env-key NAME that reads as a secret. Substring match for
|
|
141
|
+
// TOKEN/SECRET/PASSWORD(/PASSWD) (`API_TOKEN`, `authToken`, `DB_PASSWORD`,
|
|
142
|
+
// ...), suffix match for `_PAT`/`_KEY` (deliberately a suffix, not a
|
|
143
|
+
// substring — "_KEY" alone would otherwise fire on ordinary words like
|
|
144
|
+
// "keyboard"), plus the literal incident-motivating name. Tested per
|
|
145
|
+
// IDENTIFIER, not per whole line — see hasSecretNamedIdentifier.
|
|
146
|
+
const SECRET_KEYWORD_RE = /(?:TOKEN|SECRET|PASSWORD|PASSWD)|_PAT$|_KEY$/iu;
|
|
147
|
+
const IDENTIFIER_RE = /[A-Za-z_][A-Za-z0-9_]*/gu;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* True when `identifier` (an already-extracted `[A-Za-z_][A-Za-z0-9_]*` run)
|
|
151
|
+
* is SHAPED like a real variable/env-key name rather than a plain English
|
|
152
|
+
* word that merely contains a secret keyword as a substring: it has an
|
|
153
|
+
* underscore (snake_case / SCREAMING_SNAKE env-var shape, e.g.
|
|
154
|
+
* `GITHUB_TOKEN`, `foo_api_key`), mixes upper and lower case
|
|
155
|
+
* (camelCase/PascalCase, e.g. `authToken`), or is entirely uppercase letters
|
|
156
|
+
* (a bare ALL-CAPS identifier, e.g. `SECRET` used standalone). A plain
|
|
157
|
+
* single-case word with no underscore — "token", "secretary", "tokenize" —
|
|
158
|
+
* is not identifier-shaped, even though it may contain a secret keyword as a
|
|
159
|
+
* substring: "token-economical" and a bare "token" in prose don't qualify.
|
|
160
|
+
*/
|
|
161
|
+
function isIdentifierShaped(identifier) {
|
|
162
|
+
if (identifier.includes("_")) return true;
|
|
163
|
+
const hasUpper = /[A-Z]/u.test(identifier);
|
|
164
|
+
const hasLower = /[a-z]/u.test(identifier);
|
|
165
|
+
if (hasUpper && hasLower) return true; // camelCase / PascalCase
|
|
166
|
+
return hasUpper && !hasLower; // bare ALL-CAPS identifier (e.g. `SECRET`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function hasSecretNamedIdentifier(text) {
|
|
170
|
+
for (const identifier of text.match(IDENTIFIER_RE) ?? []) {
|
|
171
|
+
if (identifier.toUpperCase() === "BUNDLE_GITHUB__COM") return true;
|
|
172
|
+
if (SECRET_KEYWORD_RE.test(identifier) && isIdentifierShaped(identifier)) return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// An angle-bracket PLACEHOLDER or generic type — `<owner/name>`, `<x>`, a
|
|
178
|
+
// `Map` of two type parameters where the second is a credential-named type —
|
|
179
|
+
// whose trailing angle bracket is not a shell redirect. The content charset
|
|
180
|
+
// is deliberately narrow (identifier/path-ish chars only: letters, digits,
|
|
181
|
+
// `_ , . / : -` and interior spaces) and must both START and END on an
|
|
182
|
+
// identifier/path char (never on a space): this is what keeps a REAL
|
|
183
|
+
// redirect pair like `<input.txt >output.txt` (space right before the
|
|
184
|
+
// trailing bracket) from being misread as one placeholder spanning both —
|
|
185
|
+
// the bracket-strip below would otherwise swallow a genuine redirect target.
|
|
186
|
+
// Applied in a loop so nested placeholders (two levels of generic type
|
|
187
|
+
// parameters) are fully stripped, innermost first.
|
|
188
|
+
const ANGLE_PLACEHOLDER_RE = /<[A-Za-z0-9_](?:[A-Za-z0-9_ ,./:-]*[A-Za-z0-9_.])?>/gu;
|
|
189
|
+
|
|
190
|
+
function stripAnglePlaceholders(text) {
|
|
191
|
+
let stripped = text;
|
|
192
|
+
let previous;
|
|
193
|
+
do {
|
|
194
|
+
previous = stripped;
|
|
195
|
+
stripped = stripped.replace(ANGLE_PLACEHOLDER_RE, "");
|
|
196
|
+
} while (stripped !== previous);
|
|
197
|
+
return stripped;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Output sinks named in the motivating incident: echo/printf/tee, base64
|
|
201
|
+
// (encode-then-emit), a `>`/`>>` redirect, and a GitHub Actions workflow
|
|
202
|
+
// `::directive::`. This is deliberately a same-line, no-literal-value-
|
|
203
|
+
// required class, so a variable name reaching a sink is enough on its own.
|
|
204
|
+
// A workflow mask directive next to the sink call does not change the
|
|
205
|
+
// verdict: masking only redacts what CI's log renderer shows LATER, so a
|
|
206
|
+
// value still reaches this stream unmasked, and the flow is still a hit.
|
|
207
|
+
// The redirect branch excludes `=>`/`->`/`>=` (lookbehind/lookahead around the
|
|
208
|
+
// bare `>`/`>>`) — those are an arrow function or a comparison in ordinary
|
|
209
|
+
// source, not a shell redirect, and would otherwise fire on nearly any JS/TS
|
|
210
|
+
// line that also happens to name a *TOKEN*/*SECRET*/... variable — and
|
|
211
|
+
// requires a plausible target to follow (`> file`, `>>out.log`,
|
|
212
|
+
// `>/dev/null`), never a bare `>` with nothing after it. Run against the
|
|
213
|
+
// angle-placeholder-stripped text (see stripAnglePlaceholders) so a `>` that
|
|
214
|
+
// merely closes a `<owner/name>` placeholder or a `Map<string, AuthToken>`
|
|
215
|
+
// generic is never read as a redirect.
|
|
216
|
+
const SINK_RE = /\b(?:echo|printf|tee|base64)\b|(?<![=-])>{1,2}(?!=)(?=\s*\S)|::[A-Za-z][\w-]*::/u;
|
|
217
|
+
|
|
218
|
+
function hasSecretNameToSinkFlow(text) {
|
|
219
|
+
return hasSecretNamedIdentifier(text) && SINK_RE.test(stripAnglePlaceholders(text));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The reason a matched line is allowlisted, or `null` when it is not. Any
|
|
224
|
+
* comment syntax works (`#`, `//`, `<!--`, ...): this is a plain substring
|
|
225
|
+
* test, not a language-aware parse — the `secret-scan:allow` marker plus a
|
|
226
|
+
* trailing reason exempts the ONE line it appears on, nothing else.
|
|
227
|
+
* @param {string} text
|
|
228
|
+
* @returns {string|null}
|
|
229
|
+
*/
|
|
230
|
+
export function allowlistReason(text) {
|
|
231
|
+
const match = ALLOW_RE.exec(text);
|
|
232
|
+
return match ? match[1] : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Scan one added line's text against all three detector classes.
|
|
237
|
+
* @param {string} text
|
|
238
|
+
* @returns {{ detectorClass: string, reason: string }[]}
|
|
239
|
+
*/
|
|
240
|
+
export function scanLineText(text) {
|
|
241
|
+
if (allowlistReason(text) !== null) return [];
|
|
242
|
+
const findings = [];
|
|
243
|
+
const literal = matchLiteralPattern(text) ?? matchBase64Credential(text);
|
|
244
|
+
if (literal) {
|
|
245
|
+
findings.push({
|
|
246
|
+
detectorClass: DETECTOR_CLASSES.LITERAL_CREDENTIAL,
|
|
247
|
+
reason: `matches known credential format (${literal})`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (hasHighEntropyToken(text)) {
|
|
251
|
+
findings.push({
|
|
252
|
+
detectorClass: DETECTOR_CLASSES.HIGH_ENTROPY,
|
|
253
|
+
reason: "high-entropy token-shaped literal",
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (hasSecretNameToSinkFlow(text)) {
|
|
257
|
+
findings.push({
|
|
258
|
+
detectorClass: DETECTOR_CLASSES.SINK_PATTERN,
|
|
259
|
+
reason: "secret-named variable flows into an output sink on this line",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
return findings;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Parse a unified diff (`git diff --cached` output) into `{ file, line, text
|
|
267
|
+
* }` entries — one per ADDED line, `line` the new-file 1-based line number.
|
|
268
|
+
* Removed/context lines are skipped (but context lines still advance the
|
|
269
|
+
* new-file line counter). A binary-file diff (no `@@` hunk) yields no
|
|
270
|
+
* entries for that file.
|
|
271
|
+
* @param {string} diffText
|
|
272
|
+
* @returns {{ file: string, line: number, text: string }[]}
|
|
273
|
+
*/
|
|
274
|
+
export function parseAddedLines(diffText) {
|
|
275
|
+
const entries = [];
|
|
276
|
+
let file = null;
|
|
277
|
+
let newLine = 0;
|
|
278
|
+
// `+++ `/`--- ` are STRUCTURAL file headers only before the first `@@`
|
|
279
|
+
// hunk of a file — inside a hunk, a line starting with `+` (even `++ ` or
|
|
280
|
+
// `--- `-shaped content) is added/removed CONTENT, never a header. Gate the
|
|
281
|
+
// header checks on hunk position (`inHunk`), not a naive prefix test alone,
|
|
282
|
+
// so a planted credential on a line whose content happens to start with
|
|
283
|
+
// `+ ` can never be misread as a header and skipped from every detector.
|
|
284
|
+
let inHunk = false;
|
|
285
|
+
for (const rawLine of diffText.split("\n")) {
|
|
286
|
+
if (rawLine.startsWith("diff --git ")) {
|
|
287
|
+
inHunk = false;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (!inHunk && rawLine.startsWith("+++ ")) {
|
|
291
|
+
const target = rawLine.slice(4).replace(/\t.*$/u, "");
|
|
292
|
+
file = target === "/dev/null" ? null : target.replace(/^b\//u, "");
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (!inHunk && rawLine.startsWith("--- ")) continue;
|
|
296
|
+
if (rawLine.startsWith("@@")) {
|
|
297
|
+
const match = rawLine.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/u);
|
|
298
|
+
newLine = match ? Number.parseInt(match[1], 10) : 0;
|
|
299
|
+
inHunk = true;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
if (rawLine.startsWith("+")) {
|
|
303
|
+
entries.push({ file, line: newLine, text: rawLine.slice(1) });
|
|
304
|
+
newLine += 1;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (rawLine.startsWith("-")) continue; // removed line — never advances the new-file counter
|
|
308
|
+
if (rawLine.startsWith("\\")) continue; // ""
|
|
309
|
+
// A context line (leading space) or a blank line inside a hunk both
|
|
310
|
+
// still exist in the new file, so both advance the counter.
|
|
311
|
+
if (newLine > 0) newLine += 1;
|
|
312
|
+
}
|
|
313
|
+
return entries;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Scan a full unified diff. Findings never carry the matched substring —
|
|
318
|
+
* only file/line/detectorClass/reason.
|
|
319
|
+
* @param {string} diffText
|
|
320
|
+
* @returns {{ ok: boolean, findings: { file: string, line: number, detectorClass: string, reason: string }[] }}
|
|
321
|
+
*/
|
|
322
|
+
export function scanDiffText(diffText) {
|
|
323
|
+
const findings = [];
|
|
324
|
+
for (const entry of parseAddedLines(diffText)) {
|
|
325
|
+
for (const hit of scanLineText(entry.text)) {
|
|
326
|
+
findings.push({ file: entry.file, line: entry.line, ...hit });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return { ok: findings.length === 0, findings };
|
|
330
|
+
}
|