@dev-loops/core 1.0.0-rc.6 → 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 +7 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +36 -4
- package/src/cli/primitives.mjs +30 -1
- package/src/config/config.mjs +254 -13
- package/src/config/extension-defaults.yaml +34 -1
- package/src/github/comment-id-guard.mjs +97 -9
- package/src/github/copilot-helpers.mjs +114 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +7 -0
- package/src/loop/agent-stall.mjs +4 -2
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +34 -1
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +12 -19
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +34 -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/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 +448 -9
- 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/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +1 -27
- package/src/security/secret-scan.mjs +330 -0
|
@@ -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
|
+
}
|