@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.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/CHANGELOG.md +38 -0
- package/bin/agentic-security.js +11 -2
- package/dist/1919.index.js +1 -1
- package/dist/2432.index.js +10 -1
- package/dist/4113.index.js +60 -9
- package/dist/5613.index.js +500 -0
- package/dist/6239.index.js +469 -0
- package/dist/7033.index.js +4 -1
- package/dist/7838.index.js +39 -3
- package/dist/8714.index.js +1 -1
- package/dist/8798.index.js +2 -2
- package/dist/9207.index.js +5 -0
- package/dist/9560.index.js +205 -62
- package/dist/{1905.index.js → 9999.index.js} +265 -8
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +14 -9
- package/src/dataflow/engine.js +20 -4
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +28 -0
- package/src/discovery/memory.js +7 -5
- package/src/engine.js +29 -8
- package/src/fix/apply-fix-service.js +33 -3
- package/src/language/CLAUDE.md +6 -1
- package/src/language/aibom.js +4 -1
- package/src/language/fix-lifecycle.js +9 -0
- package/src/language/haskell-ir.js +47 -7
- package/src/language/haskell-sca.js +87 -7
- package/src/language/haskell-supply.js +175 -6
- package/src/language/haskell-web.js +200 -18
- package/src/language/nix-fix.js +1 -1
- package/src/language/resolved-pass.js +4 -1
- package/src/language/witness.js +1 -1
- package/src/mcp/CLAUDE.md +43 -4
- package/src/mcp/tools.js +185 -43
- package/src/posture/CLAUDE.md +174 -0
- package/src/posture/assurance/baseline-inventory.js +218 -0
- package/src/posture/assurance/baseline.js +322 -0
- package/src/posture/assurance/bounded-io.js +145 -0
- package/src/posture/assurance/config.js +299 -0
- package/src/posture/assurance/contracts.js +315 -0
- package/src/posture/assurance/identity.js +62 -0
- package/src/posture/assurance/migrations.js +259 -0
- package/src/posture/assurance/schema-kit.js +111 -0
- package/src/posture/assurance/verification-record.js +258 -0
- package/src/posture/attestation.js +5 -0
- package/src/posture/autopilot.js +28 -1
- package/src/posture/blast-radius.js +15 -1
- package/src/posture/evidence-bundle.js +93 -7
- package/src/posture/execution-proof.js +11 -1
- package/src/posture/fix-verify-loop.js +20 -6
- package/src/posture/fix-verify.js +37 -2
- package/src/posture/oracles/adapters.js +360 -0
- package/src/posture/oracles/conformance.js +275 -0
- package/src/posture/oracles/oracle.js +433 -0
- package/src/posture/oracles/registry.js +76 -0
- package/src/posture/oracles/scenario-classes.js +223 -0
- package/src/posture/remediation-ledger.js +10 -1
- package/src/posture/replay/replay.js +249 -0
- package/src/posture/verification/advisory-state.js +71 -0
- package/src/posture/verification/emit.js +257 -0
- package/src/posture/verification/hypothesis-promotion.js +211 -0
- package/src/posture/verification/patch-negative.js +214 -0
- package/src/posture/verification/patch-promotion.js +105 -0
- package/src/posture/verification/projection.js +183 -0
- package/src/posture/verification/repair-records.js +124 -0
- package/src/posture/verifier.js +16 -0
- package/src/report/index.js +49 -0
- package/src/sandbox/CLAUDE.md +41 -0
- package/src/sandbox/backend-namespace.js +7 -0
- package/src/sandbox/backend-userspace.js +68 -29
- package/src/sandbox/control-probes.js +215 -0
- package/src/sandbox/supervise.js +201 -0
- package/src/sandbox/trust-boundary.js +113 -0
- package/src/sandbox/trust-domains.js +171 -0
- package/src/sast/pipeline.js +51 -4
|
@@ -175,7 +175,23 @@ function exportedLookup(proj, target, name, depth, seen) {
|
|
|
175
175
|
return null;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
// A top-level resolution (depth 0, nothing seen yet) is a pure function of the module table, which is complete before any function
|
|
179
|
+
// is lowered, so its answer is kept per module: the same name is resolved again at every use site, and each resolution walks the
|
|
180
|
+
// module's imports and the re-export chains of every module it imports (hub modules re-export hundreds of names). Resolutions
|
|
181
|
+
// made inside a re-export chain (depth > 0) depend on the path taken, so they are never kept. Callers treat the result as
|
|
182
|
+
// read-only.
|
|
183
|
+
const RESOLVE_MEMO = new WeakMap(); // module info -> (qualifier, name) -> resolution; a module's memo dies with the module
|
|
184
|
+
function resolveGlobal(proj, mod, name, qual, depth = 0, seen = null) {
|
|
185
|
+
if (depth !== 0 || seen) return resolveGlobalUncached(proj, mod, name, qual, depth, seen || new Set());
|
|
186
|
+
let memo = RESOLVE_MEMO.get(mod);
|
|
187
|
+
if (!memo) { memo = new Map(); RESOLVE_MEMO.set(mod, memo); }
|
|
188
|
+
const key = qual ? `${qual}\0${name}` : name;
|
|
189
|
+
let r = memo.get(key);
|
|
190
|
+
if (r === undefined) { r = resolveGlobalUncached(proj, mod, name, qual, 0, new Set()); memo.set(key, r); }
|
|
191
|
+
return r;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function resolveGlobalUncached(proj, mod, name, qual, depth, seen) {
|
|
179
195
|
if ((!qual || qual === mod.name) && mod.defs.has(name)) return defResult(mod, name);
|
|
180
196
|
if (depth > HS_IR_LIMITS.maxResolveDepth) return { kind: 'unknown', reason: 'resolution-depth-cap' };
|
|
181
197
|
const hits = [];
|
|
@@ -649,8 +665,7 @@ class FnLowerer {
|
|
|
649
665
|
// call to it, not a mere reference: it runs when sequenced.
|
|
650
666
|
executeRef(v, ln) {
|
|
651
667
|
if (!v || v.kind !== 'ident' || !v.hs || !v.hs.functionRef || v.hs.closure) return v;
|
|
652
|
-
|
|
653
|
-
for (const m of this.proj.modules.values()) for (const d of m.defs.values()) if (d.qid === v.hs.functionRef) def = d;
|
|
668
|
+
const def = this.proj.defByQid(v.hs.functionRef);
|
|
654
669
|
if (!def || def.kind !== 'fun' || def.arity !== 0) return v;
|
|
655
670
|
return { kind: 'call', callee: v.name, args: [], line: ln, hs: { status: 'resolved', target: v.hs.functionRef, effect: 'io', action: true } };
|
|
656
671
|
}
|
|
@@ -1103,9 +1118,35 @@ class Project {
|
|
|
1103
1118
|
this.byFile.get(fn.file).push(fn);
|
|
1104
1119
|
}
|
|
1105
1120
|
|
|
1121
|
+
// The three lookups below used to walk every module on every call (a constructor, a record type, a def by qid), which is
|
|
1122
|
+
// quadratic in project size. They are built once, lazily, on first use: the module table is complete before any function
|
|
1123
|
+
// is lowered, and nothing adds a module, a constructor or a definition afterwards. First-wins / last-wins orders match the
|
|
1124
|
+
// original scans exactly.
|
|
1106
1125
|
findCon(name) {
|
|
1107
|
-
|
|
1108
|
-
|
|
1126
|
+
if (!this._conIndex) {
|
|
1127
|
+
this._conIndex = new Map();
|
|
1128
|
+
for (const m of this.modules.values()) for (const [n, c] of m.cons) if (!this._conIndex.has(n)) this._conIndex.set(n, c);
|
|
1129
|
+
}
|
|
1130
|
+
return this._conIndex.get(name) || null;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Record types declared in the project: type name -> field names (a later declaration of the same type name wins).
|
|
1134
|
+
recordTypes() {
|
|
1135
|
+
if (!this._recTypes) {
|
|
1136
|
+
const recTypes = new Map();
|
|
1137
|
+
for (const m of this.modules.values()) for (const d of (m.parse && m.parse.data) || []) if (d.constructors && d.constructors.some((c) => c.fields && c.fields.length)) recTypes.set(d.name, d.constructors.flatMap((c) => c.fields || []));
|
|
1138
|
+
this._recTypes = recTypes;
|
|
1139
|
+
}
|
|
1140
|
+
return this._recTypes;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// A definition by its qid (the last one in module order wins, as the linear scan it replaces did).
|
|
1144
|
+
defByQid(qid) {
|
|
1145
|
+
if (!this._defIndex) {
|
|
1146
|
+
this._defIndex = new Map();
|
|
1147
|
+
for (const m of this.modules.values()) for (const d of m.defs.values()) this._defIndex.set(d.qid, d);
|
|
1148
|
+
}
|
|
1149
|
+
return this._defIndex.get(qid) || null;
|
|
1109
1150
|
}
|
|
1110
1151
|
|
|
1111
1152
|
instancesOf(cls, method) { return [...(this.instanceIndex.get(`${cls}.${method}`) || [])]; }
|
|
@@ -1123,8 +1164,7 @@ function lowerTopFunction(proj, mod, fun, def) {
|
|
|
1123
1164
|
fn.hs.textParams = ann.map((a) => a.name);
|
|
1124
1165
|
// Parameters of an exported function whose type is a record DECLARED IN THE PROJECT: the lineage view treats each such
|
|
1125
1166
|
// parameter as a source of that record's fields (a customer record handed to an entry point), field by field.
|
|
1126
|
-
const recTypes =
|
|
1127
|
-
for (const m of proj.modules.values()) for (const d of (m.parse && m.parse.data) || []) if (d.constructors && d.constructors.some((c) => c.fields && c.fields.length)) recTypes.set(d.name, d.constructors.flatMap((c) => c.fields || []));
|
|
1167
|
+
const recTypes = proj.recordTypes();
|
|
1128
1168
|
fn.hs.recordParams = [];
|
|
1129
1169
|
if (exported && sig && Array.isArray(sig.argTypes) && Array.isArray(fn.params)) {
|
|
1130
1170
|
sig.argTypes.forEach((t, i) => { const m = /^\(?\s*([A-Z][A-Za-z0-9_']*)\s*\)?$/.exec(t); if (m && recTypes.has(m[1]) && fn.params[i]) fn.hs.recordParams.push({ name: fn.params[i], type: m[1], fields: [...new Set(recTypes.get(m[1]))] }); });
|
|
@@ -32,7 +32,24 @@ export const GHC_BOOT_PACKAGES = Object.freeze(new Set([
|
|
|
32
32
|
'process', 'stm', 'text', 'time', 'transformers', 'unix', 'xhtml', 'terminfo', 'Cabal', 'Cabal-syntax',
|
|
33
33
|
]));
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The `base` series each GHC release series ships (the series only: patch releases of one GHC can ship different `base` patch levels, so
|
|
37
|
+
* the answer is a range, never a single version). A release series absent from this table is not guessed.
|
|
38
|
+
*/
|
|
39
|
+
export const GHC_BASE_SERIES = Object.freeze({
|
|
40
|
+
'8.8': '4.13', '8.10': '4.14', '9.0': '4.15', '9.2': '4.16', '9.4': '4.17', '9.6': '4.18', '9.8': '4.19', '9.10': '4.20', '9.12': '4.21', '9.14': '4.22',
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** `>=4.18 && <4.19` for a GHC version such as "9.6.4", or null when the series is not in the table. */
|
|
44
|
+
export function baseRangeForGhc(ghcVersion) {
|
|
45
|
+
const m = /^(\d+)\.(\d+)(?:\.|$)/.exec(String(ghcVersion || ''));
|
|
46
|
+
const series = m && GHC_BASE_SERIES[`${m[1]}.${m[2]}`];
|
|
47
|
+
if (!series) return null;
|
|
48
|
+
const [a, b] = series.split('.').map(Number);
|
|
49
|
+
return `>=${a}.${b} && <${a}.${b + 1}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const hackagePurl =(name, version) => `pkg:hackage/${encodeURIComponent(name).replace(/%2D/g, '-')}${version ? `@${encodeURIComponent(version)}` : ''}`;
|
|
36
53
|
|
|
37
54
|
/** The exact JSON body an OSV `/v1/query` takes for one component. */
|
|
38
55
|
export function osvWireQuery(name, version) {
|
|
@@ -234,17 +251,46 @@ export function matchComponent(aff, comp) {
|
|
|
234
251
|
}
|
|
235
252
|
// `unbounded` = the manifest declared the dependency with NO version bound (`build-depends: aeson`), which in Cabal means "any version".
|
|
236
253
|
// That is a declared range like any other (it overlaps an advisory, so possibly-affected), not an absent one.
|
|
237
|
-
|
|
238
|
-
|
|
254
|
+
const projectConstraints = Array.isArray(comp.projectConstraints) ? comp.projectConstraints : [];
|
|
255
|
+
if (comp.declaredRange || comp.unbounded || projectConstraints.length) {
|
|
256
|
+
const eff = effectiveDeclared(comp);
|
|
257
|
+
const declared = eff.intervals;
|
|
239
258
|
if (declared === null) return { status: 'unknown', reason: 'the declared range could not be parsed' };
|
|
240
|
-
if (!declared.length) return { status: 'unknown', reason: 'the declared range admits no version' };
|
|
259
|
+
if (!declared.length) return { status: 'unknown', reason: projectConstraints.length ? `the declared range and the project constraints (${projectConstraints.map((c) => c.text).join(', ')}) admit no common version` : 'the declared range admits no version' };
|
|
241
260
|
const overlap = intervalsOverlap(declared, aff.intervals);
|
|
242
|
-
if (!overlap) return { status: 'not-affected', reason:
|
|
261
|
+
if (!overlap) return { status: 'not-affected', reason: `no version allowed by the declared range is affected${eff.note}` };
|
|
243
262
|
const subset = declared.every((d) => aff.intervals.some((a) => !isEmpty(intersectIntervals(d, a)) && containsInterval(a, d)));
|
|
244
|
-
return subset ? { status: 'affected', reason:
|
|
263
|
+
return subset ? { status: 'affected', reason: `every version allowed by the declared range is affected${eff.note}` } : { status: 'possibly-affected', reason: `${comp.declaredRange ? 'the declared range allows both affected and unaffected versions; no resolved version is known' : 'the dependency is declared with no version bound, so any version may be selected; no resolved version is known'}${eff.note}` };
|
|
245
264
|
}
|
|
246
265
|
return { status: 'unknown', reason: 'neither a resolved version nor a declared range is known' };
|
|
247
266
|
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The versions a component may be built with: its declared range, widened by `allow-newer` / `allow-older` (which only ever ADD
|
|
270
|
+
* versions: an upper or lower bound dropped), then narrowed by project `constraints:` (hard solver constraints, so they intersect).
|
|
271
|
+
* Widening comes first because a relaxation applies to the package's own bounds and a constraint to whatever remains.
|
|
272
|
+
* @returns {{intervals: object[]|null, note: string}} null intervals = the declared range could not be parsed
|
|
273
|
+
*/
|
|
274
|
+
export function effectiveDeclared(comp) {
|
|
275
|
+
let intervals = comp.declaredRange ? rangeToIntervals(comp.declaredRange) : [iv(null, false, null, false)];
|
|
276
|
+
if (intervals === null) return { intervals: null, note: '' };
|
|
277
|
+
const notes = [];
|
|
278
|
+
const rel = Array.isArray(comp.relaxation) && comp.relaxation.length ? new Set(comp.relaxation.map((r) => r.direction)) : null;
|
|
279
|
+
if (rel && comp.declaredRange) {
|
|
280
|
+
intervals = intervals.map((x) => iv(rel.has('older') ? ZERO : x.lo, rel.has('older') ? true : x.loInc, rel.has('newer') ? null : x.hi, rel.has('newer') ? false : x.hiInc));
|
|
281
|
+
notes.push(`widened by ${[...rel].map((d) => `allow-${d}`).join(' and ')}, so the declared bounds are not authoritative`);
|
|
282
|
+
}
|
|
283
|
+
const cons = Array.isArray(comp.projectConstraints) ? comp.projectConstraints : [];
|
|
284
|
+
for (const c of cons) {
|
|
285
|
+
const ci = rangeToIntervals(c.text);
|
|
286
|
+
if (ci === null) continue;
|
|
287
|
+
const out = [];
|
|
288
|
+
for (const a of intervals) for (const b of ci) out.push(intersectIntervals(a, b));
|
|
289
|
+
intervals = normalizeIntervals(out);
|
|
290
|
+
}
|
|
291
|
+
if (cons.length) notes.push(`narrowed by project constraints: ${cons.map((c) => c.text).join(', ')}`);
|
|
292
|
+
return { intervals, note: notes.length ? ` (${notes.join('; ')})` : '' };
|
|
293
|
+
}
|
|
248
294
|
function containsInterval(outer, inner) {
|
|
249
295
|
const loOk = outer.lo === null || (inner.lo !== null && (cmp(inner.lo, outer.lo) > 0 || (cmp(inner.lo, outer.lo) === 0 && (outer.loInc || !inner.loInc))));
|
|
250
296
|
const hiOk = outer.hi === null || (inner.hi !== null && (cmp(inner.hi, outer.hi) < 0 || (cmp(inner.hi, outer.hi) === 0 && (outer.hiInc || !inner.hiInc))));
|
|
@@ -291,6 +337,10 @@ export function evaluateComponents(components, db, opts = {}) {
|
|
|
291
337
|
...(ghc ? { remediation: `${comp.name} is provided by the compiler: upgrade GHC (the fix is in ${comp.name} ${aff.fixedIn.join(', ') || '(no fixed version published)'}), not a Cabal dependency bound.` } : { remediation: aff.fixedIn.length ? `Upgrade ${comp.name} to ${aff.fixedIn.join(' or ')}.` : `No fixed version of ${comp.name} is published; remove or replace the dependency.` }),
|
|
292
338
|
kev: kevHit, epss: epssVals.length ? Math.max(...epssVals) : 'unknown',
|
|
293
339
|
file: comp.manifest || null, line: comp.line || null, versionSource: comp.versionSource || null,
|
|
340
|
+
carriedBy: (comp.carriers && comp.carriers.length ? comp.carriers : [{ file: comp.manifest || null, line: comp.line || null, target: comp.target || null, componentKind: comp.componentKind || null, declaredRange: comp.declaredRange || null }]),
|
|
341
|
+
...(comp.projectConstraints && comp.projectConstraints.length ? { projectConstraints: comp.projectConstraints } : {}),
|
|
342
|
+
...(comp.relaxation && comp.relaxation.length ? { relaxation: comp.relaxation } : {}),
|
|
343
|
+
...(comp.compilerDerived ? { compilerDerived: comp.compilerDerived } : {}),
|
|
294
344
|
dataSource: { feed: db.source, generatedAt: db.generatedAt, integrity: db.integrity, feedStatus: feed.status },
|
|
295
345
|
language: 'haskell', capability: 'sca', analysisKind: 'application', evidenceKind: 'manifest',
|
|
296
346
|
confidence: m.status === 'affected' ? 0.9 : 0.5,
|
|
@@ -299,7 +349,37 @@ export function evaluateComponents(components, db, opts = {}) {
|
|
|
299
349
|
}
|
|
300
350
|
if (!any && !statuses.some((s) => s.name === comp.name && s.status !== 'not-affected')) statuses.push({ ...base, status: 'not-affected' });
|
|
301
351
|
}
|
|
302
|
-
return { findings, statuses, feed };
|
|
352
|
+
return { findings: collapseToWorst(findings), statuses, feed };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const STATUS_RANK = { affected: 3, 'possibly-affected': 2, unknown: 1 };
|
|
356
|
+
/**
|
|
357
|
+
* One finding per (package, scope, advisory) carrying the WORST status among the declared uses of that package, with every component that
|
|
358
|
+
* carries that worst status listed in `carriedBy`. Distinct declared ranges used to be collapsed by name and scope with the first one
|
|
359
|
+
* winning, which hid a worse use behind a milder one (found by the live Hackage feed bench). The milder uses are not dropped from the
|
|
360
|
+
* record: `otherUses` lists them, and every use keeps its own row in `statuses`.
|
|
361
|
+
*/
|
|
362
|
+
export function collapseToWorst(findings) {
|
|
363
|
+
const groups = new Map();
|
|
364
|
+
for (const f of findings) {
|
|
365
|
+
const k = `${f.name}\u0000${f.scope || ''}\u0000${f.osvId}\u0000${(f.ids && f.ids[0]) || ''}`; // two records for one advisory stay two findings
|
|
366
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
367
|
+
groups.get(k).push(f);
|
|
368
|
+
}
|
|
369
|
+
const out = [];
|
|
370
|
+
for (const members of groups.values()) {
|
|
371
|
+
if (members.length === 1) { out.push(members[0]); continue; }
|
|
372
|
+
const rank = (f) => STATUS_RANK[f.matchStatus] || 0;
|
|
373
|
+
const worst = Math.max(...members.map(rank));
|
|
374
|
+
const top = members.filter((f) => rank(f) === worst);
|
|
375
|
+
const rest = members.filter((f) => rank(f) !== worst);
|
|
376
|
+
const carried = [];
|
|
377
|
+
for (const f of top) for (const c of f.carriedBy || []) if (!carried.some((x) => x.file === c.file && x.line === c.line && x.target === c.target && x.declaredRange === c.declaredRange)) carried.push(c);
|
|
378
|
+
const lead = { ...top[0], carriedBy: carried };
|
|
379
|
+
if (rest.length) lead.otherUses = rest.map((f) => ({ matchStatus: f.matchStatus, declaredRange: f.declaredRange, version: f.version, carriedBy: f.carriedBy }));
|
|
380
|
+
out.push(lead);
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
303
383
|
}
|
|
304
384
|
|
|
305
385
|
// ── reachability (imports) ───────────────────────────────────────────────────
|
|
@@ -7,7 +7,8 @@ import { join } from 'node:path';
|
|
|
7
7
|
import { analyzeHaskellManifests } from './haskell-manifests.js';
|
|
8
8
|
import { buildHaskellIR } from './haskell-ir.js';
|
|
9
9
|
import { getLastRefresh, refreshHackageAdvisories, liveFeedEnabled, FEED_ENV } from './haskell-advisory-feed.js';
|
|
10
|
-
import { AdvisoryDb, loadAdvisorySnapshot, evaluateComponents, reachability, nearNameCandidates, sourceIntegrity, lifecycle, licensePolicy, hackagePurl, GHC_BOOT_PACKAGES } from './haskell-sca.js';
|
|
10
|
+
import { AdvisoryDb, loadAdvisorySnapshot, evaluateComponents, reachability, nearNameCandidates, sourceIntegrity, lifecycle, licensePolicy, hackagePurl, GHC_BOOT_PACKAGES, rangeToIntervals, intervalsContain, baseRangeForGhc } from './haskell-sca.js';
|
|
11
|
+
import { deriveCppContext } from './haskell-cpp.js';
|
|
11
12
|
|
|
12
13
|
const MANIFEST = /(?:^|\/)(?:[^/]+\.cabal|cabal\.project(?:\.[a-z]+)?|package\.yaml|stack\.yaml|stack\.yaml\.lock)$/;
|
|
13
14
|
const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
|
|
@@ -38,12 +39,16 @@ export function hackageComponents(files) {
|
|
|
38
39
|
// workspace package, not to Hackage; reporting it would raise advisories about the project's own (usually fixed) version as if it were a
|
|
39
40
|
// dependency. Found by the live Hackage feed bench (aeson's own test suite depends on aeson).
|
|
40
41
|
const defined = new Set([...(manifests.packages || []), ...(manifests.hpack || [])].map((p) => p.name).filter(Boolean));
|
|
42
|
+
// Every distinct DECLARED USE is kept (not just the first per package and scope): a second manifest or component with a different range was
|
|
43
|
+
// never evaluated, so a worse status could hide behind a milder kept one. The evaluator reports the worst per package, scope and advisory
|
|
44
|
+
// and lists the components that carry it; `components` below keeps the old one-per-package-and-scope shape for inventories.
|
|
41
45
|
const seen = new Map();
|
|
42
46
|
for (const d of manifests.dependencies || []) {
|
|
43
47
|
if (defined.has(d.name)) continue;
|
|
44
48
|
const lock = lockedFor(d.name, d.manifest);
|
|
45
49
|
const resolved = (lock && lock.version) || d.exactPin || null;
|
|
46
|
-
|
|
50
|
+
// a covered (resolved) use is not merged into an uncovered one; an uncovered use is distinguished by where and how it is declared
|
|
51
|
+
const key = `${d.name}@${d.scope || 'runtime'}@${resolved || ''}@${resolved ? '' : `${d.manifest}|${d.component || ''}|${d.declaredRange || ''}|${d.rangeKind || ''}`}`;
|
|
47
52
|
if (seen.has(key)) continue;
|
|
48
53
|
seen.set(key, {
|
|
49
54
|
ecosystem: 'hackage', name: d.name, version: resolved, declaredRange: d.declaredRange || null, unbounded: !resolved && !d.declaredRange && d.rangeKind === 'unbounded',
|
|
@@ -71,7 +76,96 @@ export function hackageComponents(files) {
|
|
|
71
76
|
c.unbounded = false;
|
|
72
77
|
c.rangeInheritedFromSibling = true;
|
|
73
78
|
}
|
|
74
|
-
|
|
79
|
+
// Project-level bounds from cabal.project: `constraints:` narrow a declared range (the solver must satisfy both) and `allow-newer` /
|
|
80
|
+
// `allow-older` widen it. Both apply only to a use with no resolved version, from a project file that actually governs the manifest.
|
|
81
|
+
const bounds = projectBounds(manifests);
|
|
82
|
+
for (const c of seen.values()) {
|
|
83
|
+
if (c.version) continue;
|
|
84
|
+
const gov = bounds.governing(c.manifest);
|
|
85
|
+
if (!gov.length) continue;
|
|
86
|
+
const cons = bounds.constraintsFor(c.name, gov);
|
|
87
|
+
const relax = bounds.relaxationsFor(c.name, gov);
|
|
88
|
+
if (relax.length) c.relaxation = relax;
|
|
89
|
+
if (!cons.length) continue;
|
|
90
|
+
// an exact pin inside the declared range IS the resolved version; anything else is an interval to intersect
|
|
91
|
+
const pins = cons.filter((x) => x.pin);
|
|
92
|
+
const distinctPins = [...new Set(pins.map((x) => x.pin))];
|
|
93
|
+
const declaredIv = c.declaredRange ? rangeToIntervals(c.declaredRange) : [{ lo: null, loInc: false, hi: null, hiInc: false }];
|
|
94
|
+
if (distinctPins.length === 1 && declaredIv && (!c.declaredRange || intervalsContain(declaredIv, distinctPins[0])) && cons.every((x) => x.pin || (rangeToIntervals(x.text) && intervalsContain(rangeToIntervals(x.text), distinctPins[0])))) {
|
|
95
|
+
const p = pins[0];
|
|
96
|
+
c.version = distinctPins[0]; c.resolution = 'constraint-pin'; c.versionSource = { file: p.file, line: p.line || null }; c.unbounded = false;
|
|
97
|
+
c.projectConstraints = cons.map(({ text, file, line }) => ({ text, file, line }));
|
|
98
|
+
} else {
|
|
99
|
+
c.projectConstraints = cons.map(({ text, file, line }) => ({ text, file, line }));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Collapse uses that ended up identical (same package, scope, version, bounds): one entry, every declaring component kept in `carriers`.
|
|
103
|
+
const merged = new Map();
|
|
104
|
+
for (const c of seen.values()) {
|
|
105
|
+
const key = `${c.name}@${c.scope}@${c.version || ''}@${c.version ? '' : `${c.declaredRange || ''}|${c.unbounded}|${(c.projectConstraints || []).map((x) => x.text).join(',')}|${(c.relaxation || []).map((x) => x.direction).join(',')}`}`;
|
|
106
|
+
const carrier = { file: c.manifest, line: c.line, target: c.target, componentKind: c.componentKind, declaredRange: c.declaredRange };
|
|
107
|
+
if (merged.has(key)) { merged.get(key).carriers.push(carrier); continue; }
|
|
108
|
+
merged.set(key, { ...c, carriers: [carrier] });
|
|
109
|
+
}
|
|
110
|
+
const uses = [...merged.values()];
|
|
111
|
+
const first = new Map();
|
|
112
|
+
for (const c of uses) { const k = `${c.name}@${c.scope}@${c.version || ''}`; if (!first.has(k)) first.set(k, c); }
|
|
113
|
+
return { components: [...first.values()], uses, manifests, bounds: { conditionalConstraintsSkipped: bounds.conditionalSkipped } };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const isRemotePattern = (p) => /^[a-z][a-z0-9+.-]*:\/\//i.test(p);
|
|
117
|
+
/** Does a cabal.project `packages:` pattern name this manifest (a .cabal, or a package.yaml whose cabal file is generated beside it)? */
|
|
118
|
+
function patternNames(pattern, relManifest) {
|
|
119
|
+
let pat = String(pattern).trim().replace(/^\.\//, '');
|
|
120
|
+
if (pat === '' || pat === '.') pat = '*.cabal';
|
|
121
|
+
else if (pat.endsWith('/')) pat = `${pat}*.cabal`;
|
|
122
|
+
else if (!pat.endsWith('.cabal')) pat = `${pat}/*.cabal`;
|
|
123
|
+
const rel = /(?:^|\/)package\.yaml$/.test(relManifest) ? relManifest.replace(/package\.yaml$/, 'package.cabal') : relManifest;
|
|
124
|
+
const re = new RegExp(`^${pat.split('**').map((seg) => seg.split('*').map((x) => x.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('[^/]*')).join('.*')}$`);
|
|
125
|
+
return re.test(rel);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* cabal.project constraints and relaxations, scoped to the manifests a project file governs. A project governs a manifest only when the
|
|
130
|
+
* manifest sits at or below the project's directory AND matches one of its `packages:` patterns (a project with no `packages:` governs
|
|
131
|
+
* the .cabal file in its own directory, which is cabal's default). A constraint under an `if` is not in force everywhere and is not applied
|
|
132
|
+
* (counted in `conditionalSkipped` and disclosed). A freeze file is handled as a resolution, not here.
|
|
133
|
+
*/
|
|
134
|
+
function projectBounds(manifests) {
|
|
135
|
+
const dirOf = (p) => (typeof p === 'string' && p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '');
|
|
136
|
+
const isFreeze = (f) => /\.freeze$|(?:^|\/)cabal\.config$/.test(f || '');
|
|
137
|
+
const projects = (manifests.projects || []).filter((p) => p.kind === 'cabal-project' && !isFreeze(p.file));
|
|
138
|
+
const constraints = [];
|
|
139
|
+
let conditionalSkipped = 0;
|
|
140
|
+
for (const c of manifests.constraints || []) {
|
|
141
|
+
if (c.kind !== 'range' || !c.range || isFreeze(c.manifest)) continue;
|
|
142
|
+
if (c.qualifier && c.qualifier !== 'any') continue;
|
|
143
|
+
if (c.conditions && c.conditions.length) { conditionalSkipped += 1; continue; }
|
|
144
|
+
constraints.push({ name: c.name, text: c.rangeText, file: c.file, line: c.line, project: c.manifest, pin: c.range.t === 'cmp' && c.range.op === '==' && !c.range.wildcard ? c.range.text : null });
|
|
145
|
+
}
|
|
146
|
+
const governs = (project, manifest) => {
|
|
147
|
+
const base = dirOf(project.file);
|
|
148
|
+
let rel;
|
|
149
|
+
if (base === '') rel = manifest;
|
|
150
|
+
else if (manifest.startsWith(`${base}/`)) rel = manifest.slice(base.length + 1);
|
|
151
|
+
else return false;
|
|
152
|
+
const pats = (project.packages || []).map((x) => x.pattern).filter((x) => typeof x === 'string' && !isRemotePattern(x));
|
|
153
|
+
return (pats.length ? pats : ['.']).some((pat) => patternNames(pat, rel));
|
|
154
|
+
};
|
|
155
|
+
const relaxMatches = (target, dep) => {
|
|
156
|
+
const t = String(target || '').trim().replace(/^\^/, '');
|
|
157
|
+
if (t === '' || t === 'all' || t === '*' || t === '*:*') return true;
|
|
158
|
+
const parts = t.split(':');
|
|
159
|
+
const last = parts[parts.length - 1];
|
|
160
|
+
return last === dep || last === '*';
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
conditionalSkipped,
|
|
164
|
+
governing: (manifest) => projects.filter((p) => typeof manifest === 'string' && governs(p, manifest)),
|
|
165
|
+
constraintsFor: (name, gov) => constraints.filter((c) => c.name === name && gov.some((p) => p.file === c.project)),
|
|
166
|
+
// A relaxation naming package `a:dep` is applied to `dep` wherever it is used: widening more than the cabal semantics is the safe direction.
|
|
167
|
+
relaxationsFor: (name, gov) => gov.flatMap((p) => (p.allowRelaxations || []).filter((r) => relaxMatches(r.target, name)).map((r) => ({ direction: r.direction, target: r.target, file: r.file, line: r.line }))),
|
|
168
|
+
};
|
|
75
169
|
}
|
|
76
170
|
|
|
77
171
|
/**
|
|
@@ -130,6 +224,70 @@ export async function prefetchHackageFeed(files, { env = process.env, resolvedCo
|
|
|
130
224
|
} catch (e) { return { status: 'failed', detail: `the feed step failed: ${String((e && e.message) || e).slice(0, 120)}` }; }
|
|
131
225
|
}
|
|
132
226
|
|
|
227
|
+
/**
|
|
228
|
+
* The compiler this project is built with, only when it is stated: `with-compiler`, a Stack `compiler:`, a plan's compiler-id, or a
|
|
229
|
+
* `tested-with` whose every item is the same exact version (a claim by the project, weaker than a pin). Sources that disagree, or a
|
|
230
|
+
* `tested-with` listing several versions, leave it UNKNOWN, never a guess.
|
|
231
|
+
* @returns {{version: string|null, label: string, basis: string|null, reason: string|null}}
|
|
232
|
+
*/
|
|
233
|
+
export function projectCompiler(files, manifests, resolved) {
|
|
234
|
+
const pins = [];
|
|
235
|
+
const take = (v, basis) => { if (typeof v === 'string') { const m = /(?:^|[\\/])ghc-(\d+(?:\.\d+)+)$/.exec(v.trim()); if (m) pins.push({ version: m[1], basis }); } };
|
|
236
|
+
for (const p of (manifests && manifests.projects) || []) take(p.withCompiler, 'with-compiler');
|
|
237
|
+
for (const s of (manifests && manifests.stack) || []) take(s.compiler, 'stack compiler');
|
|
238
|
+
const pc = resolved && resolved.compiler;
|
|
239
|
+
if (pc && /^ghc$/i.test(pc.name || 'ghc') && pc.version) pins.push({ version: pc.version, basis: 'plan compiler-id' });
|
|
240
|
+
const distinct = [...new Set(pins.map((p) => p.version))];
|
|
241
|
+
if (distinct.length > 1) return { version: null, label: 'unknown compiler', basis: null, reason: `the project states conflicting compilers: ${pins.map((p) => `${p.basis} ${p.version}`).join(', ')}` };
|
|
242
|
+
if (distinct.length === 1) return { version: distinct[0], label: `GHC ${distinct[0]}`, basis: [...new Set(pins.map((p) => p.basis))].join(' + '), reason: null };
|
|
243
|
+
let tested = null;
|
|
244
|
+
try { tested = deriveCppContext(files).ghc; } catch { /* unreadable manifests state nothing */ }
|
|
245
|
+
if (tested && tested.basis === 'tested-with') {
|
|
246
|
+
const vs = [...new Set(tested.versions.map((v) => v.join('.')))];
|
|
247
|
+
if (vs.length === 1) return { version: vs[0], label: `GHC ${vs[0]}`, basis: 'tested-with (the project\'s own claim, not a pin)', reason: null };
|
|
248
|
+
return { version: null, label: 'unknown compiler', basis: null, reason: `tested-with lists several GHC versions: ${vs.join(', ')}; none is pinned` };
|
|
249
|
+
}
|
|
250
|
+
return { version: null, label: 'unknown compiler', basis: null, reason: 'no compiler is pinned by with-compiler, a Stack compiler or a plan, and no tested-with names exactly one version' };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A compiler-provided package takes its version from the plan's boot unit, else (for base) the compiler's base series; otherwise it stays as declared. */
|
|
254
|
+
function resolveBootComponent(c, compiler, resolved) {
|
|
255
|
+
if (!c.ghcComponent || c.version) return c;
|
|
256
|
+
const boot = resolved && resolved.bootLibraries && resolved.bootLibraries.find((b) => b.name === c.name && b.version);
|
|
257
|
+
if (boot) return { ...c, version: boot.version, resolution: 'plan-compiler', versionSource: boot.versionSource || null, compilerDerived: { compiler: compiler.label, basis: 'plan boot library' } };
|
|
258
|
+
if (compiler.version && c.name === 'base') {
|
|
259
|
+
const range = baseRangeForGhc(compiler.version);
|
|
260
|
+
if (range) return { ...c, declaredRange: range, unbounded: false, declaredRangeAsWritten: c.declaredRange || null, compilerDerived: { compiler: compiler.label, basis: compiler.basis, rule: `base ${range} is the base series of ${compiler.label}` } };
|
|
261
|
+
}
|
|
262
|
+
return c;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const GHC_RANK = { affected: 3, 'possibly-affected': 2, unknown: 1 };
|
|
266
|
+
/** One finding per advisory for the project's compiler: members, the worst status, and a remediation that names the compiler. */
|
|
267
|
+
function groupCompilerFindings(findings, compiler) {
|
|
268
|
+
const byAdvisory = new Map();
|
|
269
|
+
for (const f of findings) { const k = `${f.osvId}\u0000${(f.ids && f.ids[0]) || ''}`; if (!byAdvisory.has(k)) byAdvisory.set(k, []); byAdvisory.get(k).push(f); }
|
|
270
|
+
const out = [];
|
|
271
|
+
for (const members of byAdvisory.values()) {
|
|
272
|
+
const rank = (f) => GHC_RANK[f.matchStatus] || 0;
|
|
273
|
+
const lead = members.slice().sort((a, b) => rank(b) - rank(a))[0];
|
|
274
|
+
const packages = [...new Set(members.map((m) => m.name))].sort();
|
|
275
|
+
const fixed = [...new Set(members.flatMap((m) => m.fixedIn || []))];
|
|
276
|
+
const list = packages.join(', ');
|
|
277
|
+
const fixText = fixed.length ? `a release that ships ${list} ${fixed.join(' or ')} or later` : `a release with a fixed ${list} (none is published yet)`;
|
|
278
|
+
const remediation = compiler.version
|
|
279
|
+
? `${list} ${packages.length > 1 ? 'are' : 'is'} provided by the compiler, and this project builds with ${compiler.label} (${compiler.basis}). Upgrade GHC to ${fixText}; a Cabal bound does not change the compiler's own library.`
|
|
280
|
+
: `${list} ${packages.length > 1 ? 'are' : 'is'} provided by the compiler, and this project's compiler is unknown (${compiler.reason}), so which ${list} version applies cannot be decided. Pin one with with-compiler in cabal.project, or supply a Cabal plan, to decide it. The fix is a compiler upgrade: upgrade GHC to ${fixText}.`;
|
|
281
|
+
out.push({
|
|
282
|
+
...lead, name: list, packages, grouped: 'compiler', ghcComponent: true, compiler: { version: compiler.version, label: compiler.label, basis: compiler.basis, reason: compiler.reason },
|
|
283
|
+
matchStatus: lead.matchStatus, remediation,
|
|
284
|
+
members: members.map((m) => ({ name: m.name, scope: m.scope, version: m.version, declaredRange: m.declaredRange, matchStatus: m.matchStatus, matchReason: m.matchReason, resolution: m.resolution, carriedBy: m.carriedBy, ...(m.compilerDerived ? { compilerDerived: m.compilerDerived } : {}) })),
|
|
285
|
+
memberCount: members.length,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
133
291
|
/** Imports and import-qualified callees across the project's Haskell sources. */
|
|
134
292
|
export function collectUsage(files) {
|
|
135
293
|
const hs = {};
|
|
@@ -156,9 +314,15 @@ export function analyzeHaskellSupply(files, { db = undefined, root = null, env =
|
|
|
156
314
|
if (!components.length) return out;
|
|
157
315
|
let feedReason = null;
|
|
158
316
|
if (db === undefined) { const c = configuredAdvisoryDb(root, env); db = c.db; feedReason = c.reason; }
|
|
159
|
-
const
|
|
160
|
-
out.
|
|
317
|
+
const compiler = projectCompiler(files, manifests, resolved);
|
|
318
|
+
out.compiler = compiler;
|
|
319
|
+
// Evaluate every distinct declared use (the evaluator reports the worst per package, scope and advisory), with compiler-provided packages
|
|
320
|
+
// resolved against the project's compiler where that can be decided.
|
|
321
|
+
const evalComponents = mergeResolved(hc.uses, resolved).map((c) => resolveBootComponent(c, compiler, resolved));
|
|
322
|
+
const ev = evaluateComponents(evalComponents, db, { kev, epss });
|
|
323
|
+
out.statuses = ev.statuses.map((s) => (s.ghcComponent ? { ...s, compiler: compiler.label } : s));
|
|
161
324
|
out.feed = ev.feed;
|
|
325
|
+
if (hc.bounds && hc.bounds.conditionalConstraintsSkipped) out.gaps.push({ kind: 'conditional-constraints-not-applied', detail: `${hc.bounds.conditionalConstraintsSkipped} cabal.project constraint(s) sit under a condition (if os(...), flag, compiler) and were NOT applied to declared ranges; they are not in force everywhere, so a declared range they would narrow is judged as written` });
|
|
162
326
|
if (!db) out.gaps.push({ kind: 'advisory-feed-unavailable', detail: `${feedReason || ev.feed.detail}. ${components.length} Hackage dependencies were NOT checked against any advisory: the absence of findings is not a clean result.` });
|
|
163
327
|
else if (ev.feed.status === 'stale-cache') out.gaps.push({ kind: 'advisory-feed-stale', detail: ev.feed.detail });
|
|
164
328
|
const notCovered = ev.statuses.filter((x) => x.status === 'feed-incomplete' || x.status === 'feed-stale');
|
|
@@ -173,8 +337,13 @@ export function analyzeHaskellSupply(files, { db = undefined, root = null, env =
|
|
|
173
337
|
f.reachability = r;
|
|
174
338
|
f.functionReachable = r.function === 'reachable' ? 'reachable' : (r.function === 'not-imported' ? 'unreachable' : 'unknown');
|
|
175
339
|
f.reachabilityTier = r.import === 'imported' ? (r.function === 'reachable' ? 'function-reachable' : 'import-reachable') : (r.import === 'not-imported' ? 'not-imported' : 'unknown');
|
|
176
|
-
out.supplyChain.push({ ...f, file: f.file, line: f.line });
|
|
177
340
|
}
|
|
341
|
+
// Compiler-provided packages (GHC boot libraries) are one finding per advisory for the project's compiler, not one per package and scope:
|
|
342
|
+
// the fix is a compiler upgrade whatever the Cabal bounds say. Every member stays listed in the group and every use keeps its status row.
|
|
343
|
+
const ordinary = ev.findings.filter((f) => !f.ghcComponent);
|
|
344
|
+
const groups = groupCompilerFindings(ev.findings.filter((f) => f.ghcComponent), compiler);
|
|
345
|
+
for (const f of [...ordinary, ...groups]) out.supplyChain.push({ ...f, file: f.file, line: f.line });
|
|
346
|
+
out.counts = { dependencyFindings: ordinary.length, compilerAdvisories: groups.length, compilerMemberRows: groups.reduce((n, g) => n + g.memberCount, 0), statusRows: out.statuses.length };
|
|
178
347
|
// policy
|
|
179
348
|
const si = sourceIntegrity(manifests || {});
|
|
180
349
|
for (const s of si.filter((x) => x.finding)) {
|