@clear-capabilities/agentic-security-scanner 0.153.1 → 0.154.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 +67 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1164 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +18 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +140 -19
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/code-injection-multilang.js +15 -0
- package/src/sast/go-extended.js +11 -0
- package/src/sast/pipeline.js +34 -1
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Project context for Haskell and Nix when a surface scans only part of a project (X-014).
|
|
2
|
+
//
|
|
3
|
+
// The MCP `scan_diff` / `verify_fix` / `synthesize_fix` tools and the LSP on-save scan hand the engine ONE file (or a
|
|
4
|
+
// few). For Haskell and Nix that is not enough to be right: a taint flow crosses modules, and an effective NixOS value
|
|
5
|
+
// is decided by the modules a configuration imports. So these surfaces add the import closure of the file(s) as
|
|
6
|
+
// context, plus the manifests, read from disk, bounded in files and bytes. Findings are still reported only for the
|
|
7
|
+
// files the caller asked about. Nothing here writes, executes or fetches anything.
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { STATE_DIR_NAME } from '../posture/state-dir.js';
|
|
12
|
+
import { isLanguageSource, isLanguageManifest, isLanguageExcludedPath, buildImportGraph, importClosure } from './discovery.js';
|
|
13
|
+
import { planHaskellFix, validateHaskellFix, unifiedDiff as hsDiff } from './haskell-fix.js';
|
|
14
|
+
import { planNixFix, validateNixFix, planNixUpgrade } from './nix-fix.js';
|
|
15
|
+
import { planHaskellUpgrade, unifiedDiff as _hsDiff } from './haskell-fix.js';
|
|
16
|
+
import { writeWithBackup } from './fix-lifecycle.js';
|
|
17
|
+
import { recordExternalFix } from '../posture/fix-history.js';
|
|
18
|
+
|
|
19
|
+
export const CONTEXT_BUDGETS = Object.freeze({ maxFiles: 600, maxBytes: 4 * 1024 * 1024, maxFileBytes: 400_000, maxDepth: 12 });
|
|
20
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.hg', 'dist-newstyle', '.stack-work', '.cabal-sandbox', '.direnv', 'result', STATE_DIR_NAME]);
|
|
21
|
+
|
|
22
|
+
const norm = (p) => String(p).split(path.sep).join('/');
|
|
23
|
+
export const isLanguagePath = (rel) => /\.(?:l?hs|hs-boot|hsc|nix)$/i.test(rel);
|
|
24
|
+
|
|
25
|
+
/** Reads every Haskell/Nix source and manifest under `root`, bounded. Returns {files, manifests, truncated}. */
|
|
26
|
+
export function loadLanguageProject(root, budgets = {}) {
|
|
27
|
+
const b = { ...CONTEXT_BUDGETS, ...budgets };
|
|
28
|
+
const files = {}; const manifests = {};
|
|
29
|
+
let count = 0; let bytes = 0; let truncated = false;
|
|
30
|
+
const walk = (dir, depth) => {
|
|
31
|
+
if (truncated || depth > b.maxDepth) { if (depth > b.maxDepth) truncated = true; return; }
|
|
32
|
+
let entries;
|
|
33
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
34
|
+
entries.sort((x, y) => (x.name < y.name ? -1 : 1));
|
|
35
|
+
for (const e of entries) {
|
|
36
|
+
if (e.isSymbolicLink()) continue;
|
|
37
|
+
const full = path.join(dir, e.name);
|
|
38
|
+
if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(full, depth + 1); continue; }
|
|
39
|
+
if (!e.isFile()) continue;
|
|
40
|
+
const rel = norm(path.relative(root, full));
|
|
41
|
+
const src = isLanguageSource(rel); const man = isLanguageManifest(rel);
|
|
42
|
+
if (!(src || man) || isLanguageExcludedPath(rel)) continue;
|
|
43
|
+
let st; try { st = fs.statSync(full); } catch { continue; }
|
|
44
|
+
if (st.size > b.maxFileBytes) continue;
|
|
45
|
+
if (count >= b.maxFiles || bytes + st.size > b.maxBytes) { truncated = true; return; }
|
|
46
|
+
try { (src ? files : manifests)[rel] = fs.readFileSync(full, 'utf8'); count++; bytes += st.size; } catch { /* unreadable: skipped */ }
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(root, 0);
|
|
50
|
+
return { files, manifests, truncated };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Expands a partial scan with the language context it needs. `fileContents` entries always win over disk. With no
|
|
55
|
+
* Haskell/Nix file in `fileContents` the inputs come back unchanged (same objects), so other languages are untouched.
|
|
56
|
+
* @returns {{fileContents:object, depFileContents:object, context:string[], truncated:boolean}}
|
|
57
|
+
*/
|
|
58
|
+
export function withLanguageContext(root, fileContents, depFileContents = {}, budgets = {}) {
|
|
59
|
+
const wanted = Object.keys(fileContents || {}).map(norm).filter(isLanguagePath);
|
|
60
|
+
if (!wanted.length || !root) return { fileContents, depFileContents, context: [], truncated: false };
|
|
61
|
+
const proj = loadLanguageProject(root, budgets);
|
|
62
|
+
const merged = { ...proj.files, ...fileContents };
|
|
63
|
+
const { graph } = buildImportGraph(merged);
|
|
64
|
+
const need = new Set();
|
|
65
|
+
for (const f of wanted) { need.add(f); for (const d of importClosure(graph, f).files) need.add(d); }
|
|
66
|
+
// importers matter too: a changed callee can make a caller (un)safe
|
|
67
|
+
const rev = new Map();
|
|
68
|
+
for (const [k, ds] of graph) for (const d of ds) { if (!rev.has(d)) rev.set(d, []); rev.get(d).push(k); }
|
|
69
|
+
let frontier = [...wanted];
|
|
70
|
+
while (frontier.length) { const next = []; for (const f of frontier) for (const r of rev.get(f) || []) if (!need.has(r)) { need.add(r); next.push(r); } frontier = next; }
|
|
71
|
+
const out = { ...fileContents };
|
|
72
|
+
const context = [];
|
|
73
|
+
for (const k of need) if (!(k in out) && k in proj.files) { out[k] = proj.files[k]; context.push(k); }
|
|
74
|
+
return { fileContents: out, depFileContents: { ...proj.manifests, ...depFileContents }, context: context.sort(), truncated: proj.truncated };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The language fixer a finding belongs to, or null. */
|
|
78
|
+
export function languageOfFinding(f) {
|
|
79
|
+
const file = String((f && (f.file || (f.sink && f.sink.file))) || '');
|
|
80
|
+
if (/\.l?hs$|\.hs-boot$|\.hsc$/i.test(file)) return 'haskell';
|
|
81
|
+
if (/\.nix$/i.test(file)) return 'nix';
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A READ-ONLY fix preview for a Haskell/Nix finding: the planned patch, its tier, and what the verification gates say.
|
|
87
|
+
* Never writes. `files` is the project's language sources (see loadLanguageProject).
|
|
88
|
+
*/
|
|
89
|
+
export async function languageFixPreview(finding, files, { apply = false, root = null } = {}) {
|
|
90
|
+
const lang = languageOfFinding(finding);
|
|
91
|
+
if (!lang) return { ok: false, status: 'unsupported', reason: 'not a Haskell or Nix finding' };
|
|
92
|
+
const plan = lang === 'haskell' ? planHaskellFix(finding, files) : planNixFix(finding, files);
|
|
93
|
+
if (!plan || plan.ok === false) return { ok: false, status: (plan && plan.status) || 'unsupported', tier: (plan && plan.tier) || null, reason: (plan && plan.reason) || 'no deterministic fix', proposal: (plan && plan.proposal) || null };
|
|
94
|
+
let res;
|
|
95
|
+
try { res = lang === 'haskell' ? await validateHaskellFix(finding, { files, apply, root }) : await validateNixFix(finding, { files, apply, root }); } catch (e) { res = { status: 'blocked', reason: `verification failed: ${String((e && e.message) || e)}` }; }
|
|
96
|
+
return {
|
|
97
|
+
ok: res.status === 'verified' || res.status === 'applied',
|
|
98
|
+
status: res.status, applied: res.applied === true, backup: res.backup || null,
|
|
99
|
+
label: plan.label || null, tier: res.tier || plan.tier || null,
|
|
100
|
+
file: plan.file, before: plan.before, after: plan.after,
|
|
101
|
+
diff: res.preview || hsDiff(plan.file, plan.before, plan.after),
|
|
102
|
+
gates: res.gates || null, reason: (res.status === 'verified' || res.status === 'applied') ? null : (res.reason || null),
|
|
103
|
+
explanation: plan.explanation || null, consequences: res.consequences || plan.consequences || [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A minimal line-range text edit turning `before` into `after` (for an LSP/editor workspace edit). */
|
|
108
|
+
export function minimalEdit(before, after) {
|
|
109
|
+
const a = before.split('\n'); const b = after.split('\n');
|
|
110
|
+
let i = 0; while (i < a.length && i < b.length && a[i] === b[i]) i++;
|
|
111
|
+
let ea = a.length; let eb = b.length;
|
|
112
|
+
while (ea > i && eb > i && a[ea - 1] === b[eb - 1]) { ea--; eb--; }
|
|
113
|
+
const endOfDoc = { line: a.length - 1, character: a[a.length - 1].length };
|
|
114
|
+
if (i >= a.length) return { range: { start: endOfDoc, end: endOfDoc }, newText: `\n${b.slice(i).join('\n')}` }; // appended lines
|
|
115
|
+
if (ea === a.length) return { range: { start: { line: i, character: 0 }, end: endOfDoc }, newText: b.slice(i, eb).join('\n') };
|
|
116
|
+
return { range: { start: { line: i, character: 0 }, end: { line: ea, character: 0 } }, newText: eb > i ? `${b.slice(i, eb).join('\n')}\n` : '' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── model-assisted workflows ─────────────────────────────────────────────────
|
|
120
|
+
import crypto from 'node:crypto';
|
|
121
|
+
import { computeLanguageDigests } from './discovery.js';
|
|
122
|
+
const _digestMemo = new WeakMap();
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A digest of `file` and everything its verdict depends on: the modules it imports, every manifest and lock file.
|
|
126
|
+
* Used where a cached model verdict must be invalidated when a DEPENDENCY changes, not only the file itself. For a
|
|
127
|
+
* non-language file the plain content hash is returned.
|
|
128
|
+
*/
|
|
129
|
+
export function languageClosureDigest(fileContents, file) {
|
|
130
|
+
if (!fileContents || !file) return '';
|
|
131
|
+
if (!isLanguagePath(file)) return crypto.createHash('sha256').update(String(fileContents[file] ?? '')).digest('hex').slice(0, 32);
|
|
132
|
+
let memo = _digestMemo.get(fileContents);
|
|
133
|
+
if (!memo) {
|
|
134
|
+
const manifests = {};
|
|
135
|
+
for (const k of Object.keys(fileContents)) if (isLanguageManifest(k)) manifests[k] = fileContents[k];
|
|
136
|
+
memo = computeLanguageDigests(fileContents, { manifests }).digests;
|
|
137
|
+
_digestMemo.set(fileContents, memo);
|
|
138
|
+
}
|
|
139
|
+
return String(memo.get(norm(file)) || crypto.createHash('sha256').update(String(fileContents[file] ?? '')).digest('hex')).slice(0, 32);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The extra context a model needs to judge a Haskell/Nix finding whose evidence is NOT on the reported line: the other
|
|
144
|
+
* files on its evidence chain and the files that decided a configuration value. Only files the finding itself names are
|
|
145
|
+
* included (never a project dump), a few lines each, bounded in total. Returns [{file, startLine, text}].
|
|
146
|
+
*/
|
|
147
|
+
export function languagePromptExtras(finding, fileContents, { maxFiles = 4, windowLines = 5, maxChars = 3000 } = {}) {
|
|
148
|
+
if (!finding || !fileContents || !isLanguagePath(String(finding.file || ''))) return [];
|
|
149
|
+
const want = []; const seen = new Set([finding.file]);
|
|
150
|
+
for (const step of Array.isArray(finding.chain) ? finding.chain : []) {
|
|
151
|
+
if (step && typeof step.file === 'string' && !seen.has(step.file) && typeof fileContents[step.file] === 'string') { seen.add(step.file); want.push({ file: step.file, line: Number.isInteger(step.line) ? step.line : 1 }); }
|
|
152
|
+
}
|
|
153
|
+
for (const f of Array.isArray(finding.controlFiles) ? finding.controlFiles : []) {
|
|
154
|
+
if (typeof f === 'string' && !seen.has(f) && typeof fileContents[f] === 'string') { seen.add(f); want.push({ file: f, line: 1 }); }
|
|
155
|
+
}
|
|
156
|
+
const out = []; let used = 0;
|
|
157
|
+
for (const w of want.slice(0, maxFiles)) {
|
|
158
|
+
const lines = fileContents[w.file].split('\n');
|
|
159
|
+
const start = Math.max(0, w.line - 1 - windowLines); const end = Math.min(lines.length, w.line + windowLines);
|
|
160
|
+
const text = lines.slice(start, end).map((l, i) => `${start + i + 1}: ${l}`).join('\n');
|
|
161
|
+
if (used + text.length > maxChars) break;
|
|
162
|
+
used += text.length; out.push({ file: w.file, startLine: start + 1, text });
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── dependency upgrades ─────────────────────────────────────────────────────────
|
|
168
|
+
/** Whether a finding is a dependency finding the language upgrade planners can act on. */
|
|
169
|
+
export function isLanguageDependencyFinding(f) {
|
|
170
|
+
return !!f && f.family === 'vulnerable-dep' && (f.ecosystem === 'hackage' || (f.ecosystem === 'nix' && /(^|\/)flake\.nix$/.test(String(f.file || ''))));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A dependency fix for a Hackage dependency (raise the declared constraint to a fixed version) or a flake input (point its
|
|
175
|
+
* ref at `toRef`). Both are SOURCE edits whose real effect needs a re-resolve or a re-lock the scanner will not run, so they
|
|
176
|
+
* carry a tier that says so: nothing here claims the dependency is fixed until it has been re-resolved.
|
|
177
|
+
*/
|
|
178
|
+
export async function languageUpgradePreview(finding, files, { apply = false, root = null, toRef = null } = {}) {
|
|
179
|
+
let plan;
|
|
180
|
+
if (finding.ecosystem === 'hackage') {
|
|
181
|
+
plan = planHaskellUpgrade({ name: finding.package, fixedIn: Array.isArray(finding.fixedIn) ? finding.fixedIn : [], unfixed: !finding.fixedIn, file: finding.declaringFile || finding.file, line: finding.declaringLine }, files);
|
|
182
|
+
if (!plan.ok) return { ok: false, status: plan.status || 'blocked', tier: 'blocked', reason: plan.reason };
|
|
183
|
+
plan = { ...plan, file: plan.file, tier: plan.status === 'fixed' ? 'full-source-edit' : 'constraints-only (not re-resolved)', label: plan.status === 'fixed' ? 'FULL' : 'WORKAROUND', note: plan.note || null,
|
|
184
|
+
explanation: `Raise the declared bound of ${finding.package} from ${plan.from} to ${plan.to}.`, consequences: ['Re-resolve (for example `cabal build --dry-run`) and rescan: this edit changes a constraint, not the resolved version.'] };
|
|
185
|
+
} else {
|
|
186
|
+
if (!toRef) return { ok: false, status: 'blocked', tier: 'blocked', reason: 'a flake input fix needs a target: pass --to <ref or revision>' };
|
|
187
|
+
const up = planNixUpgrade({ files, input: finding.package, toRef });
|
|
188
|
+
if (!up.ok) return { ok: false, status: up.status || 'blocked', tier: up.tier || 'blocked', reason: up.reason };
|
|
189
|
+
plan = { ...up, file: up.edits[0].file, before: up.edits[0].before, after: up.edits[0].after, label: 'WORKAROUND', note: up.note || null, explanation: `Point input ${up.input} at ${up.to} (was ${up.from}).`, consequences: up.consequences || [] };
|
|
190
|
+
}
|
|
191
|
+
const out = { ok: true, status: plan.status, tier: plan.tier, label: plan.label, file: plan.file, before: plan.before, after: plan.after, diff: plan.preview || hsDiff(plan.file, plan.before, plan.after), explanation: plan.explanation, consequences: [...(plan.consequences || []), ...(plan.note ? [plan.note] : [])], applied: false, gates: { path: { ok: true }, syntax: { ok: true }, rescan: { ran: false }, compile: { ran: false } } };
|
|
192
|
+
if (apply) {
|
|
193
|
+
if (!root) return { ...out, ok: false, status: 'blocked', reason: 'apply requires a root directory' };
|
|
194
|
+
out.backup = writeWithBackup(root, plan.file, plan.before, plan.after);
|
|
195
|
+
out.applied = true; out.status = 'applied';
|
|
196
|
+
try { await recordExternalFix({ scanRoot: root, file: plan.file, originalContent: plan.before, newContent: plan.after, findingId: finding.id, ruleId: finding.osvId || 'dependency-upgrade', vuln: finding.vuln || 'Vulnerable dependency', stableId: finding.stableId || null, fixLabel: plan.label, verification: { tier: plan.tier, rescan: 'not-run' }, backup: out.backup }); } catch { /* the ledger is best effort here */ }
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Language pipeline contracts for Haskell and Nix (CORE-003).
|
|
2
|
+
//
|
|
3
|
+
// Three jobs, all additive over the existing finding / component / scan-health /
|
|
4
|
+
// DataFlowGraph shapes (no required field is removed or renamed):
|
|
5
|
+
//
|
|
6
|
+
// 1. METADATA. Optional `language`, `capability`, `evidenceKind`, `scope`,
|
|
7
|
+
// `uncertainty` and `originalLocation` fields, validated by
|
|
8
|
+
// validateLanguageFinding / validateLanguageComponent. `parser`/`family`
|
|
9
|
+
// stay required exactly as before.
|
|
10
|
+
// 2. PRODUCERS. Every language analyzer is registered (with the shared
|
|
11
|
+
// pipeline/producer-registry.js as well as a local table that carries the
|
|
12
|
+
// capability and evidence kinds). A finding from an unregistered producer
|
|
13
|
+
// is dropped from the result and recorded as an `unregistered-producer`
|
|
14
|
+
// outcome, so it cannot silently reach a verdict.
|
|
15
|
+
// 3. COVERAGE OUTCOMES. runLanguageAnalysis drives registered adapters over a
|
|
16
|
+
// file set and records, per file and per analyzer, exactly one of
|
|
17
|
+
// analyzed / excluded / unresolved / failed / timed_out / missing_grammar.
|
|
18
|
+
// reconcileLanguageLedger proves those counts add up to the real inputs.
|
|
19
|
+
// languageHealth turns the ledger into scan-health conditions.
|
|
20
|
+
//
|
|
21
|
+
// Nothing here executes scanned code: adapters are caller-supplied functions
|
|
22
|
+
// over file text.
|
|
23
|
+
|
|
24
|
+
import { isRegisteredProducer, registerProducer } from '../pipeline/producer-registry.js';
|
|
25
|
+
import { isLanguageExcludedPath, isLanguageSource, languageOf } from './discovery.js';
|
|
26
|
+
|
|
27
|
+
export const LANGUAGE_CONTRACT_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
export const LANGUAGES = Object.freeze(['haskell', 'nix']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `type` of a supply-chain entry that is a build/fetch/cache TRUST finding rather than an
|
|
33
|
+
* advisory match (NIX-005). It travels in the same `supplyChain` bucket as `vulnerable_dep`,
|
|
34
|
+
* so SCA policy, SCA verdicts and scan health read it without a side channel.
|
|
35
|
+
*/
|
|
36
|
+
export const BUILD_TRUST_TYPE = 'nix_build_trust';
|
|
37
|
+
|
|
38
|
+
// Evidence kinds. For Nix, a script generated by a derivation, the static
|
|
39
|
+
// configuration it declares, and observed runtime state are different claims
|
|
40
|
+
// and must never share a kind.
|
|
41
|
+
export const EVIDENCE_KINDS = Object.freeze([
|
|
42
|
+
'source', 'manifest', 'lock', 'config', 'generated-script', 'runtime',
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
export const CAPABILITIES = Object.freeze([
|
|
46
|
+
'parse', 'sast', 'taint', 'sca', 'config', 'iac', 'privacy', 'route-inventory',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
export const FILE_STATUSES = Object.freeze([
|
|
50
|
+
'analyzed', 'excluded', 'unresolved', 'failed', 'timed_out', 'missing_grammar',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// Why an outcome was recorded. `unresolved-branch` is a file that WAS analyzed
|
|
54
|
+
// but left a branch the adapter could not resolve; it is reported on top of the
|
|
55
|
+
// file's status, not instead of it.
|
|
56
|
+
export const OUTCOME_KINDS = Object.freeze([
|
|
57
|
+
'missing-grammar', 'adapter-exception', 'unresolved-branch', 'timeout',
|
|
58
|
+
'unregistered-producer', 'excluded-path', 'unsupported-language',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
export const UNCERTAINTY_KINDS = Object.freeze([
|
|
62
|
+
'unresolved-import', 'unresolved-branch', 'generated-source', 'preprocessed', 'partial-parse',
|
|
63
|
+
// a call whose target is not known (higher-order parameter, unbound name, typeclass without a visible
|
|
64
|
+
// instance) or whose code is not analysed (foreign function): taint is widened across it, never dropped
|
|
65
|
+
'unresolved-target', 'foreign-boundary',
|
|
66
|
+
// the analysed library version is not one the model was written against: results are best-effort
|
|
67
|
+
'untested-model',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
// ── 2. producers ───────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
const _producers = new Map();
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {object} def
|
|
76
|
+
* @param {string} def.id unique, `language:<name>` by convention
|
|
77
|
+
* @param {string} def.language one of LANGUAGES
|
|
78
|
+
* @param {string} def.capability one of CAPABILITIES
|
|
79
|
+
* @param {string[]} [def.evidenceKinds] subset of EVIDENCE_KINDS it may emit
|
|
80
|
+
* @param {string} [def.version]
|
|
81
|
+
*/
|
|
82
|
+
export function registerLanguageProducer(def) {
|
|
83
|
+
if (!def || typeof def !== 'object') throw new Error('registerLanguageProducer: definition required');
|
|
84
|
+
const { id, language, capability, evidenceKinds = ['source'], version = '1' } = def;
|
|
85
|
+
if (typeof id !== 'string' || !id) throw new Error('registerLanguageProducer: id required');
|
|
86
|
+
if (!LANGUAGES.includes(language)) throw new Error(`registerLanguageProducer: "${id}" unknown language "${language}"`);
|
|
87
|
+
if (!CAPABILITIES.includes(capability)) throw new Error(`registerLanguageProducer: "${id}" unknown capability "${capability}"`);
|
|
88
|
+
for (const k of evidenceKinds) {
|
|
89
|
+
if (!EVIDENCE_KINDS.includes(k)) throw new Error(`registerLanguageProducer: "${id}" unknown evidence kind "${k}"`);
|
|
90
|
+
}
|
|
91
|
+
if (_producers.has(id)) throw new Error(`registerLanguageProducer: duplicate producer id "${id}"`);
|
|
92
|
+
_producers.set(id, Object.freeze({ id, language, capability, evidenceKinds: Object.freeze([...evidenceKinds]), version }));
|
|
93
|
+
if (!isRegisteredProducer(id)) registerProducer({ id, version, phase: 'sast', languages: [language] });
|
|
94
|
+
return _producers.get(id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isRegisteredLanguageProducer(id) {
|
|
98
|
+
return _producers.has(id);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function listLanguageProducers() {
|
|
102
|
+
return [..._producers.values()];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Test-only. */
|
|
106
|
+
export function _resetLanguageProducersForTests() {
|
|
107
|
+
_producers.clear();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── 1. metadata + validators ───────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
const REQUIRED_FINDING_FIELDS = ['id', 'severity', 'file', 'line', 'vuln', 'cwe', 'description', 'remediation', 'parser', 'family'];
|
|
113
|
+
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
114
|
+
|
|
115
|
+
function validLocation(loc) {
|
|
116
|
+
return !!loc && typeof loc === 'object'
|
|
117
|
+
&& Number.isInteger(loc.line) && loc.line >= 1
|
|
118
|
+
&& (loc.column === undefined || (Number.isInteger(loc.column) && loc.column >= 0))
|
|
119
|
+
&& (loc.file === undefined || typeof loc.file === 'string');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateLanguageFields(obj, errors, label) {
|
|
123
|
+
if (obj.language !== undefined && !LANGUAGES.includes(obj.language)) errors.push(`${label}: unknown language "${obj.language}"`);
|
|
124
|
+
if (obj.capability !== undefined && !CAPABILITIES.includes(obj.capability)) errors.push(`${label}: unknown capability "${obj.capability}"`);
|
|
125
|
+
if (obj.evidenceKind !== undefined && !EVIDENCE_KINDS.includes(obj.evidenceKind)) errors.push(`${label}: unknown evidenceKind "${obj.evidenceKind}"`);
|
|
126
|
+
if (obj.scope !== undefined) {
|
|
127
|
+
const s = obj.scope;
|
|
128
|
+
if (!s || typeof s !== 'object' || Array.isArray(s)) errors.push(`${label}: scope must be an object`);
|
|
129
|
+
else {
|
|
130
|
+
for (const k of ['target', 'configuration', 'component']) {
|
|
131
|
+
if (s[k] !== undefined && s[k] !== null && typeof s[k] !== 'string') errors.push(`${label}: scope.${k} must be a string or null`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (obj.uncertainty !== undefined) {
|
|
136
|
+
if (!Array.isArray(obj.uncertainty)) errors.push(`${label}: uncertainty must be an array`);
|
|
137
|
+
else {
|
|
138
|
+
for (const u of obj.uncertainty) {
|
|
139
|
+
if (!u || !UNCERTAINTY_KINDS.includes(u.kind)) errors.push(`${label}: unknown uncertainty kind "${u && u.kind}"`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (obj.originalLocation !== undefined && !validLocation(obj.originalLocation)) errors.push(`${label}: originalLocation needs an integer line >= 1`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Validates the finding contract. The legacy required fields are unchanged, so
|
|
148
|
+
* an output written before this contract existed still validates; the language
|
|
149
|
+
* fields are checked only when present. `producer`, when present, must be
|
|
150
|
+
* registered.
|
|
151
|
+
*/
|
|
152
|
+
export function validateLanguageFinding(f) {
|
|
153
|
+
const errors = [];
|
|
154
|
+
if (!f || typeof f !== 'object') return { valid: false, errors: ['finding must be an object'] };
|
|
155
|
+
for (const k of REQUIRED_FINDING_FIELDS) {
|
|
156
|
+
if (f[k] === undefined || f[k] === null || f[k] === '') {
|
|
157
|
+
// `line` may legitimately be null for struct detectors, but then it is not suppressible; keep it required here.
|
|
158
|
+
errors.push(`missing required field "${k}"`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (f.severity !== undefined && !SEVERITIES.includes(f.severity)) errors.push(`invalid severity "${f.severity}"`);
|
|
162
|
+
validateLanguageFields(f, errors, 'finding');
|
|
163
|
+
if (f.producer !== undefined && !isRegisteredLanguageProducer(f.producer)) errors.push(`finding: producer "${f.producer}" is not registered`);
|
|
164
|
+
return { valid: errors.length === 0, errors };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Component contract (SBOM/inventory entry). Legacy `name` + `version` stay required. */
|
|
168
|
+
export function validateLanguageComponent(c) {
|
|
169
|
+
const errors = [];
|
|
170
|
+
if (!c || typeof c !== 'object') return { valid: false, errors: ['component must be an object'] };
|
|
171
|
+
if (typeof c.name !== 'string' || !c.name) errors.push('missing required field "name"');
|
|
172
|
+
if (c.version !== undefined && c.version !== null && typeof c.version !== 'string') errors.push('component: version must be a string or null');
|
|
173
|
+
validateLanguageFields(c, errors, 'component');
|
|
174
|
+
return { valid: errors.length === 0, errors };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Adds language metadata to a finding without overwriting detector-set values. */
|
|
178
|
+
export function withLanguageMetadata(finding, meta = {}) {
|
|
179
|
+
const out = { ...finding };
|
|
180
|
+
const lang = out.language ?? meta.language ?? languageOf(out.file || '');
|
|
181
|
+
if (lang) out.language = lang;
|
|
182
|
+
for (const k of ['capability', 'evidenceKind', 'scope', 'uncertainty', 'originalLocation', 'producer']) {
|
|
183
|
+
if (out[k] === undefined && meta[k] !== undefined) out[k] = meta[k];
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── 3. coverage outcomes ───────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
function emptyCounts() {
|
|
191
|
+
return { discovered: 0, analyzed: 0, excluded: 0, unresolved: 0, failed: 0, timedOut: 0, missingGrammar: 0 };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const STATUS_COUNT_KEY = {
|
|
195
|
+
analyzed: 'analyzed', excluded: 'excluded', unresolved: 'unresolved',
|
|
196
|
+
failed: 'failed', timed_out: 'timedOut', missing_grammar: 'missingGrammar',
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function withTimeout(promiseLike, ms) {
|
|
200
|
+
if (!Number.isFinite(ms) || ms <= 0) return Promise.resolve(promiseLike);
|
|
201
|
+
let timer;
|
|
202
|
+
const timeout = new Promise((_, reject) => {
|
|
203
|
+
timer = setTimeout(() => reject(Object.assign(new Error(`timed out after ${ms}ms`), { code: 'LANG_TIMEOUT' })), ms);
|
|
204
|
+
});
|
|
205
|
+
return Promise.race([Promise.resolve(promiseLike), timeout]).finally(() => clearTimeout(timer));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Drives registered adapters over a file set.
|
|
210
|
+
*
|
|
211
|
+
* @param {object} input
|
|
212
|
+
* @param {Record<string,string>} input.files rel path -> content (the real inputs)
|
|
213
|
+
* @param {Array<{id:string, language:string, hasGrammar?:()=>boolean,
|
|
214
|
+
* analyze:(file:string, content:string)=>({findings?:object[], unresolved?:object[]}|Promise<object>)}>} input.adapters
|
|
215
|
+
* @param {number} [input.timeoutMs]
|
|
216
|
+
* @returns {Promise<{findings:object[], ledger:object, outcomes:object[]}>}
|
|
217
|
+
* ledger.byFile[file][analyzerId] is a status string; ledger.byAnalyzer[id] holds counts.
|
|
218
|
+
*/
|
|
219
|
+
export async function runLanguageAnalysis({ files = {}, adapters = [], timeoutMs = 0 } = {}) {
|
|
220
|
+
const findings = [];
|
|
221
|
+
const outcomes = [];
|
|
222
|
+
const byFile = {};
|
|
223
|
+
const byAnalyzer = {};
|
|
224
|
+
const discoveredFiles = [];
|
|
225
|
+
const excludedFiles = [];
|
|
226
|
+
|
|
227
|
+
for (const a of adapters) byAnalyzer[a.id] = { language: a.language, ...emptyCounts() };
|
|
228
|
+
|
|
229
|
+
for (const rel of Object.keys(files).sort()) {
|
|
230
|
+
const lang = languageOf(rel);
|
|
231
|
+
if (!lang || !isLanguageSource(rel)) continue; // manifests and unrelated files are not analyzer inputs
|
|
232
|
+
if (isLanguageExcludedPath(rel)) {
|
|
233
|
+
excludedFiles.push(rel);
|
|
234
|
+
outcomes.push({ kind: 'excluded-path', file: rel, language: lang });
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
discoveredFiles.push(rel);
|
|
238
|
+
byFile[rel] = {};
|
|
239
|
+
const applicable = adapters.filter((a) => a.language === lang);
|
|
240
|
+
if (!applicable.length) {
|
|
241
|
+
outcomes.push({ kind: 'unsupported-language', file: rel, language: lang });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
for (const a of applicable) {
|
|
245
|
+
const counts = byAnalyzer[a.id];
|
|
246
|
+
counts.discovered += 1;
|
|
247
|
+
const setStatus = (status) => { byFile[rel][a.id] = status; counts[STATUS_COUNT_KEY[status]] += 1; };
|
|
248
|
+
|
|
249
|
+
if (!isRegisteredLanguageProducer(a.id)) {
|
|
250
|
+
// Unregistered producers never contribute findings; the file is unresolved, not clean.
|
|
251
|
+
setStatus('unresolved');
|
|
252
|
+
outcomes.push({ kind: 'unregistered-producer', file: rel, analyzer: a.id, language: lang });
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (typeof a.hasGrammar === 'function' && !a.hasGrammar()) {
|
|
256
|
+
setStatus('missing_grammar');
|
|
257
|
+
outcomes.push({ kind: 'missing-grammar', file: rel, analyzer: a.id, language: lang });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
let result;
|
|
261
|
+
try {
|
|
262
|
+
result = await withTimeout(a.analyze(rel, files[rel]), timeoutMs);
|
|
263
|
+
} catch (err) {
|
|
264
|
+
if (err && err.code === 'LANG_TIMEOUT') {
|
|
265
|
+
setStatus('timed_out');
|
|
266
|
+
outcomes.push({ kind: 'timeout', file: rel, analyzer: a.id, language: lang, detail: String(err.message) });
|
|
267
|
+
} else {
|
|
268
|
+
setStatus('failed');
|
|
269
|
+
outcomes.push({ kind: 'adapter-exception', file: rel, analyzer: a.id, language: lang, detail: String((err && err.message) || err) });
|
|
270
|
+
}
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
const producer = _producers.get(a.id);
|
|
274
|
+
for (const f of (result && result.findings) || []) {
|
|
275
|
+
findings.push(withLanguageMetadata(f, { language: lang, capability: producer.capability, producer: a.id }));
|
|
276
|
+
}
|
|
277
|
+
const unresolved = (result && result.unresolved) || [];
|
|
278
|
+
for (const u of unresolved) {
|
|
279
|
+
outcomes.push({ kind: 'unresolved-branch', file: rel, analyzer: a.id, language: lang, line: Number.isInteger(u.line) ? u.line : null, detail: u.reason || null });
|
|
280
|
+
}
|
|
281
|
+
// A file with an unresolved branch was read but is not fully understood.
|
|
282
|
+
setStatus(unresolved.length ? 'unresolved' : 'analyzed');
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return { findings, ledger: { byFile, byAnalyzer, discoveredFiles, excludedFiles }, outcomes };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Checks that ledger counts reconcile with the real inputs: every discovered
|
|
291
|
+
* file has exactly one status per applicable analyzer, per-analyzer counts equal
|
|
292
|
+
* the sum of its statuses, and discovered + excluded equals the language
|
|
293
|
+
* sources actually present in `files`.
|
|
294
|
+
* @returns {{ok:boolean, errors:string[]}}
|
|
295
|
+
*/
|
|
296
|
+
export function reconcileLanguageLedger(ledger, files) {
|
|
297
|
+
const errors = [];
|
|
298
|
+
const real = Object.keys(files).filter((k) => isLanguageSource(k));
|
|
299
|
+
const accounted = new Set([...ledger.discoveredFiles, ...ledger.excludedFiles]);
|
|
300
|
+
if (accounted.size !== ledger.discoveredFiles.length + ledger.excludedFiles.length) errors.push('a file is both discovered and excluded');
|
|
301
|
+
for (const k of real) if (!accounted.has(k)) errors.push(`input file "${k}" is not accounted for`);
|
|
302
|
+
for (const k of accounted) if (!real.includes(k)) errors.push(`ledger file "${k}" is not a real input`);
|
|
303
|
+
|
|
304
|
+
for (const [id, c] of Object.entries(ledger.byAnalyzer)) {
|
|
305
|
+
const sum = c.analyzed + c.excluded + c.unresolved + c.failed + c.timedOut + c.missingGrammar;
|
|
306
|
+
if (sum !== c.discovered) errors.push(`analyzer "${id}": statuses sum to ${sum}, discovered ${c.discovered}`);
|
|
307
|
+
let fromFiles = 0;
|
|
308
|
+
for (const row of Object.values(ledger.byFile)) if (row[id] !== undefined) fromFiles += 1;
|
|
309
|
+
if (fromFiles !== c.discovered) errors.push(`analyzer "${id}": ${fromFiles} per-file rows, discovered ${c.discovered}`);
|
|
310
|
+
}
|
|
311
|
+
for (const [file, row] of Object.entries(ledger.byFile)) {
|
|
312
|
+
for (const [id, status] of Object.entries(row)) {
|
|
313
|
+
if (!FILE_STATUSES.includes(status)) errors.push(`file "${file}" analyzer "${id}": unknown status "${status}"`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { ok: errors.length === 0, errors };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Converts a run's ledger + outcomes into the shape computeScanHealth accepts as
|
|
321
|
+
* `languageCoverage`: totals plus human-readable conditions. Only an
|
|
322
|
+
* all-analyzed run with no unresolved branch is `complete`.
|
|
323
|
+
*/
|
|
324
|
+
export function languageHealth({ ledger, outcomes }) {
|
|
325
|
+
const totals = emptyCounts();
|
|
326
|
+
for (const c of Object.values(ledger.byAnalyzer)) {
|
|
327
|
+
for (const k of Object.keys(totals)) totals[k] += c[k];
|
|
328
|
+
}
|
|
329
|
+
totals.excluded += ledger.excludedFiles.length;
|
|
330
|
+
const byKind = {};
|
|
331
|
+
for (const o of outcomes) byKind[o.kind] = (byKind[o.kind] || 0) + 1;
|
|
332
|
+
const conditions = [];
|
|
333
|
+
if (byKind['missing-grammar']) conditions.push(`${byKind['missing-grammar']} language file(s) skipped: grammar unavailable`);
|
|
334
|
+
if (byKind['adapter-exception']) conditions.push(`${byKind['adapter-exception']} language adapter exception(s)`);
|
|
335
|
+
if (byKind['timeout']) conditions.push(`${byKind['timeout']} language file(s) timed out`);
|
|
336
|
+
if (byKind['unresolved-branch']) conditions.push(`${byKind['unresolved-branch']} unresolved language branch(es)`);
|
|
337
|
+
if (byKind['unregistered-producer']) conditions.push(`${byKind['unregistered-producer']} result(s) from an unregistered language producer were discarded`);
|
|
338
|
+
return { schemaVersion: LANGUAGE_CONTRACT_VERSION, totals, byKind, conditions };
|
|
339
|
+
}
|