@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.
Files changed (136) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/bin/agentic-security.js +123 -19
  3. package/dist/1444.index.js +64 -19
  4. package/dist/1905.index.js +46 -3
  5. package/dist/2862.index.js +325 -0
  6. package/dist/3499.index.js +5 -1
  7. package/dist/3531.index.js +261 -0
  8. package/dist/3596.index.js +103 -0
  9. package/dist/4113.index.js +15 -3
  10. package/dist/4171.index.js +319 -0
  11. package/dist/5637.index.js +10 -4
  12. package/dist/6359.index.js +433 -0
  13. package/dist/7838.index.js +15 -3
  14. package/dist/8551.index.js +1164 -0
  15. package/dist/8587.index.js +410 -0
  16. package/dist/8714.index.js +98 -0
  17. package/dist/8798.index.js +218 -0
  18. package/dist/9220.index.js +35 -3
  19. package/dist/9560.index.js +26 -3
  20. package/dist/agentic-security.mjs +51 -51
  21. package/dist/agentic-security.mjs.sha256 +1 -1
  22. package/package.json +18 -3
  23. package/src/dataflow/CLAUDE.md +9 -0
  24. package/src/dataflow/catalog-haskell.js +37 -0
  25. package/src/dataflow/catalog.js +4 -0
  26. package/src/dataflow/engine.js +33 -3
  27. package/src/dataflow/privacy-catalog-haskell.js +50 -0
  28. package/src/dataflow/privacy-catalog.js +5 -1
  29. package/src/discovery/CLAUDE.md +6 -0
  30. package/src/discovery/index.js +12 -3
  31. package/src/discovery/lenses.js +4 -1
  32. package/src/discovery/partition.js +28 -0
  33. package/src/egress/redact.js +8 -0
  34. package/src/engine.js +140 -19
  35. package/src/ir/index.js +39 -2
  36. package/src/language/CLAUDE.md +74 -0
  37. package/src/language/accuracy.js +90 -0
  38. package/src/language/aibom.js +307 -0
  39. package/src/language/assurance.js +193 -0
  40. package/src/language/bom-validate.js +227 -0
  41. package/src/language/bom.js +236 -0
  42. package/src/language/bridges.js +392 -0
  43. package/src/language/compliance-map.js +61 -0
  44. package/src/language/context.js +199 -0
  45. package/src/language/contracts.js +339 -0
  46. package/src/language/discovery.js +359 -0
  47. package/src/language/engine-pass.js +120 -0
  48. package/src/language/fix-lifecycle.js +120 -0
  49. package/src/language/haskell-adapter.js +51 -0
  50. package/src/language/haskell-disclosure.js +170 -0
  51. package/src/language/haskell-findings.js +44 -0
  52. package/src/language/haskell-fix.js +409 -0
  53. package/src/language/haskell-grammar.js +78 -0
  54. package/src/language/haskell-guards.js +382 -0
  55. package/src/language/haskell-ir.js +1377 -0
  56. package/src/language/haskell-llm.js +128 -0
  57. package/src/language/haskell-manifests.js +1290 -0
  58. package/src/language/haskell-models.js +278 -0
  59. package/src/language/haskell-parser.js +1073 -0
  60. package/src/language/haskell-resolved-graph.js +414 -0
  61. package/src/language/haskell-sca.js +387 -0
  62. package/src/language/haskell-security-rules.js +332 -0
  63. package/src/language/haskell-supply.js +140 -0
  64. package/src/language/haskell-sweep.js +85 -0
  65. package/src/language/haskell-syntax.js +895 -0
  66. package/src/language/haskell-web.js +749 -0
  67. package/src/language/nix-adapter.js +42 -0
  68. package/src/language/nix-agents.js +155 -0
  69. package/src/language/nix-build-trust.js +790 -0
  70. package/src/language/nix-closure.js +331 -0
  71. package/src/language/nix-eval-isolation.js +274 -0
  72. package/src/language/nix-fix.js +268 -0
  73. package/src/language/nix-grammar.js +97 -0
  74. package/src/language/nix-inventory.js +379 -0
  75. package/src/language/nix-ir.js +642 -0
  76. package/src/language/nix-parser.js +908 -0
  77. package/src/language/nix-privacy.js +114 -0
  78. package/src/language/nix-sca.js +358 -0
  79. package/src/language/nix-script-taint.js +885 -0
  80. package/src/language/nix-secrets.js +396 -0
  81. package/src/language/nixos-hardening.js +564 -0
  82. package/src/language/nixos-module-resolver.js +595 -0
  83. package/src/language/nixos-option-catalog.js +96 -0
  84. package/src/language/pragma.js +150 -0
  85. package/src/language/resolved-pass.js +146 -0
  86. package/src/language/secrets.js +323 -0
  87. package/src/language/state-artifacts.js +78 -0
  88. package/src/language/support-registry.js +224 -0
  89. package/src/language/witness.js +185 -0
  90. package/src/lineage/CLAUDE.md +8 -0
  91. package/src/lineage/coverage.js +2 -0
  92. package/src/lineage/cross-repo-link.js +22 -0
  93. package/src/lineage/graph-builder.js +5 -1
  94. package/src/lineage/haskell-view.js +319 -0
  95. package/src/lineage/index.js +8 -1
  96. package/src/lineage/nix-view.js +198 -0
  97. package/src/lineage/sink-registry.js +10 -1
  98. package/src/lineage/source-registry.js +3 -0
  99. package/src/lineage/transform-catalog.js +12 -0
  100. package/src/llm-validator/index.js +16 -2
  101. package/src/lsp/server.js +69 -9
  102. package/src/mcp/CLAUDE.md +7 -0
  103. package/src/mcp/tools.js +26 -3
  104. package/src/pipeline/coverage-ledger.js +0 -0
  105. package/src/pipeline/scan-health.js +19 -1
  106. package/src/posture/accuracy-scorecard.js +51 -0
  107. package/src/posture/aibom.js +61 -3
  108. package/src/posture/artifact-registry.js +12 -0
  109. package/src/posture/auditor-walkthrough.js +41 -0
  110. package/src/posture/clustering.js +2 -2
  111. package/src/posture/evidence-bundle.js +7 -1
  112. package/src/posture/execution-proof.js +15 -0
  113. package/src/posture/falsification.js +7 -0
  114. package/src/posture/family-resolve.js +7 -0
  115. package/src/posture/fix-history.js +40 -0
  116. package/src/posture/fix-verify.js +15 -3
  117. package/src/posture/provenance/git-evidence.js +24 -0
  118. package/src/posture/provenance/origin-resolver.js +14 -2
  119. package/src/posture/provenance/predicate-replay.js +11 -2
  120. package/src/posture/provenance/sca-origin.js +20 -3
  121. package/src/posture/sbom-diff.js +7 -6
  122. package/src/posture/sbom.js +85 -32
  123. package/src/posture/sca-policy.js +10 -1
  124. package/src/posture/sca-verdict.js +8 -1
  125. package/src/posture/secret-history.js +62 -18
  126. package/src/posture/stable-id.js +21 -2
  127. package/src/posture/state-dir.js +21 -0
  128. package/src/posture/threat-model-auto.js +4 -4
  129. package/src/posture/triage.js +18 -1
  130. package/src/posture/watch-mode.js +34 -3
  131. package/src/pr-delta.js +9 -4
  132. package/src/report/index.js +35 -9
  133. package/src/runScan.js +56 -5
  134. package/src/sast/code-injection-multilang.js +15 -0
  135. package/src/sast/go-extended.js +11 -0
  136. package/src/sast/pipeline.js +34 -1
@@ -0,0 +1,307 @@
1
+ // Haskell and Nix AI-BOM extraction (X-002).
2
+ //
3
+ // Inventory, not behaviour: every entry says HOW it is known and no more.
4
+ // declared a dependency or option names it (a manifest, services.x.enable = true)
5
+ // installed a package selector puts it on a system/user profile
6
+ // enabled a NixOS service is enabled in the EFFECTIVE configuration (priority and conditions applied)
7
+ // invoked source shows a call, an endpoint literal or a model literal in code
8
+ // A model CALL and any egress of sensitive data need their own usage/flow evidence: an installed SDK or an
9
+ // enabled inference service proves neither, and each entry carries `egress: {status:'not-established'}` to
10
+ // say so. A model, endpoint or provider that source does not spell out is `unresolved`, never guessed.
11
+ //
12
+ // Secrets never enter the BOM: header values are not captured at all, URL userinfo and credential-looking
13
+ // query values are replaced, and prompt bodies are hashed, not stored. Nothing here contacts a provider.
14
+
15
+ import { createHash } from 'node:crypto';
16
+ import { tokenizeHaskell, stripUrlCredentials } from './secrets.js';
17
+ import { buildHaskellIR } from './haskell-ir.js';
18
+ import { hackageComponents } from './haskell-supply.js';
19
+ import { resolveNixosConfig } from './nixos-module-resolver.js';
20
+ import { analyzeNix } from './nix-ir.js';
21
+ import { parseNix } from './nix-parser.js';
22
+ import { analyzeNixInputs } from './nix-inventory.js';
23
+
24
+ export const LANGUAGE_AIBOM_VERSION = 'language-aibom/1';
25
+
26
+ // ── curated registries (names are curated, not verified against a registry during a scan) ──
27
+ export const HS_AI_PACKAGES = Object.freeze({
28
+ 'openai-hs': { provider: 'openai', kind: 'sdk', modules: ['OpenAI.Client', 'OpenAI.Resources', 'OpenAI.Api'] },
29
+ 'openai-servant': { provider: 'openai', kind: 'sdk', modules: ['OpenAI'] },
30
+ 'openai': { provider: 'openai', kind: 'sdk', modules: ['OpenAI'] },
31
+ 'ollama-haskell': { provider: 'ollama', kind: 'sdk', modules: ['Data.Ollama', 'Ollama'] },
32
+ 'langchain-hs': { provider: null, kind: 'framework', modules: ['Langchain'] },
33
+ 'hnswlib': { provider: null, kind: 'vector-store', modules: ['HNSW', 'Data.HNSW'] },
34
+ 'pgvector': { provider: 'pgvector', kind: 'vector-store', modules: ['Database.PostgreSQL.Simple.Vector', 'PgVector'] },
35
+ 'qdrant-client': { provider: 'qdrant', kind: 'vector-store', modules: ['Qdrant'] },
36
+ });
37
+ const PROVIDER_HOSTS = [
38
+ [/^api\.openai\.com$/i, 'openai'], [/^[\w-]+\.openai\.azure\.com$/i, 'azure-openai'], [/^api\.anthropic\.com$/i, 'anthropic'],
39
+ [/^generativelanguage\.googleapis\.com$/i, 'google'], [/^api\.mistral\.ai$/i, 'mistral'], [/^api\.cohere\.(?:ai|com)$/i, 'cohere'],
40
+ [/^api\.groq\.com$/i, 'groq'], [/^openrouter\.ai$/i, 'openrouter'], [/^api\.together\.xyz$/i, 'together'], [/^api\.deepseek\.com$/i, 'deepseek'],
41
+ [/^bedrock-runtime\.[\w-]+\.amazonaws\.com$/i, 'bedrock'], [/^(?:localhost|127\.0\.0\.1|\[::1\]):11434$/i, 'ollama'], [/^api-inference\.huggingface\.co$/i, 'huggingface'], [/^api\.replicate\.com$/i, 'replicate'],
42
+ ];
43
+ const PATH_KINDS = [[/\/embeddings?\b/, 'embedding'], [/\/chat\/completions|\/messages|\/api\/chat|:generateContent|\/converse/, 'chat'], [/\/completions\b|\/api\/generate/, 'completion']];
44
+ const MODEL_PROVIDER = [[/^(?:gpt-|o[134](?:-|$)|text-embedding-|chatgpt-|dall-e|whisper-|davinci|babbage)/i, 'openai'], [/^claude-/i, 'anthropic'], [/^gemini-|^text-embedding-00/i, 'google'], [/^(?:mistral|mixtral|codestral|ministral)/i, 'mistral'], [/^command(?:-|$)|^embed-/i, 'cohere']];
45
+ const OPEN_WEIGHTS = /^(?:llama|qwen|phi|gemma|deepseek|mixtral|mistral|smollm|tinyllama|nomic-embed|mxbai|codellama|starcoder)[\w.:-]*$/i;
46
+ const SENSITIVE_QUERY = /^(?:key|api[_-]?key|token|access[_-]?token|secret|password|pwd|auth|signature|sig|code)$/i;
47
+
48
+ const sha16 = (s) => createHash('sha256').update(String(s)).digest('hex').slice(0, 16);
49
+ const lineAt = (text, idx) => { let l = 1; for (let i = 0; i < idx; i++) if (text.charCodeAt(i) === 10) l++; return l; };
50
+
51
+ /** URL safe to store: no userinfo, no fragment, credential-looking query values replaced. */
52
+ export function redactEndpoint(u) {
53
+ let s = stripUrlCredentials(String(u));
54
+ s = s.replace(/#.*$/, '');
55
+ s = s.replace(/([?&;])([^=&;#]+)=([^&;#]*)/g, (m, sep, k, v) => (SENSITIVE_QUERY.test(decodeURIComponent(k)) ? `${sep}${k}=REDACTED` : m));
56
+ s = s.replace(/\/(?:key|token|apikey|api-key)\/[^/?#]+/gi, '/$1/REDACTED');
57
+ return s;
58
+ }
59
+ const providerOfHost = (host) => { for (const [re, p] of PROVIDER_HOSTS) if (re.test(host)) return p; return null; };
60
+ const providerOfModel = (id) => { for (const [re, p] of MODEL_PROVIDER) if (re.test(id)) return p; return OPEN_WEIGHTS.test(id) ? 'open-weights' : null; };
61
+ const isModelId = (v) => /^[A-Za-z0-9][\w.:/@-]{1,100}$/.test(v) && /[A-Za-z]/.test(v) && (providerOfModel(v) !== null || /^[\w.-]+\/[\w.-]+$/.test(v));
62
+
63
+ const ident = (kind, o) => ({ ...o, componentClass: 'ai', kind, egress: { status: 'not-established', reason: 'an AI component being present does not show that any data is sent to it; that needs flow evidence' } });
64
+
65
+ function addEvidence(map, key, base, ev) {
66
+ if (!map.has(key)) map.set(key, { ...base, evidence: [] });
67
+ const e = map.get(key);
68
+ if (!e.evidence.some((x) => x.file === ev.file && x.line === ev.line && x.kind === ev.kind)) e.evidence.push(ev);
69
+ return e;
70
+ }
71
+
72
+ // ── Haskell ──────────────────────────────────────────────────────────────────
73
+ const HTTP_FNS = new Set(['parseRequest', 'parseRequest_', 'parseUrlThrow', 'parseUrlThrow_', 'parseUrl']); // functions whose first argument IS the URL
74
+
75
+ export function extractHaskellAI(files) {
76
+ const sources = Object.entries(files).filter(([p, t]) => /\.l?hs$/i.test(p) && typeof t === 'string');
77
+ const models = new Map(); const endpoints = new Map(); const prompts = []; const vector = new Map(); const embeddings = new Map();
78
+ const frameworks = new Map(); const unresolved = []; const gaps = [];
79
+ const comps = hackageComponents(files).components;
80
+ const declared = new Map(comps.filter((c) => HS_AI_PACKAGES[c.name]).map((c) => [c.name, c]));
81
+ let ir = null;
82
+ try { ir = buildHaskellIR(Object.fromEntries(sources)); } catch (e) { gaps.push({ kind: 'haskell-ir-failed', detail: e.message }); }
83
+ const importsOf = (f) => (ir && ir.perFile ? (Object.values(ir.perFile).find((x) => x.file === f) || {}).imports || [] : []);
84
+
85
+ for (const [file, text] of sources) {
86
+ const toks = tokenizeHaskell(text);
87
+ const imports = importsOf(file);
88
+ // a module may belong to several package names (OpenAI.Client is openai-hs, `OpenAI` is a prefix of others):
89
+ // exact module matches beat prefix matches, and a declared package beats an undeclared one
90
+ const scored = Object.entries(HS_AI_PACKAGES).map(([pkg, d]) => {
91
+ let score = 0;
92
+ for (const i of imports) for (const m of d.modules) { if (i.module === m) score = Math.max(score, 3); else if (i.module.startsWith(`${m}.`)) score = Math.max(score, 2); }
93
+ if (score && declared.has(pkg)) score += 1;
94
+ return [pkg, d, score];
95
+ }).filter(([, , sc]) => sc > 0);
96
+ const best = scored.reduce((a, [, , sc]) => Math.max(a, sc), 0);
97
+ const sdkHit = scored.filter(([, , sc]) => sc === best).map(([pkg, d]) => [pkg, d]);
98
+ const aiContext = sdkHit.length > 0 || toks.some((t) => t.k === 's' && /^(?:model|messages|prompt|temperature|max_tokens|embedding)$/.test(t.v));
99
+ for (const [pkg, d] of sdkHit) {
100
+ const first = imports.find((i) => d.modules.some((m) => i.module === m || i.module.startsWith(`${m}.`)));
101
+ const ev = { file, line: first.line || 1, kind: 'import', detail: first.module };
102
+ const bucket = d.kind === 'vector-store' ? vector : frameworks;
103
+ const c = declared.get(pkg);
104
+ addEvidence(bucket, `${pkg}`, ident(d.kind === 'vector-store' ? 'vector-store' : 'inference-framework', { ecosystem: 'hackage', name: pkg, version: c ? c.version : null, versionResolved: !!(c && c.version), provider: d.provider, status: 'invoked', usage: 'imported' }), ev);
105
+ }
106
+ const providerHints = new Set(sdkHit.map(([, d]) => d.provider).filter(Boolean));
107
+ let fileProvider = null; let fileEndpoint = null;
108
+ // endpoints: URL literals on a known provider host
109
+ for (const t of toks) {
110
+ if (t.k !== 's') continue;
111
+ const m = /^(?:[A-Z]+\s+)?(https?:\/\/)([^/\s"']+)(\/[^\s"']*)?/.exec(t.v);
112
+ if (!m) continue;
113
+ const host = m[2].replace(/^[^@]*@/, ''); const path = m[3] || '';
114
+ const prov = providerOfHost(host);
115
+ if (!prov) continue;
116
+ const url = redactEndpoint(`${m[1]}${host}${path}`);
117
+ const kind = (PATH_KINDS.find(([re]) => re.test(path)) || [null, 'unknown'])[1];
118
+ fileProvider = fileProvider || prov; fileEndpoint = fileEndpoint || url;
119
+ addEvidence(endpoints, url, ident('endpoint', { url, host, provider: prov, purpose: kind, status: 'invoked', usage: 'endpoint-literal' }), { file, line: lineAt(text, t.start), kind: 'url-literal' });
120
+ if (kind === 'embedding') addEvidence(embeddings, `endpoint:${url}`, ident('embedding', { name: `${prov} embeddings endpoint`, provider: prov, endpoint: url, status: 'invoked', usage: 'endpoint-literal' }), { file, line: lineAt(text, t.start), kind: 'embeddings-path' });
121
+ }
122
+ // models: "model" key followed by a literal, or `model = "x"`
123
+ for (let i = 0; i < toks.length; i++) {
124
+ const t = toks[i];
125
+ const isKey = (t.k === 's' && t.v === 'model') || (t.k === 'i' && /^(?:model|modelName|chatModel|embeddingModel)$/.test(t.v));
126
+ if (!isKey) continue;
127
+ const op = toks[i + 1];
128
+ if (!op || op.k !== 'o' || !/^(?:\.=|:|=|=\s*)$/.test(op.v)) continue;
129
+ // skip an optional function application: `T.pack "x"`, `ModelId "x"`, `(` ...
130
+ let j = i + 2; while (toks[j] && (toks[j].k === 'x' && toks[j].v === '(' || toks[j].k === 'i')) j++;
131
+ const val = toks[j];
132
+ const line = lineAt(text, t.start);
133
+ if (val && val.k === 's' && isModelId(val.v)) {
134
+ const prov = providerOfModel(val.v) || fileProvider || [...providerHints][0] || 'unknown';
135
+ const key = `${prov}:${val.v}`;
136
+ addEvidence(models, key, ident('model', { type: 'model', provider: prov, modelId: val.v, revision: null, pinned: false, pinnedBy: null, status: 'invoked', usage: 'model-literal', providerBasis: providerOfModel(val.v) ? 'model-id prefix' : (fileProvider ? 'endpoint in the same file' : (providerHints.size ? 'imported SDK' : 'none')) }), { file, line, kind: 'model-literal' });
137
+ if (/embed/i.test(val.v)) addEvidence(embeddings, `model:${key}`, ident('embedding', { name: val.v, provider: prov, status: 'invoked', usage: 'model-literal' }), { file, line, kind: 'embedding-model' });
138
+ } else if (aiContext && t.k === 's' && val && !(val.k === 's')) {
139
+ unresolved.push({ kind: 'model', file, line, reason: 'the model identifier is not a literal (a variable, a record field or a configuration value)', provider: fileProvider || [...providerHints][0] || null });
140
+ }
141
+ }
142
+ // dynamic endpoints: an HTTP request builder fed something that is not a URL literal, in an AI-looking file
143
+ if (aiContext) for (let i = 0; i < toks.length; i++) {
144
+ const t = toks[i];
145
+ if (t.k !== 'i' || !HTTP_FNS.has(t.v)) continue;
146
+ let j = i + 1; while (toks[j] && toks[j].k === 'x' && toks[j].v === '(') j++;
147
+ const arg = toks[j];
148
+ if (arg && arg.k === 'i' && !HTTP_FNS.has(arg.v) && !/^(?:def|opts|defaults|manager|mgr)$/.test(arg.v)) unresolved.push({ kind: 'endpoint', file, line: lineAt(text, t.start), reason: `${t.v} is given ${arg.v}, not a URL literal: the endpoint cannot be read statically`, provider: fileProvider || [...providerHints][0] || null });
149
+ }
150
+ // prompt templates: long literals bound to a prompt-like name, or with template placeholders
151
+ for (let i = 0; i < toks.length; i++) {
152
+ const t = toks[i];
153
+ if (t.k !== 's' || t.v.length < 40) continue;
154
+ const prev = toks[i - 1]; const prev2 = toks[i - 2];
155
+ const named = prev && prev.k === 'o' && /^(?:=|<-)$/.test(prev.v) && prev2 && prev2.k === 'i' && /(?:prompt|instruction|template|systemMessage)/i.test(prev2.v);
156
+ const looks = /\{\{[^}]+\}\}|%s|\$\{\w+\}/.test(t.v) && /\b(?:you are|answer|assistant|respond|user|system)\b/i.test(t.v);
157
+ if (!(named || looks)) continue;
158
+ prompts.push({ type: 'prompt-template', file, line: lineAt(text, t.start), bytes: Buffer.byteLength(t.v), sha256_16: sha16(t.v), lines: t.v.split('\\n').length, source: 'inline-literal', name: named ? prev2.v : null, placeholders: (t.v.match(/\{\{[^}]+\}\}|%s|\$\{\w+\}/g) || []).length });
159
+ }
160
+ // pgvector in SQL text
161
+ for (const t of toks) if (t.k === 's' && /(?:<->|<=>|<#>)|\bvector\s*\(\s*\d+\s*\)|CREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?vector/i.test(t.v)) addEvidence(vector, 'pgvector (SQL)', ident('vector-store', { ecosystem: null, name: 'pgvector', provider: 'pgvector', status: 'invoked', usage: 'sql-literal' }), { file, line: lineAt(text, t.start), kind: 'sql-vector-operator' });
162
+ }
163
+ // declared-only AI packages (no import found): present in a manifest, nothing more
164
+ for (const [pkg, c] of declared) {
165
+ const d = HS_AI_PACKAGES[pkg];
166
+ const bucket = d.kind === 'vector-store' ? vector : frameworks;
167
+ if (!bucket.has(pkg)) bucket.set(pkg, { ...ident(d.kind === 'vector-store' ? 'vector-store' : 'inference-framework', { ecosystem: 'hackage', name: pkg, version: c.version, versionResolved: !!c.version, declaredRange: c.declaredRange, provider: d.provider, status: 'declared', usage: 'none-found' }), evidence: [{ file: c.manifest, line: c.line, kind: 'build-depends' }] });
168
+ else { const e = bucket.get(pkg); e.declaredIn = { file: c.manifest, line: c.line }; e.evidence.push({ file: c.manifest, line: c.line, kind: 'build-depends' }); }
169
+ }
170
+ const arr = (m) => [...m.values()];
171
+ return { models: arr(models), endpoints: arr(endpoints), frameworks: arr(frameworks), vectorStores: arr(vector), embeddings: arr(embeddings), promptTemplates: prompts, unresolved, gaps, services: [] };
172
+ }
173
+
174
+ // ── Nix ──────────────────────────────────────────────────────────────────────
175
+ export const NIX_AI_SERVICES = Object.freeze({
176
+ 'services.ollama': { name: 'ollama', kind: 'inference-server', provider: 'ollama', defaultPort: 11434, modelsOption: 'loadModels', packages: ['ollama', 'ollama-cuda', 'ollama-rocm'] },
177
+ 'services.open-webui': { name: 'open-webui', kind: 'chat-ui', provider: 'open-webui', defaultPort: 8080, packages: ['open-webui'] },
178
+ 'services.llama-cpp': { name: 'llama-cpp', kind: 'inference-server', provider: 'llama.cpp', defaultPort: 8080, modelOption: 'model', packages: ['llama-cpp'] },
179
+ 'services.tabby': { name: 'tabby', kind: 'inference-server', provider: 'tabby', defaultPort: 8080, modelOption: 'model', packages: ['tabby'] },
180
+ 'services.nextjs-ollama-llm-ui': { name: 'nextjs-ollama-llm-ui', kind: 'chat-ui', provider: 'ollama', defaultPort: 3000, packages: [] },
181
+ 'services.litellm': { name: 'litellm', kind: 'llm-proxy', provider: 'litellm', defaultPort: 4000, packages: ['litellm'] },
182
+ });
183
+ const NIX_AI_PACKAGES = new Set(['ollama', 'ollama-cuda', 'ollama-rocm', 'llama-cpp', 'whisper-cpp', 'open-webui', 'tabby', 'litellm', 'aider-chat', 'python3Packages.openai', 'python3Packages.anthropic', 'python3Packages.transformers', 'python3Packages.langchain', 'python3Packages.torch', 'python312Packages.openai', 'python312Packages.transformers', 'python312Packages.torch']);
184
+ const OCI_AI = [[/(?:^|\/)ollama\/ollama/i, 'ollama'], [/vllm\/vllm-openai|vllm/i, 'vllm'], [/ggerganov\/llama\.cpp|llama\.cpp/i, 'llama.cpp'], [/huggingface\/text-generation-inference/i, 'tgi'], [/open-webui/i, 'open-webui']];
185
+ const WEIGHTS = /\.(?:gguf|safetensors|ggml|onnx|bin|pt|pth)(?:$|\?)/i;
186
+
187
+ const spanLine = (s) => (s && s.startLine) || (s && s.line) || null;
188
+
189
+ export function extractNixAI(files) {
190
+ const nix = Object.fromEntries(Object.entries(files).filter(([p, t]) => /\.nix$/i.test(p) && typeof t === 'string'));
191
+ const services = []; const models = new Map(); const frameworks = new Map(); const unresolved = []; const gaps = []; const links = [];
192
+ if (!Object.keys(nix).length) return { services, models: [], frameworks: [], vectorStores: [], embeddings: [], endpoints: [], promptTemplates: [], unresolved, gaps, links };
193
+ // the NixOS entry point: a configuration.nix (shortest path wins), else the file no other file imports by name
194
+ const names = Object.keys(nix).sort((a, b) => a.length - b.length || a.localeCompare(b));
195
+ const base = (p) => p.replace(/^.*\//, '');
196
+ const importedBy = (f) => names.some((o) => o !== f && new RegExp(`\\./?(?:[\\w./-]*/)?${base(f).replace(/\./g, '\\.')}\\b`).test(nix[o]));
197
+ const entry = names.find((p) => base(p) === 'configuration.nix') || names.find((p) => !importedBy(p)) || names[0];
198
+ let cfg = null;
199
+ try { cfg = resolveNixosConfig({ entry, files: nix }); } catch (e) { gaps.push({ kind: 'nixos-resolution-failed', detail: e.message }); }
200
+ const lookup = (p) => (cfg ? cfg.lookup(p) : null);
201
+ const srcLine = (r) => { const w = r && (r.sources || []).find((s) => s.role === 'winner') || (r && r.sources && r.sources[0]); return w ? { file: w.file, line: spanLine(w.span) } : { file: null, line: null }; };
202
+ const stateOf = (r) => {
203
+ if (!r || !(r.sources || []).length) return 'unknown';
204
+ if (r.status === 'conditional') return 'conditional';
205
+ if (r.status !== 'set') return 'unknown';
206
+ const win = r.sources.find((s) => s.role === 'winner') || r.sources[0];
207
+ if (r.value === false) return 'disabled';
208
+ if (r.value === true) return (win.conditions && win.conditions.length) || (r.caveats && r.caveats.length) || !r.definite ? 'conditional' : 'enabled';
209
+ return 'unknown';
210
+ };
211
+ const byPrefix = new Map();
212
+ for (const [prefix, d] of Object.entries(NIX_AI_SERVICES)) {
213
+ const en = lookup(`${prefix}.enable`);
214
+ if (!en || !(en.sources || []).length) continue;
215
+ const st = stateOf(en); const where = srcLine(en);
216
+ const port = lookup(`${prefix}.port`); const host = lookup(`${prefix}.host`);
217
+ const svc = ident('service', { name: d.name, kind: d.kind, provider: d.provider, option: `${prefix}.enable`, status: st === 'enabled' ? 'enabled' : st, listen: { host: host && host.value !== undefined ? host.value : null, port: port && port.value !== undefined ? port.value : null, defaultPort: d.defaultPort, hostKnown: !!(host && host.value !== undefined), portKnown: !!(port && port.value !== undefined) }, evidence: [{ file: where.file, line: where.line, kind: 'nixos-option' }], precedence: { winnerPriority: en.precedence && en.precedence.winnerPriority, conditions: ((en.sources || [])[0] || {}).conditions || [] } });
218
+ services.push(svc); byPrefix.set(prefix, svc);
219
+ if (st === 'disabled') continue;
220
+ if (d.modelsOption) {
221
+ const lm = lookup(`${prefix}.${d.modelsOption}`);
222
+ if (lm && lm.status === 'set') {
223
+ const w = srcLine(lm);
224
+ const list = Array.isArray(lm.value) ? lm.value : null;
225
+ if (!list) unresolved.push({ kind: 'model', file: w.file, line: w.line, reason: `${prefix}.${d.modelsOption} is not a literal list`, provider: d.provider });
226
+ for (const id of list || []) {
227
+ if (typeof id !== 'string') { unresolved.push({ kind: 'model', file: w.file, line: w.line, reason: 'a model entry is not a literal string', provider: d.provider }); continue; }
228
+ const digest = /@sha256:[0-9a-f]{64}$/.test(id);
229
+ addEvidence(models, `${d.provider}:${id}`, ident('model', { type: 'model', provider: d.provider, modelId: id, revision: digest ? id.split('@')[1] : null, pinned: digest, pinnedBy: digest ? 'digest' : null, status: st === 'enabled' ? 'enabled' : st, usage: 'service-config', service: d.name }), { file: w.file, line: w.line, kind: `${prefix}.${d.modelsOption}` });
230
+ }
231
+ }
232
+ }
233
+ if (d.modelOption) {
234
+ const mo = lookup(`${prefix}.${d.modelOption}`);
235
+ if (mo && mo.status === 'set') {
236
+ const w = srcLine(mo);
237
+ if (typeof mo.value === 'string') {
238
+ const id = mo.value.replace(/^.*\//, '');
239
+ addEvidence(models, `${d.provider}:${mo.value}`, ident('model', { type: 'model', provider: d.provider, modelId: id, artifactPath: mo.value, revision: null, pinned: false, pinnedBy: null, status: st === 'enabled' ? 'enabled' : st, usage: 'service-config', service: d.name, note: 'a path or name: nothing pins the content' }), { file: w.file, line: w.line, kind: `${prefix}.${d.modelOption}` });
240
+ } else unresolved.push({ kind: 'model', file: w.file, line: w.line, reason: `${prefix}.${d.modelOption} is not a literal string (a derivation or an expression)`, provider: d.provider });
241
+ }
242
+ }
243
+ }
244
+ // open-webui -> ollama is a link ONLY when the configuration says so (a literal base URL naming the same host/port)
245
+ const owu = byPrefix.get('services.open-webui'); const oll = byPrefix.get('services.ollama');
246
+ if (owu && oll) {
247
+ const env = lookup('services.open-webui.environment.OLLAMA_BASE_URL') || lookup('services.open-webui.environment');
248
+ const val = env && env.status === 'set' ? (typeof env.value === 'string' ? env.value : (env.value && env.value.OLLAMA_BASE_URL)) : null;
249
+ const port = oll.listen.port ?? oll.listen.defaultPort;
250
+ if (typeof val === 'string' && new RegExp(`(?:localhost|127\\.0\\.0\\.1|\\[::1\\]):${port}\\b`).test(val)) links.push({ from: 'open-webui', to: 'ollama', kind: 'configured-backend', evidence: { option: 'services.open-webui.environment.OLLAMA_BASE_URL', value: redactEndpoint(val) }, flow: 'configuration-only: no data flow is claimed' });
251
+ else links.push({ from: 'open-webui', to: 'ollama', kind: 'both-enabled', evidence: null, flow: 'not established: two enabled services are not a link' });
252
+ }
253
+ // installed packages (selectors on a profile)
254
+ let inv = null;
255
+ try { inv = analyzeNixInputs({ files: nix }); } catch { inv = null; }
256
+ for (const s of (inv && inv.selectors) || []) {
257
+ if (!NIX_AI_PACKAGES.has(s.attr)) continue;
258
+ addEvidence(frameworks, `nix:${s.attr}`, ident('inference-framework', { ecosystem: 'nix', name: s.attr, version: null, versionResolved: false, status: 'installed', usage: s.role === 'build' ? 'build-input' : `${s.role}-profile`, provider: null }), { file: s.file, line: s.line, kind: 'package-selector' });
259
+ }
260
+ // OCI containers and fetched weights: walk the AST
261
+ for (const [file, text] of Object.entries(nix)) {
262
+ const parse = parseNix(text, { file }); if (!parse.ast) continue;
263
+ const stack = [parse.ast];
264
+ while (stack.length) {
265
+ const n = stack.pop(); if (!n || typeof n !== 'object') continue;
266
+ if (n.type === 'attrset') {
267
+ for (const b of n.bindings || []) {
268
+ if (b.kind !== 'attr' || !b.value) continue;
269
+ const key = (b.path || []).map((s) => (s && s.kind === 'static' ? s.name : null)).join('.');
270
+ const v = b.value && b.value.type === 'paren' ? b.value.expr : b.value;
271
+ if (/(?:^|\.)image$/.test(key) && v && v.type === 'string' && !v.interpolated && v.literal) { const hit = OCI_AI.find(([re]) => re.test(v.literal)); if (hit) addEvidence(frameworks, `oci:${v.literal}`, ident('inference-framework', { ecosystem: 'oci', name: v.literal, provider: hit[1], status: 'declared', usage: 'container-declaration', imageLayerScan: 'not-performed' }), { file, line: spanLine(b.span), kind: 'oci-container-image' }); }
272
+ }
273
+ }
274
+ if (n.type === 'app') {
275
+ let f = n; const args = []; while (f && f.type === 'app') { args.unshift(f.arg); f = f.fn && f.fn.type === 'paren' ? f.fn.expr : f.fn; }
276
+ const name = f && f.type === 'ident' ? f.name : (f && f.type === 'select' ? (f.attrpath[f.attrpath.length - 1] || {}).name : null);
277
+ if (name === 'fetchurl' || name === 'fetchFromHuggingFace') {
278
+ const a = args[0] && (args[0].type === 'paren' ? args[0].expr : args[0]);
279
+ let url = null; let hash = null;
280
+ if (a && a.type === 'attrset') for (const b of a.bindings) { if (b.kind !== 'attr') continue; const k = (b.path || []).map((s) => s && s.name).join('.'); const val = b.value && b.value.type === 'paren' ? b.value.expr : b.value; if (k === 'url' && val && val.literal) url = val.literal; if (/^(?:sha256|hash)$/.test(k) && val && val.literal) hash = val.literal; if (k === 'url' && val && !val.literal) unresolved.push({ kind: 'model', file, line: spanLine(n.span), reason: 'a fetched model URL is built by an expression', provider: 'huggingface' }); }
281
+ if (url && (WEIGHTS.test(url) || /huggingface\.co/.test(url))) {
282
+ const m = /huggingface\.co\/([^/]+\/[^/]+)\/resolve\/([^/]+)\/([^?]+)/.exec(url);
283
+ const id = m ? `${m[1]}/${m[3]}` : url.replace(/^.*\//, '').replace(/\?.*$/, '');
284
+ const rev = m ? m[2] : null; const revPinned = !!(rev && /^[0-9a-f]{40}$/.test(rev));
285
+ addEvidence(models, `huggingface:${id}`, ident('model', { type: 'model', provider: 'huggingface', modelId: id, revision: rev, pinned: !!hash, pinnedBy: hash ? (revPinned ? 'content-hash+revision' : 'content-hash') : null, refPinned: revPinned, source: redactEndpoint(url), status: 'declared', usage: 'fetched-weights', note: hash ? 'the content hash pins the bytes' : 'no hash: the fetched bytes can change' }), { file, line: spanLine(n.span), kind: 'fetchurl' });
286
+ }
287
+ }
288
+ }
289
+ for (const k of Object.keys(n)) { const c = n[k]; if (c && typeof c === 'object' && k !== 'span') { if (Array.isArray(c)) stack.push(...c); else stack.push(c); } }
290
+ }
291
+ }
292
+ return { services, models: [...models.values()], frameworks: [...frameworks.values()], vectorStores: [], embeddings: [], endpoints: [], promptTemplates: [], unresolved, gaps, links };
293
+ }
294
+
295
+ /** Both languages, merged by identity with evidence kept (never one entry per mention). */
296
+ export function extractLanguageAI(files) {
297
+ const hs = extractHaskellAI(files); const nx = extractNixAI(files);
298
+ const merge = (a, b, keyFn) => { const m = new Map(); for (const x of [...a, ...b]) { const k = keyFn(x); if (m.has(k)) { const e = m.get(k); e.evidence = [...(e.evidence || []), ...(x.evidence || [])]; } else m.set(k, { ...x }); } return [...m.values()]; };
299
+ return {
300
+ models: merge(hs.models, nx.models, (x) => `${x.provider}:${x.modelId}`),
301
+ endpoints: hs.endpoints,
302
+ services: nx.services,
303
+ frameworks: merge(hs.frameworks, nx.frameworks, (x) => `${x.ecosystem}:${x.name}`),
304
+ vectorStores: hs.vectorStores, embeddings: hs.embeddings, promptTemplates: hs.promptTemplates,
305
+ unresolved: [...hs.unresolved, ...nx.unresolved], links: nx.links, gaps: [...hs.gaps, ...nx.gaps],
306
+ };
307
+ }
@@ -0,0 +1,193 @@
1
+ // Scan health and assurance for Haskell and Nix analysis (X-007).
2
+ //
3
+ // Collects every way language analysis can be incomplete into ONE `languageCoverage` input for computeScanHealth,
4
+ // so the existing advisory / standard / strict policy and the "Safe to deploy" verdict apply unchanged:
5
+ //
6
+ // required capabilities parser, SAST, taint, SCA, secrets (Haskell); parser, config, taint, secrets, supply (Nix).
7
+ // A required capability that is disabled, unavailable or failed is a CONDITION.
8
+ // expected static limits an opaque boundary (Template Haskell, quasi-quotes, FFI, CPP, hsc) is a documented limit
9
+ // of a static analysis, not a failure. It is listed under `limitations`, never as a
10
+ // condition, and never hides the files it touches (they stay `unresolved` in the ledger).
11
+ // optional modes nix-eval, cabal-plan, hackage-live. Their state is separate: `not_selected` (no
12
+ // condition), `not_run` (selected but absent: a condition, never reported as run), `failed`
13
+ // (a condition; static findings are retained untouched), `ok`.
14
+ // data currency a stale or missing advisory snapshot, a malformed manifest, a partial effective NixOS
15
+ // configuration.
16
+ //
17
+ // Independent findings are never dropped here: this module only produces health inputs and returns the findings it
18
+ // was given by reference, with a count, so a caller can prove retention.
19
+
20
+ import { runLanguageAnalysis, reconcileLanguageLedger, languageHealth } from './contracts.js';
21
+ import { createHaskellAdapter } from './haskell-adapter.js';
22
+ import { createNixAdapter } from './nix-adapter.js';
23
+ import { analyzeHaskellManifests } from './haskell-manifests.js';
24
+ import { packageOfModule } from './haskell-models.js';
25
+
26
+ export const ASSURANCE_VERSION = 'language-assurance/1';
27
+
28
+ export const REQUIRED_CAPABILITIES = Object.freeze({
29
+ haskell: Object.freeze(['parser', 'sast', 'taint', 'sca', 'secrets']),
30
+ nix: Object.freeze(['parser', 'config', 'taint', 'secrets', 'supply']),
31
+ });
32
+ export const OPTIONAL_MODES = Object.freeze(['nix-eval', 'cabal-plan', 'hackage-live']);
33
+
34
+ const MALFORMED_MANIFEST = new Set(['malformed_manifest', 'malformed_yaml', 'parser_failure', 'read_failed', 'malformed_dependency', 'malformed_lock_entry', 'malformed_constraint', 'malformed_extra_dep', 'invalid_version_range', 'empty_manifest']);
35
+ const LANGS = Object.keys(REQUIRED_CAPABILITIES);
36
+ const langOf = (f) => (/\.l?hs$|\.hs-boot$|\.hsc$/i.test(f) ? 'haskell' : /\.nix$/i.test(f) ? 'nix' : null);
37
+ const isManifest = (f) => /(?:^|\/)(?:[^/]+\.cabal|cabal\.project(?:\.freeze|\.local)?|package\.yaml|stack\.yaml(?:\.lock)?)$/i.test(f);
38
+
39
+ function optionalState(name, cfg) {
40
+ const c = cfg && cfg[name];
41
+ if (!c || !c.selected) return { name, selected: false, status: 'not_selected', ran: false, reason: 'the mode is opt-in and was not selected', condition: null };
42
+ const r = c.result;
43
+ if (!r) return { name, selected: true, status: 'not_run', ran: false, reason: c.reason || 'selected, but no result was produced', condition: `optional mode "${name}" was selected but did not run` };
44
+ if (r.status === 'ok' && r.ran !== false) return { name, selected: true, status: 'ok', ran: true, reason: null, condition: null };
45
+ return { name, selected: true, status: r.status || 'failed', ran: false, reason: r.reason || null, condition: `optional mode "${name}" ${r.status === 'timed_out' ? 'timed out' : 'failed'}${r.reason ? ` (${r.reason})` : ''}; static results are retained` };
46
+ }
47
+
48
+ /**
49
+ * @param {object} input
50
+ * @param {Record<string,string>} input.files rel path -> content: sources and manifests
51
+ * @param {object[]} [input.adapters] override the default parse adapters (tests inject hung or corrupt ones)
52
+ * @param {number} [input.timeoutMs] per-file adapter deadline
53
+ * @param {string[]} [input.disabled] capability ids switched off, "<language>:<capability>"
54
+ * @param {Record<string,string[]>} [input.required] override of REQUIRED_CAPABILITIES
55
+ * @param {object|null} [input.advisoryDb] a Hackage AdvisoryDb (haskell-sca.js), or null when none loaded
56
+ * @param {object[]} [input.effectiveConfigs] NixOS effective-config reports (nixos-module-resolver.js)
57
+ * @param {Record<string,{selected:boolean, result?:object, reason?:string}>} [input.optional]
58
+ * @param {object[]} [input.findings] findings already produced; returned untouched
59
+ */
60
+ export async function assessLanguageAssurance(input = {}) {
61
+ const files = input.files || {};
62
+ const present = new Set(Object.keys(files).map(langOf).filter(Boolean));
63
+ const manifests = Object.entries(files).filter(([f]) => isManifest(f));
64
+ if (manifests.length) present.add('haskell');
65
+ const required = input.required || REQUIRED_CAPABILITIES;
66
+ const disabled = new Set(input.disabled || []);
67
+ const findings = Array.isArray(input.findings) ? input.findings : [];
68
+ const conditions = [];
69
+ const limitations = [];
70
+
71
+ // 1. parse adapters over every language source
72
+ // Boundaries that do not make a file unresolved but that a reader must be told about: CPP (every branch was parsed, the
73
+ // chosen one is not known) and FFI (the foreign code is not analysed).
74
+ const disclosed = new Map(); // boundary kind -> {count, files:Set}
75
+ const projectModules = new Set(); const importedModules = new Map(); // module -> files
76
+ const onParse = (file, parse) => {
77
+ if (parse && parse.module && parse.module.name) projectModules.add(parse.module.name);
78
+ for (const i of (parse && parse.imports) || []) if (i && i.module) { const e = importedModules.get(i.module) || new Set(); e.add(file); importedModules.set(i.module, e); }
79
+ for (const b of (parse && parse.boundaries) || []) if (b.kind === 'cpp' || b.kind === 'ffi') { const e = disclosed.get(b.kind) || { count: 0, files: new Set() }; e.count += b.count || 1; e.files.add(file); disclosed.set(b.kind, e); }
80
+ };
81
+ const adapters = input.adapters || [createHaskellAdapter({ onParse }), createNixAdapter()];
82
+ const run = await runLanguageAnalysis({ files, adapters, timeoutMs: input.timeoutMs || 0 });
83
+ const reconcile = reconcileLanguageLedger(run.ledger, files);
84
+ const base = languageHealth({ ledger: run.ledger, outcomes: run.outcomes });
85
+ const byKind = { ...base.byKind };
86
+
87
+ // outcome classification: expected static limits are not conditions
88
+ const opaque = run.outcomes.filter((o) => o.kind === 'unresolved-branch' && /^opaque-boundary/.test(String(o.detail || '')));
89
+ const otherUnresolved = run.outcomes.filter((o) => o.kind === 'unresolved-branch' && !/^opaque-boundary/.test(String(o.detail || '')));
90
+ if (byKind['missing-grammar']) conditions.push(`${byKind['missing-grammar']} language file(s) skipped: grammar unavailable or corrupt`);
91
+ if (byKind['adapter-exception']) conditions.push(`${byKind['adapter-exception']} language adapter exception(s)`);
92
+ if (byKind['timeout']) conditions.push(`${byKind['timeout']} language file(s) timed out`);
93
+ if (otherUnresolved.length) conditions.push(`${otherUnresolved.length} unresolved language construct(s) (syntax errors, dynamic attributes or imports)`);
94
+ if (byKind['unregistered-producer']) conditions.push(`${byKind['unregistered-producer']} result(s) from an unregistered language producer were discarded`);
95
+ if (opaque.length) {
96
+ const kinds = {};
97
+ for (const o of opaque) { const k = String(o.detail).replace(/^opaque-boundary:\s*/, ''); kinds[k] = (kinds[k] || 0) + 1; }
98
+ for (const [k, n] of Object.entries(kinds).sort()) limitations.push({ kind: 'opaque-boundary', boundary: k, count: n, note: 'a documented limit of static analysis: this code is not analysed, and is not reported clean' });
99
+ }
100
+ const DISCLOSED_NOTE = { cpp: 'preprocessor conditionals: every branch was parsed, but which branch is compiled is not known', ffi: 'foreign declarations: the foreign code is not analysed and its effects are not modelled' };
101
+ for (const [k, e] of [...disclosed.entries()].sort()) limitations.push({ kind: 'opaque-boundary', boundary: k, count: e.count, files: [...e.files].sort().slice(0, 20), note: `a documented limit of static analysis (${DISCLOSED_NOTE[k]})` });
102
+ // Imported modules this scan has no model for: their functions are ordinary, opaque calls (never a source, sink or
103
+ // sanitizer), so a taint flow through them is widened and disclosed on the finding. Listed so a reader knows how much of the
104
+ // import surface the models cover. A limitation, not a condition: most real projects import unmodelled modules.
105
+ const unmodeled = [...importedModules.keys()].filter((m) => !projectModules.has(m) && !packageOfModule(m)).sort();
106
+ if (unmodeled.length) limitations.push({ kind: 'unmodeled-imports', count: unmodeled.length, modules: unmodeled.slice(0, 30), note: 'no security model exists for these imported modules: their functions are analysed as ordinary calls, so a flow through them is widened and disclosed on the finding' });
107
+ // Sources the default ignore list kept out of the scan (tests, specs, mocks, bin, build, dist, vendor): the same
108
+ // policy every language has, but stated here so an unscanned Haskell/Nix file is never invisible.
109
+ // Sources and manifests over the read cap were NOT analysed: unlike a default-ignored directory, that is a gap in the analysis,
110
+ // so it is a condition (the verdict cannot be complete), listed with the path, its size and the cap.
111
+ const sizeSkipped = Array.isArray(input.sizeSkipped) ? input.sizeSkipped : [];
112
+ if (sizeSkipped.length) conditions.push(`${sizeSkipped.length} Haskell/Nix source or manifest file(s) exceed the size cap and were not analysed: ${sizeSkipped.slice(0, 3).map((x) => `${x.file} (${x.bytes} bytes > ${x.cap})`).join(', ')}${sizeSkipped.length > 3 ? ', ...' : ''}`);
113
+ const policyExcluded = Array.isArray(input.policyExcluded) ? input.policyExcluded : [];
114
+ if (policyExcluded.length) limitations.push({ kind: 'default-ignore', count: policyExcluded.length, files: policyExcluded.slice(0, 20), note: 'Haskell/Nix files under a default-ignored directory (tests, specs, mocks, bin, build, dist, vendor) are not scanned; this is the project-wide default for every language' });
115
+ // Nix-built images and declared OCI services are not image-layer or base-image scanned: the container analyzers read
116
+ // Dockerfile and compose SOURCE. Stated whenever such a declaration is present, so its absence from the findings is
117
+ // never read as a clean image.
118
+ const ociFiles = Object.entries(files).filter(([f, t]) => /\.nix$/i.test(f) && typeof t === 'string' && /oci-containers|dockerTools\.(?:buildImage|buildLayeredImage|streamLayeredImage)|virtualisation\.(?:docker|podman)/.test(t)).map(([f]) => f);
119
+ if (ociFiles.length) limitations.push({ kind: 'container-image-scan', files: ociFiles.sort(), note: 'Nix-built images and declared OCI services are analysed as configuration only: image layers and base-image vulnerabilities are NOT scanned' });
120
+ if (!reconcile.ok) conditions.push(`language ledger does not reconcile with the inputs: ${reconcile.errors[0]}`);
121
+
122
+ // 2. manifests
123
+ if (manifests.length) {
124
+ let m = null;
125
+ try { m = analyzeHaskellManifests(manifests.map(([path, text]) => ({ path, text })), {}); } catch (e) { conditions.push(`Haskell manifest analysis failed: ${String((e && e.message) || e)}`); }
126
+ if (m) {
127
+ const bad = m.diagnostics.filter((d) => MALFORMED_MANIFEST.has(d.kind));
128
+ if (bad.length) conditions.push(`${bad.length} malformed Haskell manifest entr${bad.length === 1 ? 'y' : 'ies'}: ${[...new Set(bad.map((d) => d.kind))].join(', ')}`);
129
+ if (m.coverage && m.coverage.failed) conditions.push(`${m.coverage.failed} Haskell manifest(s) could not be parsed`);
130
+ }
131
+ }
132
+
133
+ // 3. advisory feed currency (only meaningful when Haskell dependencies are in play and SCA is required)
134
+ const scaRequired = (required.haskell || []).includes('sca') && !disabled.has('haskell:sca');
135
+ if (present.has('haskell') && scaRequired && manifests.length) {
136
+ const db = input.advisoryDb;
137
+ if (!db) conditions.push('no Hackage advisory snapshot is loaded: Haskell dependency vulnerabilities were not assessed');
138
+ else if (db.stale) conditions.push(`the Hackage advisory snapshot is stale (${db.ageDays == null ? 'age unknown' : `${Math.floor(db.ageDays)} day(s) old`}, limit ${db.maxAgeDays})`);
139
+ }
140
+
141
+ // 3b. resolved dependency data and the Nix closure: a stale, malformed or unchecked input is stated, never silently dropped
142
+ for (const g of input.supplyGaps || []) {
143
+ if (!g || !g.kind) continue;
144
+ if (/unavailable|stale|malformed|failed|unverified/.test(g.kind)) conditions.push(`${g.kind.replace(/^(?:resolved|closure)-/, '')}: ${String(g.detail || '').slice(0, 220)}`);
145
+ else limitations.push({ kind: 'supply-gap', gap: g.kind, note: String(g.detail || '').slice(0, 220) });
146
+ }
147
+
148
+ if (input.licenseUnavailable) limitations.push({ kind: 'license-data-unavailable', count: input.licenseUnavailable, note: 'Haskell and Nix dependency records carry no license data, so no license policy was applied to them; this is not a statement that they are acceptable' });
149
+
150
+ // 4. effective NixOS configuration
151
+ for (const r of input.effectiveConfigs || []) {
152
+ if (r && r.completeness === 'partial') conditions.push(`the effective NixOS configuration for ${(r.target && r.target.name) || 'a target'} is partial: ${(r.unresolved || []).length} unresolved, ${(r.truncated || []).length} truncated`);
153
+ }
154
+
155
+ // 5. required capabilities
156
+ const capabilities = {};
157
+ for (const lang of LANGS) {
158
+ if (!present.has(lang)) continue;
159
+ capabilities[lang] = {};
160
+ for (const cap of required[lang] || []) {
161
+ const id = `${lang}:${cap}`;
162
+ let status = 'ran'; let detail = null;
163
+ if (disabled.has(id)) { status = 'disabled'; detail = 'switched off by configuration'; }
164
+ else if (cap === 'parser') {
165
+ const counts = Object.values(run.ledger.byAnalyzer).filter((a) => a.language === lang);
166
+ const failed = counts.reduce((s, a) => s + a.failed + a.timedOut + a.missingGrammar, 0);
167
+ if (failed) { status = 'failed'; detail = `${failed} file(s) not parsed`; }
168
+ }
169
+ capabilities[lang][cap] = { required: true, status, detail };
170
+ if (status === 'disabled') conditions.push(`required analyzer "${id}" is disabled`);
171
+ }
172
+ }
173
+
174
+ // 6. optional modes
175
+ const optionalModes = {};
176
+ for (const name of OPTIONAL_MODES) {
177
+ const st = optionalState(name, input.optional);
178
+ optionalModes[name] = { selected: st.selected, status: st.status, ran: st.ran, reason: st.reason };
179
+ if (st.condition) conditions.push(st.condition);
180
+ }
181
+
182
+ return {
183
+ version: ASSURANCE_VERSION,
184
+ languageCoverage: { totals: base.totals, byKind, conditions, limitations, capabilities, optionalModes },
185
+ ledger: { reconciled: reconcile.ok, errors: reconcile.errors, byAnalyzer: run.ledger.byAnalyzer },
186
+ outcomes: run.outcomes,
187
+ retained: { findings: findings.length, findingsRef: findings },
188
+ conditions,
189
+ limitations,
190
+ optionalModes,
191
+ capabilities,
192
+ };
193
+ }