@foldrun/core 1.2.0 → 1.3.0
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/dist/src/scan.d.ts +6 -0
- package/dist/src/scan.js +52 -12
- package/package.json +1 -1
- package/src/scan.ts +53 -12
package/dist/src/scan.d.ts
CHANGED
|
@@ -33,6 +33,9 @@ export interface ScanOptions {
|
|
|
33
33
|
outwardTools?: Iterable<string>;
|
|
34
34
|
/** Only these rule ids (or categories, `secret`, `security`, …). */
|
|
35
35
|
only?: string[];
|
|
36
|
+
/** The account AGENTS.md's `network:` — the level above the workspace's,
|
|
37
|
+
* which the files of one workspace do not carry. */
|
|
38
|
+
accountNetwork?: unknown;
|
|
36
39
|
}
|
|
37
40
|
export declare const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
|
|
38
41
|
export declare const SCAN_RULES: readonly ScanRule[];
|
|
@@ -60,6 +63,9 @@ export declare function ruleDocsUrl(id: string): string;
|
|
|
60
63
|
export declare function findingFingerprint(rule: string, file: string, text: string): string;
|
|
61
64
|
/** What a finding may show of a secret: enough to find it, not to use it. */
|
|
62
65
|
export declare function redactSecret(secret: string): string;
|
|
66
|
+
/** The text with every credential in it redacted — for a finding that
|
|
67
|
+
* quotes source, so no message ever carries a key whole. */
|
|
68
|
+
export declare function redactText(text: string): string;
|
|
63
69
|
/** The known field a key was probably meant to be, or null. */
|
|
64
70
|
export declare function nearestField(key: string, known: readonly string[]): string | null;
|
|
65
71
|
/**
|
package/dist/src/scan.js
CHANGED
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import crypto from "node:crypto";
|
|
29
29
|
import matter from "gray-matter";
|
|
30
30
|
import { refNames } from "./refs.js";
|
|
31
|
+
import { resolveNetwork } from "./network.js";
|
|
31
32
|
export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
|
|
32
33
|
export const SCAN_RULES = [
|
|
33
34
|
{
|
|
@@ -74,7 +75,8 @@ export const SCAN_RULES = [
|
|
|
74
75
|
level: "warn",
|
|
75
76
|
title: "Reads untrusted content, holds private data and can send it out",
|
|
76
77
|
help: "An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
|
|
77
|
-
"Take one leg away:
|
|
78
|
+
"Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
|
|
79
|
+
"and an agent that reads the web stays open), split the reading and the sending into two agents, " +
|
|
78
80
|
"or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
|
|
79
81
|
owasp: ["ASI01", "ASI02", "ASI03"],
|
|
80
82
|
},
|
|
@@ -272,6 +274,41 @@ function credentialLike(value) {
|
|
|
272
274
|
export function redactSecret(secret) {
|
|
273
275
|
return secret.length <= 8 ? "****" : `${secret.slice(0, 4)}…${secret.slice(-2)}`;
|
|
274
276
|
}
|
|
277
|
+
/** Every credential in a piece of text, as [value, what]. One reading for
|
|
278
|
+
* the secret rule and for any other rule that quotes the line. */
|
|
279
|
+
function credentialsIn(line) {
|
|
280
|
+
const out = [];
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
const add = (v, what) => {
|
|
283
|
+
if (!seen.has(v)) {
|
|
284
|
+
seen.add(v);
|
|
285
|
+
out.push([v, what]);
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
for (const [re, what] of SECRET_SHAPES) {
|
|
289
|
+
if (what === "a private key")
|
|
290
|
+
continue; // a header names a key; it is not one
|
|
291
|
+
for (const m of line.matchAll(re)) {
|
|
292
|
+
if (!PLACEHOLDER.test(m[0]) && entropy(m[0]) >= 3)
|
|
293
|
+
add(m[0], what);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
for (const m of line.matchAll(ASSIGNED))
|
|
297
|
+
if (credentialLike(m[2]))
|
|
298
|
+
add(m[2], `\`${m[1]}\` with a literal value`);
|
|
299
|
+
for (const m of line.matchAll(BEARER))
|
|
300
|
+
if (credentialLike(m[1]))
|
|
301
|
+
add(m[1], "a bearer token");
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
/** The text with every credential in it redacted — for a finding that
|
|
305
|
+
* quotes source, so no message ever carries a key whole. */
|
|
306
|
+
export function redactText(text) {
|
|
307
|
+
let out = text;
|
|
308
|
+
for (const [v] of credentialsIn(text).sort((a, b) => b[0].length - a[0].length))
|
|
309
|
+
out = out.split(v).join(redactSecret(v));
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
275
312
|
function scanSecrets(file, lines, push) {
|
|
276
313
|
for (let i = 0; i < lines.length; i++) {
|
|
277
314
|
const line = lines[i];
|
|
@@ -351,13 +388,13 @@ function scanContent(file, lines, push) {
|
|
|
351
388
|
const kind = HIDDEN_NAMES.find(([re]) => re.test(line))?.[1] ?? "invisible characters";
|
|
352
389
|
const n = line.match(HIDDEN)?.length ?? 0;
|
|
353
390
|
const tagged = /\uDB40[\uDC00-\uDC7F]/.test(line) ? ` that read as: "${decodeTags(line).slice(0, 80)}"` : "";
|
|
354
|
-
push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${tagged} — invisible to a reviewer, read by the model`);
|
|
391
|
+
push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${redactText(tagged)} — invisible to a reviewer, read by the model`);
|
|
355
392
|
}
|
|
356
393
|
// An instruction quoted inside a fence is an example of one, not one.
|
|
357
394
|
if (data && !fenced) {
|
|
358
395
|
const m = INJECTION.find((re) => re.test(line));
|
|
359
396
|
if (m)
|
|
360
|
-
push("content/injection-phrase", file.path, i + 1, line, `"${line.trim().slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
|
|
397
|
+
push("content/injection-phrase", file.path, i + 1, line, `"${redactText(line.trim()).slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
|
|
361
398
|
}
|
|
362
399
|
if (md) {
|
|
363
400
|
for (const b of line.matchAll(BLOB)) {
|
|
@@ -502,15 +539,13 @@ function docKind(p) {
|
|
|
502
539
|
}
|
|
503
540
|
// ── agents: what one can reach, taken together ─────────────────────────────
|
|
504
541
|
const WRITE_METHODS = /\b(?:POST|PUT|PATCH|DELETE)\b/i;
|
|
505
|
-
function scanAgents(agents, outward, toolDefs, push) {
|
|
542
|
+
function scanAgents(agents, outward, toolDefs, push, networks) {
|
|
506
543
|
const byName = new Map(agents.map((a) => [a.name, a]));
|
|
507
544
|
for (const a of agents) {
|
|
508
545
|
const d = a.data;
|
|
509
546
|
const tools = refNames(d.tools);
|
|
510
547
|
const denied = new Set(refNames(d.disallowedTools));
|
|
511
548
|
const has = (t) => tools.includes(t) && !denied.has(t);
|
|
512
|
-
const network = d.network;
|
|
513
|
-
const netLevel = typeof network === "string" ? network : Array.isArray(network) ? "list" : network && typeof network === "object" ? "list" : null;
|
|
514
549
|
// Untrusted: it reads the open web. (A webhook body is the other source;
|
|
515
550
|
// that is a flow's trigger, and the flow-level rule is a later one.)
|
|
516
551
|
const readsWeb = has("web") || tools.some((t) => /browse|fetch|scrape|crawl/i.test(t) && has(t));
|
|
@@ -519,16 +554,18 @@ function scanAgents(agents, outward, toolDefs, push) {
|
|
|
519
554
|
const apis = Array.isArray(d.apis) ? d.apis : [];
|
|
520
555
|
const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers) : [];
|
|
521
556
|
const privateLeg = [...secrets.map((s) => `secret ${s}`), ...apis.map((x) => `api ${x?.name ?? "?"}`), ...mcp.map((m) => `MCP ${m}`)];
|
|
522
|
-
// Out: a sandbox open to every site
|
|
523
|
-
//
|
|
524
|
-
|
|
557
|
+
// Out: a sandbox open to every site, a tool marked outward, or an API it
|
|
558
|
+
// may write to. The level is the runtime's own resolution — agent, then
|
|
559
|
+
// workspace, then account, and open by default for an agent that reads
|
|
560
|
+
// the web; a bare list of sites adds to a level, it does not set one.
|
|
561
|
+
const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
|
|
525
562
|
const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
|
|
526
563
|
const writeApis = apis.filter((x) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
|
|
527
564
|
const outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x) => `writes to api ${x?.name ?? "?"}`)];
|
|
528
565
|
const gated = refNames(d.hooks).length > 0 || d.permissionMode === "plan";
|
|
529
566
|
if (readsWeb && privateLeg.length && outLeg.length && !gated) {
|
|
530
567
|
push("security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`, `${a.name} reads the web, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
|
|
531
|
-
`a page it reads can tell it where to send what it holds.
|
|
568
|
+
`a page it reads can tell it where to send what it holds. Take it off the open network (network: { level: trusted, allow: [...] }), split it, or add a hooks: gate`);
|
|
532
569
|
}
|
|
533
570
|
// Sub-agents declaring tools the parent lacks.
|
|
534
571
|
for (const s of refNames(d.subagents)) {
|
|
@@ -666,7 +703,8 @@ export function scanFiles(files, options = {}) {
|
|
|
666
703
|
toolDefs.set(f.path, { data, lines });
|
|
667
704
|
}
|
|
668
705
|
}
|
|
669
|
-
|
|
706
|
+
const workspaceMd = files.find((f) => f.path === "AGENTS.md");
|
|
707
|
+
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
|
|
670
708
|
return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
|
|
671
709
|
}
|
|
672
710
|
/**
|
|
@@ -698,7 +736,9 @@ export function applyBaseline(findings, baseline) {
|
|
|
698
736
|
const b = baseline;
|
|
699
737
|
if (!b || b.version !== 1 || !Array.isArray(b.findings))
|
|
700
738
|
return { kept: findings, baselined: 0 };
|
|
701
|
-
|
|
739
|
+
// An entry that is not {rule, file, fingerprint} hides nothing.
|
|
740
|
+
const entries = b.findings.filter((f) => !!f && typeof f === "object" && typeof f.rule === "string" && typeof f.file === "string" && typeof f.fingerprint === "string");
|
|
741
|
+
const known = new Set(entries.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
|
|
702
742
|
const kept = findings.filter((f) => !known.has(`${f.rule}\0${f.file}\0${f.fingerprint}`));
|
|
703
743
|
return { kept, baselined: findings.length - kept.length };
|
|
704
744
|
}
|
package/package.json
CHANGED
package/src/scan.ts
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import crypto from "node:crypto";
|
|
30
30
|
import matter from "gray-matter";
|
|
31
31
|
import { refNames } from "./refs.ts";
|
|
32
|
+
import { resolveNetwork } from "./network.ts";
|
|
32
33
|
|
|
33
34
|
export type ScanLevel = "error" | "warn" | "info";
|
|
34
35
|
export type ScanCategory = "secret" | "security" | "content" | "supply-chain" | "config";
|
|
@@ -69,6 +70,9 @@ export interface ScanOptions {
|
|
|
69
70
|
outwardTools?: Iterable<string>;
|
|
70
71
|
/** Only these rule ids (or categories, `secret`, `security`, …). */
|
|
71
72
|
only?: string[];
|
|
73
|
+
/** The account AGENTS.md's `network:` — the level above the workspace's,
|
|
74
|
+
* which the files of one workspace do not carry. */
|
|
75
|
+
accountNetwork?: unknown;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
export const OWASP_URL = "https://genai.owasp.org/resource/owasp-top-10-for-agentic-applications-for-2026";
|
|
@@ -123,7 +127,8 @@ export const SCAN_RULES: readonly ScanRule[] = [
|
|
|
123
127
|
title: "Reads untrusted content, holds private data and can send it out",
|
|
124
128
|
help:
|
|
125
129
|
"An agent with all three can be told — by any page it reads — to send what it holds anywhere it can reach. " +
|
|
126
|
-
"Take one leg away:
|
|
130
|
+
"Take one leg away: take it off the open network (`network: { level: trusted, allow: [the sites it needs] }` — a bare list only adds sites, " +
|
|
131
|
+
"and an agent that reads the web stays open), split the reading and the sending into two agents, " +
|
|
127
132
|
"or guard its calls with a `hooks:` gate. An approval (`!`) on every outward step is the other answer.",
|
|
128
133
|
owasp: ["ASI01", "ASI02", "ASI03"],
|
|
129
134
|
},
|
|
@@ -329,6 +334,36 @@ export function redactSecret(secret: string): string {
|
|
|
329
334
|
return secret.length <= 8 ? "****" : `${secret.slice(0, 4)}…${secret.slice(-2)}`;
|
|
330
335
|
}
|
|
331
336
|
|
|
337
|
+
/** Every credential in a piece of text, as [value, what]. One reading for
|
|
338
|
+
* the secret rule and for any other rule that quotes the line. */
|
|
339
|
+
function credentialsIn(line: string): [string, string][] {
|
|
340
|
+
const out: [string, string][] = [];
|
|
341
|
+
const seen = new Set<string>();
|
|
342
|
+
const add = (v: string, what: string) => {
|
|
343
|
+
if (!seen.has(v)) {
|
|
344
|
+
seen.add(v);
|
|
345
|
+
out.push([v, what]);
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
for (const [re, what] of SECRET_SHAPES) {
|
|
349
|
+
if (what === "a private key") continue; // a header names a key; it is not one
|
|
350
|
+
for (const m of line.matchAll(re)) {
|
|
351
|
+
if (!PLACEHOLDER.test(m[0]) && entropy(m[0]) >= 3) add(m[0], what);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
for (const m of line.matchAll(ASSIGNED)) if (credentialLike(m[2]!)) add(m[2]!, `\`${m[1]}\` with a literal value`);
|
|
355
|
+
for (const m of line.matchAll(BEARER)) if (credentialLike(m[1]!)) add(m[1]!, "a bearer token");
|
|
356
|
+
return out;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** The text with every credential in it redacted — for a finding that
|
|
360
|
+
* quotes source, so no message ever carries a key whole. */
|
|
361
|
+
export function redactText(text: string): string {
|
|
362
|
+
let out = text;
|
|
363
|
+
for (const [v] of credentialsIn(text).sort((a, b) => b[0].length - a[0].length)) out = out.split(v).join(redactSecret(v));
|
|
364
|
+
return out;
|
|
365
|
+
}
|
|
366
|
+
|
|
332
367
|
function scanSecrets(file: ScanFile, lines: string[], push: Push) {
|
|
333
368
|
for (let i = 0; i < lines.length; i++) {
|
|
334
369
|
const line = lines[i]!;
|
|
@@ -403,12 +438,12 @@ function scanContent(file: ScanFile, lines: string[], push: Push) {
|
|
|
403
438
|
const kind = HIDDEN_NAMES.find(([re]) => re.test(line))?.[1] ?? "invisible characters";
|
|
404
439
|
const n = line.match(HIDDEN)?.length ?? 0;
|
|
405
440
|
const tagged = /\uDB40[\uDC00-\uDC7F]/.test(line) ? ` that read as: "${decodeTags(line).slice(0, 80)}"` : "";
|
|
406
|
-
push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${tagged} — invisible to a reviewer, read by the model`);
|
|
441
|
+
push("content/hidden-unicode", file.path, i + 1, line, `${n} ${kind}${redactText(tagged)} — invisible to a reviewer, read by the model`);
|
|
407
442
|
}
|
|
408
443
|
// An instruction quoted inside a fence is an example of one, not one.
|
|
409
444
|
if (data && !fenced) {
|
|
410
445
|
const m = INJECTION.find((re) => re.test(line));
|
|
411
|
-
if (m) push("content/injection-phrase", file.path, i + 1, line, `"${line.trim().slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
|
|
446
|
+
if (m) push("content/injection-phrase", file.path, i + 1, line, `"${redactText(line.trim()).slice(0, 90)}" — agents read this file as data, and this line reads as an order`);
|
|
412
447
|
}
|
|
413
448
|
if (md) {
|
|
414
449
|
for (const b of line.matchAll(BLOB)) {
|
|
@@ -568,15 +603,13 @@ interface ToolFacts {
|
|
|
568
603
|
lines: string[];
|
|
569
604
|
}
|
|
570
605
|
|
|
571
|
-
function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<string, ToolFacts>, push: Push) {
|
|
606
|
+
function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<string, ToolFacts>, push: Push, networks: { account?: unknown; workspace?: unknown }) {
|
|
572
607
|
const byName = new Map(agents.map((a) => [a.name, a]));
|
|
573
608
|
for (const a of agents) {
|
|
574
609
|
const d = a.data;
|
|
575
610
|
const tools = refNames(d.tools);
|
|
576
611
|
const denied = new Set(refNames(d.disallowedTools));
|
|
577
612
|
const has = (t: string) => tools.includes(t) && !denied.has(t);
|
|
578
|
-
const network = d.network;
|
|
579
|
-
const netLevel = typeof network === "string" ? network : Array.isArray(network) ? "list" : network && typeof network === "object" ? "list" : null;
|
|
580
613
|
|
|
581
614
|
// Untrusted: it reads the open web. (A webhook body is the other source;
|
|
582
615
|
// that is a flow's trigger, and the flow-level rule is a later one.)
|
|
@@ -586,9 +619,11 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
|
|
|
586
619
|
const apis = Array.isArray(d.apis) ? d.apis : [];
|
|
587
620
|
const mcp = d.mcpServers && typeof d.mcpServers === "object" ? Object.keys(d.mcpServers as object) : [];
|
|
588
621
|
const privateLeg = [...secrets.map((s) => `secret ${s}`), ...apis.map((x: any) => `api ${x?.name ?? "?"}`), ...mcp.map((m) => `MCP ${m}`)];
|
|
589
|
-
// Out: a sandbox open to every site
|
|
590
|
-
//
|
|
591
|
-
|
|
622
|
+
// Out: a sandbox open to every site, a tool marked outward, or an API it
|
|
623
|
+
// may write to. The level is the runtime's own resolution — agent, then
|
|
624
|
+
// workspace, then account, and open by default for an agent that reads
|
|
625
|
+
// the web; a bare list of sites adds to a level, it does not set one.
|
|
626
|
+
const openNet = resolveNetwork({ account: networks.account, workspace: networks.workspace, agent: d.network, readsWeb }).level === "open";
|
|
592
627
|
const outwardTools = tools.filter((t) => outward.has(t) && !denied.has(t));
|
|
593
628
|
const writeApis = apis.filter((x: any) => WRITE_METHODS.test(JSON.stringify(x?.methods ?? x?.method ?? "")));
|
|
594
629
|
const outLeg = [...(openNet ? ["an open network"] : []), ...outwardTools.map((t) => `outward tool ${t}`), ...writeApis.map((x: any) => `writes to api ${x?.name ?? "?"}`)];
|
|
@@ -598,7 +633,7 @@ function scanAgents(agents: AgentFacts[], outward: Set<string>, toolDefs: Map<st
|
|
|
598
633
|
push(
|
|
599
634
|
"security/lethal-trifecta", a.file, a.line, `trifecta ${a.name}`,
|
|
600
635
|
`${a.name} reads the web, holds ${privateLeg.slice(0, 3).join(", ")}${privateLeg.length > 3 ? ", …" : ""} and can reach ${outLeg.slice(0, 2).join(" and ")} — ` +
|
|
601
|
-
`a page it reads can tell it where to send what it holds.
|
|
636
|
+
`a page it reads can tell it where to send what it holds. Take it off the open network (network: { level: trusted, allow: [...] }), split it, or add a hooks: gate`,
|
|
602
637
|
);
|
|
603
638
|
}
|
|
604
639
|
|
|
@@ -748,7 +783,8 @@ export function scanFiles(files: ScanFile[], options: ScanOptions = {}): ScanFin
|
|
|
748
783
|
toolDefs.set(f.path, { data, lines });
|
|
749
784
|
}
|
|
750
785
|
}
|
|
751
|
-
|
|
786
|
+
const workspaceMd = files.find((f) => f.path === "AGENTS.md");
|
|
787
|
+
scanAgents(agents, outward, toolDefs, push, { account: options.accountNetwork, workspace: workspaceMd ? frontOf(workspaceMd.content)?.network : undefined });
|
|
752
788
|
|
|
753
789
|
return findings.sort((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0) || a.rule.localeCompare(b.rule));
|
|
754
790
|
}
|
|
@@ -791,7 +827,12 @@ export function makeBaseline(findings: Pick<ScanFinding, "rule" | "file" | "fing
|
|
|
791
827
|
export function applyBaseline<T extends Pick<ScanFinding, "rule" | "file" | "fingerprint">>(findings: T[], baseline: unknown): { kept: T[]; baselined: number } {
|
|
792
828
|
const b = baseline as ScanBaseline | null;
|
|
793
829
|
if (!b || b.version !== 1 || !Array.isArray(b.findings)) return { kept: findings, baselined: 0 };
|
|
794
|
-
|
|
830
|
+
// An entry that is not {rule, file, fingerprint} hides nothing.
|
|
831
|
+
const entries = (b.findings as unknown[]).filter(
|
|
832
|
+
(f): f is ScanBaseline["findings"][number] =>
|
|
833
|
+
!!f && typeof f === "object" && typeof (f as any).rule === "string" && typeof (f as any).file === "string" && typeof (f as any).fingerprint === "string",
|
|
834
|
+
);
|
|
835
|
+
const known = new Set(entries.map((f) => `${f.rule}\0${f.file}\0${f.fingerprint}`));
|
|
795
836
|
const kept = findings.filter((f) => !known.has(`${f.rule}\0${f.file}\0${f.fingerprint}`));
|
|
796
837
|
return { kept, baselined: findings.length - kept.length };
|
|
797
838
|
}
|