@clear-capabilities/agentic-security-scanner 0.195.22 → 0.195.24
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 +26 -0
- package/dist/3596.index.js +1 -1
- package/dist/550.index.js +9 -8
- package/dist/5620.index.js +59 -10
- package/dist/5937.index.js +88 -16
- package/dist/7838.index.js +32 -5
- package/dist/9441.index.js +50 -7
- package/dist/9824.index.js +19 -13
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/{c1e282247b06d51c8ffb.json → daefda0819bb18309b75.json} +3 -3
- package/package.json +3 -3
- package/src/discovery/detector-spec-synthesis.js +3 -3
- package/src/discovery/llm-invoke.js +3 -3
- package/src/egress/transport.js +148 -0
- package/src/lineage/CLAUDE.md +12 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +23 -0
- package/src/lineage/driver.js +19 -2
- package/src/lineage/engine.js +24 -1
- package/src/lineage/graph-builder.js +3 -1
- package/src/lineage/lineage-source-catalog.js +143 -0
- package/src/lineage/source-seeding.js +142 -16
- package/src/lineage/summaries.js +21 -5
- package/src/llm-validator/index.js +4 -2
- package/src/llm-validator/local-endpoint.js +4 -1
- package/src/llm-validator/ollama-provider.js +8 -6
- package/src/posture/adversary-agent.js +27 -10
- package/src/posture/custom-rules.js +13 -1
- package/src/posture/fleet/cli.js +2 -4
- package/src/posture/fleet/fleet-manifest.js +4 -1
- package/src/posture/fleet/job-runner.js +28 -4
- package/src/posture/fleet/org-health.js +23 -0
- package/src/posture/fleet/scan-cache.js +1 -1
- package/src/posture/flow-narration.js +10 -8
- package/src/posture/llm-redteam.js +3 -1
- package/src/posture/provenance/coordinator.js +8 -0
- package/src/posture/provenance/origin-resolver.js +17 -4
- package/src/posture/rule-compiler/cli.js +56 -8
- package/src/posture/rule-compiler/corpus.js +10 -2
- package/src/posture/rule-compiler/ir.js +10 -1
- package/src/posture/rule-compiler/promotion.js +12 -2
- package/src/posture/rule-compiler/regression.js +1 -1
- package/src/posture/rule-compiler/store.js +27 -7
- package/src/posture/strategic-metrics.js +50 -7
- package/src/sca/llm-function-extract.js +17 -13
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
//
|
|
2
|
+
// lineage-source-catalog.js — JavaScript data collection points that are
|
|
3
|
+
// lineage sources but NOT taint sources (GA plan, GA-03.1: "address
|
|
4
|
+
// source-category omissions without broadening the main taint catalog
|
|
5
|
+
// incorrectly").
|
|
6
|
+
//
|
|
7
|
+
// Four of the fourteen reachable `SOURCE_CATEGORIES` had no `language: 'js'`
|
|
8
|
+
// entry in `dataflow/catalog.js` at all: `cli-argument` (only `py-sys-argv`
|
|
9
|
+
// and the argparse entries), `http-upload` (only Django and Symfony),
|
|
10
|
+
// `storage-read` (only `py-open-read` and the C `read`/`fread`/`fgets`
|
|
11
|
+
// entries) and `user-input` (only `py-input`, `gets`, `scanf`). The lineage
|
|
12
|
+
// corpus therefore could not mint a source node for any of them from a
|
|
13
|
+
// JavaScript fixture, and the four corpus entries that name them were
|
|
14
|
+
// written in Python syntax on purpose, as a disclosed limitation.
|
|
15
|
+
//
|
|
16
|
+
// Why these entries live HERE and not in `dataflow/catalog.js`'s `CATALOG`:
|
|
17
|
+
// that catalog is the taint engine's source list, and every entry in it
|
|
18
|
+
// becomes an attacker-controlled input for every SAST and taint finding the
|
|
19
|
+
// engine emits. A file's contents (`fs.readFileSync`) and a console prompt
|
|
20
|
+
// (`prompt()`, `readlineSync.question()`) are places personal data ENTERS a
|
|
21
|
+
// program, which is what a lineage graph records, but treating them as
|
|
22
|
+
// attacker-controlled would turn every configuration read into a taint
|
|
23
|
+
// source and flood the SAST output with findings nobody asked for. The
|
|
24
|
+
// Data Flow Explorer already keeps a second, isolated catalog for the same
|
|
25
|
+
// reason (`dataflow/privacy-catalog.js`, never merged into `CATALOG`); this
|
|
26
|
+
// module follows that precedent on the source side. `test/lineage/
|
|
27
|
+
// lineage-source-catalog.test.js` pins that `dataflow/catalog.js`'s own
|
|
28
|
+
// `matchSource` returns null for every shape listed here, so adding an
|
|
29
|
+
// entry here can never widen the taint engine by accident.
|
|
30
|
+
//
|
|
31
|
+
// Reuse boundary: imports nothing from `dataflow/`. The matcher below is a
|
|
32
|
+
// deliberately small structural match over the parser's expression shape
|
|
33
|
+
// (`ident`/`member`/`call`), not a copy of `catalog.js`'s index machinery;
|
|
34
|
+
// an entry that needs receiver types, keyword arguments or a language family
|
|
35
|
+
// other than JavaScript belongs in the main catalog, with the taint
|
|
36
|
+
// consequences weighed there.
|
|
37
|
+
|
|
38
|
+
import { reclassifySource } from './source-registry.js';
|
|
39
|
+
|
|
40
|
+
const JS_FILE = /\.(?:[cm]?js|jsx|tsx?)$/i;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Each entry mirrors the main catalog's source shape (`kind`, `id`,
|
|
44
|
+
* `language`, `framework`, `match`, `label`, `provenance`) so
|
|
45
|
+
* `reclassifySource` reads it unchanged: `provenance` is the key the
|
|
46
|
+
* registry's `PROVENANCE_MAP` resolves to a `SOURCE_CATEGORIES` value.
|
|
47
|
+
* `lineageOnly: true` is the marker a reader greps for; nothing consults it.
|
|
48
|
+
*/
|
|
49
|
+
export const LINEAGE_SOURCE_CATALOG = Object.freeze([
|
|
50
|
+
{
|
|
51
|
+
kind: 'source', id: 'js-process-argv', language: 'js', framework: 'node', lineageOnly: true,
|
|
52
|
+
match: { type: 'member', object: 'process', prop: 'argv' }, label: 'process.argv', provenance: 'cli',
|
|
53
|
+
why: 'a positional command-line argument; the value is whatever the operator typed, not attacker-controlled in the taint sense, so it is a collection point for lineage only',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
kind: 'source', id: 'js-multer-req-file', language: 'js', framework: 'express', lineageOnly: true,
|
|
57
|
+
match: { type: 'member', object: 'req', prop: 'file' }, label: 'req.file', provenance: 'upload',
|
|
58
|
+
why: 'the single uploaded file multer attaches to the request (name, type, buffer, path)',
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
kind: 'source', id: 'js-multer-req-files', language: 'js', framework: 'express', lineageOnly: true,
|
|
62
|
+
match: { type: 'member', object: 'req', prop: 'files' }, label: 'req.files', provenance: 'upload',
|
|
63
|
+
why: 'the uploaded files multer attaches to the request, keyed by field name',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
kind: 'source', id: 'js-fs-readFileSync', language: 'js', framework: 'node', lineageOnly: true,
|
|
67
|
+
match: { type: 'call', callee: 'readFileSync' }, label: 'fs.readFileSync()', provenance: 'file-read',
|
|
68
|
+
why: 'the contents of a file; the callback form `fs.readFile(p, cb)` delivers its contents to a callback and is not seeded (its path argument is already a sink in the main catalog)',
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
kind: 'source', id: 'js-window-prompt', language: 'js', framework: 'browser', lineageOnly: true,
|
|
72
|
+
match: { type: 'call', callee: 'prompt', bare: true }, label: 'prompt()', provenance: 'stdin',
|
|
73
|
+
why: 'text a person typed into the browser prompt dialog; only the bare global, never `x.prompt()` (inquirer and friends return a promise of an answers object)',
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
kind: 'source', id: 'js-readline-sync-question', language: 'js', framework: 'node', lineageOnly: true,
|
|
77
|
+
match: { type: 'call', callee: 'question', receiver: 'readlineSync' }, label: 'readlineSync.question()', provenance: 'stdin',
|
|
78
|
+
why: 'text a person typed at the console; the readline module\'s own `rl.question(q, cb)` delivers the answer to a callback and is not seeded',
|
|
79
|
+
},
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
/** The receiver chain of a member-call callee, outermost first (`a.b.c()` -> ['a', 'b']). */
|
|
83
|
+
function receiverSegments(callee) {
|
|
84
|
+
const segs = [];
|
|
85
|
+
let cur = callee && callee.kind === 'member' ? callee.object : null;
|
|
86
|
+
let depth = 0;
|
|
87
|
+
while (cur && depth++ < 8) {
|
|
88
|
+
if (cur.kind === 'ident') { segs.unshift(cur.name); break; }
|
|
89
|
+
if (cur.kind === 'member') { if (typeof cur.prop === 'string') segs.unshift(cur.prop); cur = cur.object; continue; }
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
return segs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function calleeName(callee) {
|
|
96
|
+
if (!callee) return null;
|
|
97
|
+
if (typeof callee === 'string') return callee.slice(callee.lastIndexOf('.') + 1);
|
|
98
|
+
if (callee.kind === 'ident') return callee.name ?? null;
|
|
99
|
+
if (callee.kind === 'member') return typeof callee.prop === 'string' ? callee.prop : null;
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function entryMatches(entry, expr) {
|
|
104
|
+
const m = entry.match;
|
|
105
|
+
if (m.type === 'member') {
|
|
106
|
+
return expr.kind === 'member' && expr.object?.kind === 'ident' && expr.object.name === m.object && expr.prop === m.prop;
|
|
107
|
+
}
|
|
108
|
+
if (m.type === 'call') {
|
|
109
|
+
if (expr.kind !== 'call') return false;
|
|
110
|
+
if (calleeName(expr.callee) !== m.callee) return false;
|
|
111
|
+
if (m.bare) return expr.callee && expr.callee.kind === 'ident';
|
|
112
|
+
if (m.receiver) return receiverSegments(expr.callee).includes(m.receiver);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The lineage-only counterpart of `dataflow/catalog.js`'s `matchSource`:
|
|
120
|
+
* returns the first catalog entry whose `match` fits `expr`, or null.
|
|
121
|
+
* Gated to JavaScript and TypeScript files (an entry here is JavaScript by
|
|
122
|
+
* construction); a missing `file` is permissive, matching `matchSource`.
|
|
123
|
+
* Never throws on a malformed expression.
|
|
124
|
+
*/
|
|
125
|
+
export function matchLineageSource(expr, file) {
|
|
126
|
+
if (!expr || typeof expr !== 'object') return null;
|
|
127
|
+
if (file && !JS_FILE.test(file)) return null;
|
|
128
|
+
for (const entry of LINEAGE_SOURCE_CATALOG) {
|
|
129
|
+
if (entryMatches(entry, expr)) return entry;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The registry decision for every entry, computed once: proves at load time that each `provenance` resolves. */
|
|
135
|
+
export const LINEAGE_SOURCE_DECISIONS = Object.freeze(Object.fromEntries(
|
|
136
|
+
LINEAGE_SOURCE_CATALOG.map((e) => {
|
|
137
|
+
const d = reclassifySource(e);
|
|
138
|
+
if (!d || !d.category || d.coverageStatus === 'unsupported') {
|
|
139
|
+
throw new Error(`lineage-source-catalog: ${e.id} has a provenance (${e.provenance}) the source registry cannot classify`);
|
|
140
|
+
}
|
|
141
|
+
return [e.id, d];
|
|
142
|
+
}),
|
|
143
|
+
));
|
|
@@ -30,6 +30,25 @@ import { emptyState, addIdentity } from './field-identity.js';
|
|
|
30
30
|
import { reclassifySource } from './source-registry.js';
|
|
31
31
|
import { classifyDataElementName } from './classification.js';
|
|
32
32
|
import { dataElementId } from './ids.js';
|
|
33
|
+
import { matchLineageSource } from './lineage-source-catalog.js';
|
|
34
|
+
|
|
35
|
+
// ── Seed kinds (GA-03.1, 0.195.24) ──────────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// `entry`: the identity is placed in the function's ENTRY state at
|
|
38
|
+
// `seedPath` (a member-shaped source such as `req.body.card_number`, whose
|
|
39
|
+
// path is stable for the whole function). This is §3's original mechanism.
|
|
40
|
+
//
|
|
41
|
+
// `after-node`: the identity is placed at `seedPath` in the state leaving
|
|
42
|
+
// CFG node `nodeId`, by `engine.js`'s worklist through `ctx.seedAfterNode`.
|
|
43
|
+
// Used for a CALL-shaped source assigned straight to a variable
|
|
44
|
+
// (`const record = fs.readFileSync(p)`, `const ssn = prompt('SSN')`,
|
|
45
|
+
// `const data = res.json()`): the call has no access path, so an entry seed
|
|
46
|
+
// has nowhere to land, and the value only exists in the target variable
|
|
47
|
+
// AFTER the assignment (an entry seed at the target would be killed by the
|
|
48
|
+
// assignment's own strong update). Before 0.195.24 every such match was
|
|
49
|
+
// recorded in `unseedable[]`; a call-shaped source anywhere else (nested
|
|
50
|
+
// inside another call's arguments, returned directly) still is.
|
|
51
|
+
export const SEED_KINDS = Object.freeze(['entry', 'after-node']);
|
|
33
52
|
|
|
34
53
|
// ── §3.1: where the matched expressions come from ──────────────────────────
|
|
35
54
|
//
|
|
@@ -169,6 +188,26 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
|
|
|
169
188
|
if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; continue; }
|
|
170
189
|
if (nodesVisited >= nodeCap) { stopReason = 'seeding-limit'; continue; }
|
|
171
190
|
functionsScanned += 1;
|
|
191
|
+
const fnSeeds = [];
|
|
192
|
+
const mint = ({ nid, node, entry, decision, seedPath, seedKind }) => {
|
|
193
|
+
const canonicalName = seedPath.slice(seedPath.lastIndexOf('.') + 1);
|
|
194
|
+
const seed = {
|
|
195
|
+
file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null,
|
|
196
|
+
entryId: entry.id, seedPath, canonicalName, seedKind,
|
|
197
|
+
category: decision.category, coverageStatus: decision.coverageStatus,
|
|
198
|
+
externality: decision.externality, reason: decision.reason,
|
|
199
|
+
// §3.4's minting rule. `canonicalName` alone is forbidden by
|
|
200
|
+
// PRD §10.4; the discriminator carries the system proxy
|
|
201
|
+
// (repository + file), the access path, and the category.
|
|
202
|
+
// Function scope (`qid`) is deliberately NOT in the
|
|
203
|
+
// discriminator (§3.4).
|
|
204
|
+
dataElementId: dataElementId(canonicalName, [repository, fn.file, seedPath, decision.category ?? '']),
|
|
205
|
+
dataClasses: classifyDataElementName(canonicalName).classes,
|
|
206
|
+
};
|
|
207
|
+
seeds.push(seed);
|
|
208
|
+
fnSeeds.push(seed);
|
|
209
|
+
return seed;
|
|
210
|
+
};
|
|
172
211
|
for (const [nid, node] of Object.entries(fn.cfg?.nodes ?? {})) {
|
|
173
212
|
nodesVisited += 1;
|
|
174
213
|
if (seeds.length >= seedCap) { stopReason = 'seeding-limit'; break; }
|
|
@@ -176,10 +215,21 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
|
|
|
176
215
|
for (const root of exprRoots(node)) walkExpr(root, (e, p) => { if (p) parentOf.set(e, p); });
|
|
177
216
|
for (const root of exprRoots(node)) {
|
|
178
217
|
walkExpr(root, (e) => {
|
|
179
|
-
|
|
218
|
+
// The main taint catalog first (unchanged), then the lineage-only
|
|
219
|
+
// catalog for the JavaScript collection points the main catalog
|
|
220
|
+
// deliberately does not treat as taint sources.
|
|
221
|
+
const entry = matchSource(e, fn.file) ?? matchLineageSource(e, fn.file);
|
|
180
222
|
if (!entry) return;
|
|
181
223
|
const decision = reclassifySource(entry);
|
|
182
|
-
|
|
224
|
+
let seedPath = seedPathFor(e, parentOf);
|
|
225
|
+
let seedKind = 'entry';
|
|
226
|
+
if (!seedPath && node.kind === 'assign' && node.source === e && typeof node.target === 'string') {
|
|
227
|
+
// A call-shaped source that IS the whole right-hand side of an
|
|
228
|
+
// assignment: the value lands in the target, so seed the target
|
|
229
|
+
// once the node has run (see SEED_KINDS).
|
|
230
|
+
seedPath = node.target;
|
|
231
|
+
seedKind = 'after-node';
|
|
232
|
+
}
|
|
183
233
|
if (!seedPath) {
|
|
184
234
|
unseedable.push({
|
|
185
235
|
file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null,
|
|
@@ -187,23 +237,24 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
|
|
|
187
237
|
});
|
|
188
238
|
return;
|
|
189
239
|
}
|
|
190
|
-
|
|
191
|
-
seeds.push({
|
|
192
|
-
file: fn.file, qid: fn.qid, nodeId: nid, line: node.line ?? null,
|
|
193
|
-
entryId: entry.id, seedPath, canonicalName,
|
|
194
|
-
category: decision.category, coverageStatus: decision.coverageStatus,
|
|
195
|
-
externality: decision.externality, reason: decision.reason,
|
|
196
|
-
// §3.4's minting rule. `canonicalName` alone is forbidden by
|
|
197
|
-
// PRD §10.4; the discriminator carries the system proxy
|
|
198
|
-
// (repository + file), the access path, and the category.
|
|
199
|
-
// Function scope (`qid`) is deliberately NOT in the
|
|
200
|
-
// discriminator (§3.4).
|
|
201
|
-
dataElementId: dataElementId(canonicalName, [repository, fn.file, seedPath, decision.category ?? '']),
|
|
202
|
-
dataClasses: classifyDataElementName(canonicalName).classes,
|
|
203
|
-
});
|
|
240
|
+
mint({ nid, node, entry, decision, seedPath, seedKind });
|
|
204
241
|
});
|
|
205
242
|
}
|
|
206
243
|
}
|
|
244
|
+
// GA-03.1 (0.195.24): a container seed read through an ALIAS. §3.2's
|
|
245
|
+
// rule extends a match outward only through member accesses enclosing
|
|
246
|
+
// it in the SAME statement, so `const body = req.body;` seeds the
|
|
247
|
+
// container `req.body` (data element `body`, no classes) and the later
|
|
248
|
+
// `body.card_number` read is a field nothing seeded. Follow the alias
|
|
249
|
+
// within the function: an assignment of a pure access path that starts
|
|
250
|
+
// at a container seed (or at an earlier alias of one) names an alias,
|
|
251
|
+
// and every outermost pure member read rooted at an alias mints the
|
|
252
|
+
// field seed on the ORIGINAL container path. The alias assignment's own
|
|
253
|
+
// byPath forwarding (`engine.js`, the structure-preserving `ident` and
|
|
254
|
+
// `member` cases) then carries the field identity through the hop.
|
|
255
|
+
// Bounded: the alias table is rebuilt at most four rounds (one per
|
|
256
|
+
// hop of chained aliases) and a wildcard segment is never seeded.
|
|
257
|
+
if (fnSeeds.length) extendThroughAliases(fn, fnSeeds, mint);
|
|
207
258
|
}
|
|
208
259
|
const notScanned = functionsTotal - functionsScanned;
|
|
209
260
|
const completion = {
|
|
@@ -219,6 +270,61 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
|
|
|
219
270
|
return { seeds, unseedable, completion };
|
|
220
271
|
}
|
|
221
272
|
|
|
273
|
+
const MAX_ALIAS_ROUNDS = 4;
|
|
274
|
+
|
|
275
|
+
/** `(fn, fnSeeds, mint)`: see the comment at the call site in `planSeeds`. */
|
|
276
|
+
function extendThroughAliases(fn, fnSeeds, mint) {
|
|
277
|
+
const nodes = Object.entries(fn.cfg?.nodes ?? {});
|
|
278
|
+
const containers = fnSeeds.filter((s) => s.seedKind === 'entry');
|
|
279
|
+
if (!containers.length) return;
|
|
280
|
+
const containerFor = (p) => containers.find((s) => p === s.seedPath || p.startsWith(`${s.seedPath}.`)) ?? null;
|
|
281
|
+
// ident -> { basePath, seed }: the container path the alias stands for.
|
|
282
|
+
const aliases = new Map();
|
|
283
|
+
const resolve = (p) => {
|
|
284
|
+
if (!p || p.includes('*')) return null;
|
|
285
|
+
const root = p.slice(0, p.indexOf('.') === -1 ? p.length : p.indexOf('.'));
|
|
286
|
+
const a = aliases.get(root);
|
|
287
|
+
if (a) return { basePath: a.basePath + p.slice(root.length), seed: a.seed };
|
|
288
|
+
const c = containerFor(p);
|
|
289
|
+
return c ? { basePath: p, seed: c } : null;
|
|
290
|
+
};
|
|
291
|
+
for (let round = 0; round < MAX_ALIAS_ROUNDS; round++) {
|
|
292
|
+
let changed = false;
|
|
293
|
+
for (const [, node] of nodes) {
|
|
294
|
+
if (node.kind !== 'assign' || typeof node.target !== 'string' || node.target.includes('.') || node.target.includes('*')) continue;
|
|
295
|
+
if (aliases.has(node.target)) continue;
|
|
296
|
+
const r = resolve(accessPathOf(node.source));
|
|
297
|
+
if (!r) continue;
|
|
298
|
+
aliases.set(node.target, r);
|
|
299
|
+
changed = true;
|
|
300
|
+
}
|
|
301
|
+
if (!changed) break;
|
|
302
|
+
}
|
|
303
|
+
if (!aliases.size) return;
|
|
304
|
+
const minted = new Set(fnSeeds.map((s) => s.seedPath));
|
|
305
|
+
for (const [, node] of nodes) {
|
|
306
|
+
const parentOf = new Map();
|
|
307
|
+
for (const root of exprRoots(node)) walkExpr(root, (e, p) => { if (p) parentOf.set(e, p); });
|
|
308
|
+
for (const root of exprRoots(node)) {
|
|
309
|
+
walkExpr(root, (e) => {
|
|
310
|
+
if (e.kind !== 'member') return;
|
|
311
|
+
const parent = parentOf.get(e);
|
|
312
|
+
if (parent && parent.kind === 'member' && parent.object === e) return; // not outermost
|
|
313
|
+
const p = accessPathOf(e);
|
|
314
|
+
if (!p || p.includes('*')) return;
|
|
315
|
+
const root = p.slice(0, p.indexOf('.'));
|
|
316
|
+
const a = aliases.get(root);
|
|
317
|
+
if (!a) return;
|
|
318
|
+
const fieldPath = a.basePath + p.slice(root.length);
|
|
319
|
+
if (fieldPath === a.seed.seedPath || minted.has(fieldPath)) return;
|
|
320
|
+
minted.add(fieldPath);
|
|
321
|
+
const site = fn.cfg.nodes[a.seed.nodeId];
|
|
322
|
+
mint({ nid: a.seed.nodeId, node: site ?? node, entry: { id: a.seed.entryId }, decision: a.seed, seedPath: fieldPath, seedKind: 'entry' });
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
222
328
|
// ── §3.5: seedEntryStateFactory — the driver.js opts.seedEntryState hook ──
|
|
223
329
|
|
|
224
330
|
/**
|
|
@@ -233,6 +339,7 @@ export function planSeeds(callGraph, { repository, maxSeeds, maxNodes, signal, d
|
|
|
233
339
|
export function seedEntryStateFactory(seeds) {
|
|
234
340
|
const byQid = new Map();
|
|
235
341
|
for (const s of seeds) {
|
|
342
|
+
if (s.seedKind === 'after-node') continue;
|
|
236
343
|
if (!byQid.has(s.qid)) byQid.set(s.qid, []);
|
|
237
344
|
byQid.get(s.qid).push(s);
|
|
238
345
|
}
|
|
@@ -244,3 +351,22 @@ export function seedEntryStateFactory(seeds) {
|
|
|
244
351
|
return st;
|
|
245
352
|
};
|
|
246
353
|
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Builds the `ctx.seedAfterNode(qid, nodeId) -> [{path, id}] | undefined`
|
|
357
|
+
* function `engine.js`'s worklist consults after every CFG node (GA-03.1,
|
|
358
|
+
* 0.195.24): the `after-node` seeds of that function, grouped by node.
|
|
359
|
+
* Keyed by `qid` rather than bound to one function so the same function
|
|
360
|
+
* works for a callee analysed through `createCallSummaryResolver`.
|
|
361
|
+
*/
|
|
362
|
+
export function seedAfterNodeFactory(seeds) {
|
|
363
|
+
const byKey = new Map();
|
|
364
|
+
for (const s of seeds) {
|
|
365
|
+
if (s.seedKind !== 'after-node') continue;
|
|
366
|
+
const k = `${s.qid}\u0000${s.nodeId}`;
|
|
367
|
+
if (!byKey.has(k)) byKey.set(k, []);
|
|
368
|
+
byKey.get(k).push({ path: s.seedPath, id: s.dataElementId });
|
|
369
|
+
}
|
|
370
|
+
if (!byKey.size) return undefined;
|
|
371
|
+
return (qid, nodeId) => byKey.get(`${qid}\u0000${nodeId}`);
|
|
372
|
+
}
|
package/src/lineage/summaries.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { hashState, emptyState, addIdentity } from './field-identity.js';
|
|
1
|
+
import { hashState, emptyState, addIdentity, joinStates } from './field-identity.js';
|
|
2
2
|
import {
|
|
3
3
|
resolveExprIdentities, residualFlat, analyzeFunctionFieldIdentity,
|
|
4
4
|
argumentContext, callInvocationId, calleeRecorder,
|
|
@@ -425,7 +425,8 @@ export function applyAtCallSite(summary, paramNames, callArgs) {
|
|
|
425
425
|
// integration will pass a resolver backed by `scanner/src/ir/callgraph.js`
|
|
426
426
|
// instead, without this function (or `resolveExprIdentities`) needing to
|
|
427
427
|
// change at all.
|
|
428
|
-
export function createCallSummaryResolver(cache, lookupCallee) {
|
|
428
|
+
export function createCallSummaryResolver(cache, lookupCallee, resolverOpts = {}) {
|
|
429
|
+
const seedEntryState = typeof resolverOpts.seedEntryState === 'function' ? resolverOpts.seedEntryState : null;
|
|
429
430
|
return function resolveCallSummary(calleeExpr, callArgs, callerState, ctx) {
|
|
430
431
|
const resolved = lookupCallee(calleeExpr);
|
|
431
432
|
if (!resolved) return null;
|
|
@@ -435,7 +436,18 @@ export function createCallSummaryResolver(cache, lookupCallee) {
|
|
|
435
436
|
// own header comment) — this call site forwards the caller's `ctx`
|
|
436
437
|
// unmodified; the hazard cannot reappear here because the strip
|
|
437
438
|
// happens one level down, not at each call site.
|
|
438
|
-
const
|
|
439
|
+
const boundState = entryStateFromCall(fn.params, callArgs, callerState, ctx);
|
|
440
|
+
// GA-03.1 (0.195.24): the callee's OWN source seeds are facts about
|
|
441
|
+
// expressions in its body (`req.body.card_number` is a source wherever
|
|
442
|
+
// `extractCard` is called from), so they hold under every entry context,
|
|
443
|
+
// not only the driver's top-level one. Join them onto the call-derived
|
|
444
|
+
// state. The cache key is the JOINED state, so a clean call site and the
|
|
445
|
+
// driver's own analysis of the callee share one key and one summary;
|
|
446
|
+
// DESIGN_GRAPH_BUILDER.md §3.6's rule stands: no seeded summary is ever
|
|
447
|
+
// stored under the empty-entry key. The bind hops below enumerate only
|
|
448
|
+
// the call-derived half (`boundState`): a seed is not an argument.
|
|
449
|
+
const ownSeeds = seedEntryState ? seedEntryState(fn) : null;
|
|
450
|
+
const entryState = ownSeeds ? joinStates(boundState, ownSeeds) : boundState;
|
|
439
451
|
// §13.2's first half: the callee's own entry context, computed once so
|
|
440
452
|
// both the bind hop below and the return-direction wrapper at the
|
|
441
453
|
// bottom of this function can reference the exact same value.
|
|
@@ -454,7 +466,7 @@ export function createCallSummaryResolver(cache, lookupCallee) {
|
|
|
454
466
|
// C4 would collide this binding's target with any caller-local
|
|
455
467
|
// variable of the same name (Decision 5's bug class).
|
|
456
468
|
if (ctx?.recordHop) {
|
|
457
|
-
for (const [path, ids] of
|
|
469
|
+
for (const [path, ids] of boundState) {
|
|
458
470
|
for (const id of ids) {
|
|
459
471
|
ctx.recordHop({
|
|
460
472
|
kind: 'write-out', subKind: 'call-arg-bind',
|
|
@@ -508,6 +520,10 @@ export function createCallSummaryResolver(cache, lookupCallee) {
|
|
|
508
520
|
...(ctx.correlationVersion === 2 ? { correlationVersion: 2 } : null),
|
|
509
521
|
}
|
|
510
522
|
: { resolveCallSummary };
|
|
523
|
+
// GA-03.1 (0.195.24): the callee's after-node seeds are body facts
|
|
524
|
+
// too (source-seeding.js, SEED_KINDS); the hook is keyed by qid, so
|
|
525
|
+
// forwarding the caller's is exact.
|
|
526
|
+
if (ctx?.seedAfterNode) calleeCtx.seedAfterNode = ctx.seedAfterNode;
|
|
511
527
|
const result = analyzeFunctionFieldIdentity(fn, es, calleeCtx);
|
|
512
528
|
return summaryFromAnalysisResult(result);
|
|
513
529
|
});
|
|
@@ -524,7 +540,7 @@ export function createCallSummaryResolver(cache, lookupCallee) {
|
|
|
524
540
|
// under §2.2's rule (or, when there is no path-shaped argument, as
|
|
525
541
|
// edge-forming in its own right) — never dropped either way.
|
|
526
542
|
if (summary?.degradedReason && ctx?.recordHop) {
|
|
527
|
-
for (const [, ids] of
|
|
543
|
+
for (const [, ids] of boundState) {
|
|
528
544
|
for (const id of ids) {
|
|
529
545
|
ctx.recordHop({
|
|
530
546
|
kind: 'production', subKind: 'call-resolved',
|
|
@@ -77,6 +77,7 @@ import { localEndpointConfig } from './local-endpoint.js';
|
|
|
77
77
|
import { resolveProvider, buildProviderRequest, providerMatrix } from './providers.js';
|
|
78
78
|
import { evaluateEgress } from '../egress/policy.js';
|
|
79
79
|
import { recordEgressCall, payloadMetrics } from '../egress/audit.js';
|
|
80
|
+
import { modelFetch } from '../egress/transport.js';
|
|
80
81
|
import { MODEL_STATUS, summarizeModelStatus } from './model-status.js';
|
|
81
82
|
import { callOllamaChat } from './ollama-provider.js';
|
|
82
83
|
|
|
@@ -408,12 +409,13 @@ async function callEndpoint(endpoint, apiKey, model, prompt, preset = null, shap
|
|
|
408
409
|
// A model that accepts the connection and never answers must not hold a scan open forever: every call is bounded.
|
|
409
410
|
// The result is an UNAVAILABLE finding (kept, unvalidated), never a silent pass.
|
|
410
411
|
const ms = Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) > 0 ? Number(process.env.AGENTIC_SECURITY_LLM_TIMEOUT_MS) : 60_000;
|
|
411
|
-
const r = await
|
|
412
|
+
const r = await modelFetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) }, { timeoutMs: ms });
|
|
412
413
|
if (!r.ok) return { ok: false, error: `HTTP ${r.status}` };
|
|
413
414
|
const j = await r.json().catch(() => null);
|
|
414
415
|
return { ok: true, text: String(extractText(j) || ''), usage: extractUsage ? extractUsage(j) : null };
|
|
415
416
|
} catch (e) {
|
|
416
|
-
|
|
417
|
+
// a redirect, an oversized body or a spent budget is an unavailable validator, named by its transport code, never a pass
|
|
418
|
+
return { ok: false, error: e && e.code === 'timeout' ? 'timeout' : (e && e.code ? `${e.code}: ${e.message}` : e.message) };
|
|
417
419
|
}
|
|
418
420
|
}
|
|
419
421
|
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
// The connect timeout is short for the same reason — a dead port must not hang
|
|
23
23
|
// a scan.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
// `localhost` is the one name accepted; `[::1]` is how the URL parser spells the IPv6 loopback (a bare `::1` never reaches this set).
|
|
26
|
+
// `0.0.0.0` is NOT here (GA-06.3): it is the unspecified address, and a connect to it reaches a local interface only by an operating
|
|
27
|
+
// system convention, not by the address's meaning. The promise is "literal loopback", so it is refused like `[::]` and `http://0/`.
|
|
28
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
26
29
|
|
|
27
30
|
export const DEFAULT_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1/chat/completions';
|
|
28
31
|
export const DEFAULT_LOCAL_TIMEOUT_MS = 20000;
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
// never to this module's error path. See ollama-offline-egress.test.js.
|
|
33
33
|
|
|
34
34
|
import { isLoopbackUrl } from './local-endpoint.js';
|
|
35
|
+
import { modelFetch } from '../egress/transport.js';
|
|
35
36
|
import { recordOOMEvent } from './oom-feedback.js';
|
|
36
37
|
|
|
37
38
|
export const DEFAULT_OLLAMA_HOST = 'http://127.0.0.1:11434';
|
|
@@ -199,16 +200,17 @@ export function parseOllamaChatResponse(json, model) {
|
|
|
199
200
|
* "slow to generate" without a lower-level HTTP client.
|
|
200
201
|
*/
|
|
201
202
|
async function _fetchOllama(url, init, { connectTimeoutMs, requestTimeoutMs }) {
|
|
202
|
-
|
|
203
|
-
const totalTimer = setTimeout(() => controller.abort('total-timeout'), requestTimeoutMs);
|
|
203
|
+
void connectTimeoutMs; // see the note above: the client exposes no connect-only phase to bound separately
|
|
204
204
|
try {
|
|
205
|
-
|
|
205
|
+
// the shared transport: no redirect is followed, the reply is capped while it is read, the deadline is the total timeout
|
|
206
|
+
const res = await modelFetch(url, init, { timeoutMs: requestTimeoutMs });
|
|
206
207
|
return { ok: true, res };
|
|
207
208
|
} catch (e) {
|
|
208
|
-
if (
|
|
209
|
+
if (e?.code === 'timeout') return _err('ollama-timeout', `Ollama request timed out after ${requestTimeoutMs}ms.`);
|
|
210
|
+
if (e?.code === 'redirect') return _err('ollama-unreachable', `Ollama answered with a redirect, which is never followed: ${e.message}`);
|
|
211
|
+
if (e?.code === 'response-too-large') return _err('ollama-malformed-response', e.message);
|
|
212
|
+
if (e?.code === 'budget-exhausted') return _err('ollama-timeout', e.message);
|
|
209
213
|
return _err('ollama-unreachable', e?.message || String(e));
|
|
210
|
-
} finally {
|
|
211
|
-
clearTimeout(totalTimer);
|
|
212
214
|
}
|
|
213
215
|
}
|
|
214
216
|
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
|
|
27
27
|
import * as crypto from 'node:crypto';
|
|
28
28
|
import { evaluateEgress } from '../egress/policy.js';
|
|
29
|
+
import { modelFetch } from '../egress/transport.js';
|
|
30
|
+
import { resolveProvider } from '../llm-validator/providers.js';
|
|
29
31
|
|
|
30
32
|
const MAX_CALLS_DEFAULT = 50;
|
|
31
33
|
const MAX_WALL_MS_DEFAULT = 15 * 60 * 1000;
|
|
@@ -96,11 +98,21 @@ export function isExceeded(transcript, budget) {
|
|
|
96
98
|
// and parses the response as a single tool-call request. The endpoint is
|
|
97
99
|
// expected to follow an OpenAI-style chat-completions shape; we extract a
|
|
98
100
|
// JSON object of `{ tool, args }` from the model's reply.
|
|
101
|
+
// GA-06.3: the provider is resolved like the validator's, so the `local` preset's loopback refusal applies here, and a vendor key is
|
|
102
|
+
// sent only with that vendor's own preset (a bare OPENAI_API_KEY is never attached to an arbitrary endpoint). Ollama's protocol is not
|
|
103
|
+
// implemented here, so that preset counts as not configured for this agent.
|
|
104
|
+
export function _agentProvider(env = process.env) {
|
|
105
|
+
const r = resolveProvider({ role: 'verify', env });
|
|
106
|
+
if (!r.ok || r.config.provider === 'ollama') return null;
|
|
107
|
+
return r.config;
|
|
108
|
+
}
|
|
109
|
+
|
|
99
110
|
export async function defaultLlmInvoke(transcript) {
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
102
|
-
const
|
|
103
|
-
const
|
|
111
|
+
const provider = _agentProvider();
|
|
112
|
+
if (!provider) return null;
|
|
113
|
+
const { endpoint } = provider;
|
|
114
|
+
const model = provider.model || 'gpt-4o-mini';
|
|
115
|
+
const apiKey = provider.apiKey || '';
|
|
104
116
|
const headers = { 'Content-Type': 'application/json' };
|
|
105
117
|
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
|
|
106
118
|
const tools = [...TOOL_ACL].map(t => ({ tool: t, description: TOOL_HELP[t] || '' }));
|
|
@@ -115,7 +127,7 @@ export async function defaultLlmInvoke(transcript) {
|
|
|
115
127
|
};
|
|
116
128
|
let res;
|
|
117
129
|
try {
|
|
118
|
-
res = await
|
|
130
|
+
res = await modelFetch(endpoint, { method: 'POST', headers, body: JSON.stringify(body) });
|
|
119
131
|
} catch (e) {
|
|
120
132
|
return null;
|
|
121
133
|
}
|
|
@@ -136,6 +148,9 @@ const TOOL_HELP = {
|
|
|
136
148
|
'record_outcome': 'Terminate the run with a verdict (outcome: one of OUTCOMES)',
|
|
137
149
|
};
|
|
138
150
|
|
|
151
|
+
/** Bounds for a tool probe against the target: a reply is kept to 4000 characters anyway, so 64 KiB and 30 s are generous. */
|
|
152
|
+
const PROBE_BOUNDS = Object.freeze({ timeoutMs: 30_000, maxResponseBytes: 64 * 1024 });
|
|
153
|
+
|
|
139
154
|
// Default executeTool — wraps http.get / http.post against `transcript.target`.
|
|
140
155
|
// Falls back to refusal when the tool is db.read_sandbox_copy (we do not ship
|
|
141
156
|
// a sandboxed DB by default; the caller must supply one).
|
|
@@ -144,7 +159,8 @@ export async function defaultExecuteTool(call, transcript) {
|
|
|
144
159
|
if (call.tool === 'http.get') {
|
|
145
160
|
const url = (transcript?.target || '').replace(/\/$/, '') + (call.args?.path || '/');
|
|
146
161
|
try {
|
|
147
|
-
|
|
162
|
+
// the probe goes through the shared transport too: no redirect off the target, a capped reply, a deadline
|
|
163
|
+
const r = await modelFetch(url, { method: 'GET', headers: call.args?.headers || {} }, PROBE_BOUNDS);
|
|
148
164
|
const body = await r.text();
|
|
149
165
|
return { status: r.status, headers: Object.fromEntries(r.headers), body: body.slice(0, 4000) };
|
|
150
166
|
} catch (e) { return { error: String(e?.message || e) }; }
|
|
@@ -152,11 +168,11 @@ export async function defaultExecuteTool(call, transcript) {
|
|
|
152
168
|
if (call.tool === 'http.post') {
|
|
153
169
|
const url = (transcript?.target || '').replace(/\/$/, '') + (call.args?.path || '/');
|
|
154
170
|
try {
|
|
155
|
-
const r = await
|
|
171
|
+
const r = await modelFetch(url, {
|
|
156
172
|
method: 'POST',
|
|
157
173
|
headers: { 'Content-Type': 'application/json', ...(call.args?.headers || {}) },
|
|
158
174
|
body: typeof call.args?.body === 'string' ? call.args.body : JSON.stringify(call.args?.body || {}),
|
|
159
|
-
});
|
|
175
|
+
}, PROBE_BOUNDS);
|
|
160
176
|
const body = await r.text();
|
|
161
177
|
return { status: r.status, headers: Object.fromEntries(r.headers), body: body.slice(0, 4000) };
|
|
162
178
|
} catch (e) { return { error: String(e?.message || e) }; }
|
|
@@ -176,8 +192,9 @@ export async function runAgent(finding, opts = {}) {
|
|
|
176
192
|
// called later in the loop below) never runs at all.
|
|
177
193
|
let egressDecision = null;
|
|
178
194
|
let llmInvoke = opts.llmInvoke || null;
|
|
179
|
-
|
|
180
|
-
|
|
195
|
+
const provider = llmInvoke ? null : _agentProvider();
|
|
196
|
+
if (!llmInvoke && provider) {
|
|
197
|
+
egressDecision = evaluateEgress({ scanRoot: opts.scanRoot, purpose: 'adversary-agent', endpoint: provider.endpoint });
|
|
181
198
|
if (egressDecision.allowed) llmInvoke = defaultLlmInvoke;
|
|
182
199
|
}
|
|
183
200
|
const executeTool = opts.executeTool || (transcript.target ? (call) => defaultExecuteTool(call, transcript) : null);
|
|
@@ -39,6 +39,7 @@ import * as path from 'node:path';
|
|
|
39
39
|
import * as yaml from '../util/yaml.js';
|
|
40
40
|
import { globFiles } from '../util/glob.js';
|
|
41
41
|
import { loadTrustedKeys, verifyRulePack } from './rule-pack-signing.js';
|
|
42
|
+
import { isUnsafeRegex } from '../sast/redos-nfa.js';
|
|
42
43
|
|
|
43
44
|
import { statePath, safeWriteState } from './state-dir.js';
|
|
44
45
|
const LANG_EXTS = {
|
|
@@ -156,8 +157,19 @@ export function loadCustomRules(scanRoot) {
|
|
|
156
157
|
// Catches (a+)+, (a*)*, (.+)+, (\w+)+, (a|b)+, (a+|b)* etc.
|
|
157
158
|
// Not exhaustive but eliminates the vast majority of catastrophic patterns.
|
|
158
159
|
const _REDOS_NESTED_RE = /\([^)]*[+*][^)]*\)[+*?{]/;
|
|
160
|
+
// The textual heuristic above misses an ambiguous alternation under a quantifier (`(a|a)*b`) and the polynomial shapes; since
|
|
161
|
+
// 0.195.23 (GA-07) the NFA-based detector the SAST layer already ships (`sast/redos-nfa.js`) is consulted as well, so a custom or
|
|
162
|
+
// compiled rule is held to the same bar as the code the scanner flags for CWE-1333.
|
|
159
163
|
function _isSafePattern(p) {
|
|
160
|
-
|
|
164
|
+
if (_REDOS_NESTED_RE.test(p)) return false;
|
|
165
|
+
return !isUnsafeRegex(p).unsafe;
|
|
166
|
+
}
|
|
167
|
+
/** Why a pattern is refused, for the loader's message and the rule compiler's validation. Null when it is safe. */
|
|
168
|
+
export function unsafePatternReason(p) {
|
|
169
|
+
if (typeof p !== 'string') return 'not a string';
|
|
170
|
+
if (_REDOS_NESTED_RE.test(p)) return 'nested quantifiers';
|
|
171
|
+
const r = isUnsafeRegex(p);
|
|
172
|
+
return r.unsafe ? (r.reason || 'superlinear matching') : null;
|
|
161
173
|
}
|
|
162
174
|
|
|
163
175
|
/** The loader's own rule normalizer, exported so anything that scores or validates a rule uses the SAME one the scanner loads
|
package/src/posture/fleet/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { rollupFleet, renderFleetSummary } from '../fleet.js';
|
|
|
15
15
|
import { statePath, ensureStateDir, safeWriteState } from '../state-dir.js';
|
|
16
16
|
import { createSqliteJobStore } from './job-store.js';
|
|
17
17
|
import { planRun, runJobs, loadArtifacts, reclaimDeadWorkers, childScan } from './job-runner.js';
|
|
18
|
-
import { computeOrgHealth, renderOrgHealth } from './org-health.js';
|
|
18
|
+
import { computeOrgHealth, renderOrgHealth, applyArtifactProblems } from './org-health.js';
|
|
19
19
|
import { discoverEdges, splitDeclared } from './discover-edges.js';
|
|
20
20
|
import { buildEnterpriseGraph, finalizeGraph, validateEnterpriseGraph, attackPaths, vulnerableSet, componentBlastRadius, renderBlastRadius, INTERNET } from './enterprise-graph.js';
|
|
21
21
|
|
|
@@ -90,9 +90,7 @@ function runOfJob(store, id) {
|
|
|
90
90
|
async function finishRun({ args, root, store, runId, manifest, manifestFile, interrupted }) {
|
|
91
91
|
const { artifacts, problems } = loadArtifacts({ store, runId, root });
|
|
92
92
|
const jobs = store.listJobs(runId);
|
|
93
|
-
const health = computeOrgHealth({ manifest, jobs });
|
|
94
|
-
for (const p of problems) { const r = health.repositories.find((x) => x.id === p.repoId); if (r) { r.state = 'failed'; r.error = p.problem; } }
|
|
95
|
-
if (problems.length) { health.counts.complete -= problems.filter((p) => health.repositories.find((x) => x.id === p.repoId)).length; health.counts.failed += problems.length; if (health.assurance !== 'red' && manifest.repositories.some((r) => r.required && problems.some((p) => p.repoId === r.id))) health.assurance = 'red'; health.reasons.push(`${problems.length} result(s) could not be verified: ${problems.map((p) => `${p.repoId} (${p.problem})`).join(', ')}`); }
|
|
93
|
+
const health = applyArtifactProblems(computeOrgHealth({ manifest, jobs }), manifest, problems);
|
|
96
94
|
|
|
97
95
|
let previous = null;
|
|
98
96
|
const rollupFile = statePath(root, ROLLUP_FILE);
|