@clear-capabilities/agentic-security-scanner 0.187.0 → 0.189.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.
@@ -371,7 +371,17 @@ function detectGenerated(text) {
371
371
  }
372
372
 
373
373
  function newStanza(kind, name, line, scope) {
374
- return { kind, name, scope, line, deps: [], imports: [], surfaces: [], flagRefs: [], fields: {} };
374
+ return { kind, name, scope, line, deps: [], imports: [], surfaces: [], flagRefs: [], fields: {}, exposedModules: [], otherModules: [], opaque: [] };
375
+ }
376
+
377
+ // What a `ghc-options` value says about the build that the module graph cannot see: a different entry point, a source preprocessor, a compiler plugin.
378
+ // Read from the field text, never from a comment (comment lines are dropped by the line lexer before a field is read).
379
+ function ghcOpaqueKinds(text) {
380
+ const out = [];
381
+ if (/(?:^|\s)-main-is\b/.test(text)) out.push('main-is');
382
+ if (/(?:^|\s)-(?:F|pgmF)\b/.test(text)) out.push('preprocessor');
383
+ if (/(?:^|\s)-f?plugin(?:-package(?:-id)?)?\b/.test(text)) out.push('plugin');
384
+ return out;
375
385
  }
376
386
 
377
387
  function handleCabalField(st, name, node, conds, file) {
@@ -397,7 +407,17 @@ function handleCabalField(st, name, node, conds, file) {
397
407
  case 'import':
398
408
  for (const w of splitWords(val.text)) st.imports.push({ name: w.text, line: locate(val, w.offset).line });
399
409
  break;
410
+ case 'exposed-modules':
411
+ for (const w of splitWords(val.text)) st.exposedModules.push(w.text);
412
+ break;
413
+ case 'other-modules':
414
+ for (const w of splitWords(val.text)) st.otherModules.push(w.text);
415
+ break;
416
+ case 'signatures':
417
+ st.opaque.push('backpack-signatures');
418
+ break;
400
419
  case 'ghc-options': case 'ghc-prof-options': case 'ghc-shared-options':
420
+ for (const k of ghcOpaqueKinds(val.text)) if (!st.opaque.includes(k)) st.opaque.push(k);
401
421
  scanGhcOptions(val.text, null, (kind, detail, idx) => {
402
422
  const loc = locate(val, idx);
403
423
  st.surfaces.push({ kind, detail, file, line: loc.line, column: loc.column });
@@ -511,6 +531,7 @@ export function parseCabalFile(text, opts = {}) {
511
531
  for (const d of c.deps) into.deps.push({ ...d, via: `common:${imp.name}` });
512
532
  for (const s of c.surfaces) into.surfaces.push({ ...s, via: `common:${imp.name}` });
513
533
  for (const r of c.flagRefs) into.flagRefs.push(r);
534
+ for (const k of c.opaque) if (!into.opaque.includes(k)) into.opaque.push(k);
514
535
  expand(c, into, nextSeen, via);
515
536
  }
516
537
  };
@@ -518,7 +539,7 @@ export function parseCabalFile(text, opts = {}) {
518
539
  const declared = new Set(flags.map((f) => f.name));
519
540
  const unknownFlags = [];
520
541
  const components = stanzas.map((st) => {
521
- const own = { deps: st.deps.slice(), surfaces: st.surfaces.slice(), flagRefs: st.flagRefs.slice() };
542
+ const own = { deps: st.deps.slice(), surfaces: st.surfaces.slice(), flagRefs: st.flagRefs.slice(), opaque: [] };
522
543
  expand(st, own, new Set(), null);
523
544
  for (const r of own.flagRefs) {
524
545
  if (!declared.has(r.flag)) {
@@ -535,6 +556,9 @@ export function parseCabalFile(text, opts = {}) {
535
556
  line: st.line,
536
557
  imports: st.imports,
537
558
  fields: st.fields,
559
+ exposedModules: st.exposedModules,
560
+ otherModules: st.otherModules,
561
+ opaque: [...new Set([...st.opaque, ...own.opaque])],
538
562
  dependencies: own.deps.map((d) => ({ ...d, scope: d.scopeOverride || st.scope, scopeOverride: undefined })),
539
563
  surfaces: own.surfaces,
540
564
  };
@@ -639,8 +663,16 @@ export function parseHpackFile(text, opts = {}) {
639
663
  if (scopeOverride === 'build-tool') surfaces.push({ kind: 'build-tool-depends', detail: `${e.name} ${e.rangeText}`.trim(), file, line, column: d.column, component: st.name });
640
664
  }
641
665
  }
666
+ for (const key of ['exposed-modules', 'other-modules']) {
667
+ if (!(key in sec)) continue;
668
+ const v = Array.isArray(sec[key]) ? sec[key] : [sec[key]];
669
+ for (const m of v) if (typeof m === 'string') (key === 'exposed-modules' ? st.exposedModules : st.otherModules).push(m.trim());
670
+ if (key === 'exposed-modules') st.exposedDeclared = true;
671
+ }
672
+ if ('generated-exposed-modules' in sec || 'generated-other-modules' in sec) st.opaque.push('hpack-generated-modules');
642
673
  if ('ghc-options' in sec) {
643
674
  const opt = Array.isArray(sec['ghc-options']) ? sec['ghc-options'].join(' ') : String(sec['ghc-options']);
675
+ for (const k of ghcOpaqueKinds(opt)) if (!st.opaque.includes(k)) st.opaque.push(k);
644
676
  let li = findLine(lines, new RegExp(`^${ind}(?:-\\s+)?ghc-options\\s*:`), start);
645
677
  if (li < 0) li = start;
646
678
  scanGhcOptions(opt, null, (kind, detail) => surfaces.push({ kind, detail, file, line: li + 1, column: 0, component: st.name }));
@@ -662,7 +694,7 @@ export function parseHpackFile(text, opts = {}) {
662
694
  }
663
695
  };
664
696
 
665
- const mkStanza = (kind, name, scope) => ({ kind, name, scope, deps: [], flagRefs: [] });
697
+ const mkStanza = (kind, name, scope) => ({ kind, name, scope, deps: [], flagRefs: [], exposedModules: [], otherModules: [], opaque: [], exposedDeclared: false });
666
698
  const finish = (st, line) => {
667
699
  for (const r of st.flagRefs) {
668
700
  if (!(doc.flags && isObj(doc.flags) && r.flag in doc.flags)) {
@@ -670,17 +702,19 @@ export function parseHpackFile(text, opts = {}) {
670
702
  diagnostics.push(diag('unknown_flag', `condition references flag '${r.flag}' that package.yaml never declares`, file, r.line, 'warning', { flag: r.flag }));
671
703
  }
672
704
  }
673
- return { kind: st.kind, name: st.name, scope: st.scope, file, line, dependencies: st.deps.map((d) => ({ ...d, scope: d.scopeOverride || st.scope, scopeOverride: undefined })) };
705
+ return { kind: st.kind, name: st.name, scope: st.scope, file, line, exposedModules: st.exposedModules, otherModules: st.otherModules, exposedDeclared: st.exposedDeclared, opaque: st.opaque, buildTools: st.deps.filter((d) => d.scopeOverride === 'build-tool').map((d) => d.name), dependencies: st.deps.map((d) => ({ ...d, scope: d.scopeOverride || st.scope, scopeOverride: undefined })) };
674
706
  };
675
707
 
676
708
  const components = [];
677
709
  let packageDependencies = [];
710
+ let topOpaque = [];
678
711
  try {
679
712
  const top = mkStanza('package', pkg.name, 'package');
680
713
  collect(doc, top, [], 0, 0);
681
714
  // top-level `when` and deps apply to every component
682
715
  const topLoc = finish(top, 1);
683
716
  packageDependencies = topLoc.dependencies;
717
+ topOpaque = topLoc.opaque;
684
718
 
685
719
  const section = (key, kind, scope, single) => {
686
720
  if (!(key in doc)) return;
@@ -735,7 +769,7 @@ export function parseHpackFile(text, opts = {}) {
735
769
  return {
736
770
  kind: 'hpack', file, package: pkg, provenance: { generatedBy: null, source: 'hand-written' },
737
771
  components, packageDependencies, flags: flagList, unknownFlags, sourceRepositories,
738
- surfaces, incomplete, diagnostics,
772
+ surfaces, incomplete, diagnostics, topOpaque,
739
773
  };
740
774
  }
741
775
 
@@ -1258,6 +1292,55 @@ export function analyzeHaskellManifests(files, opts = {}) {
1258
1292
  return redactUrlsDeep(out);
1259
1293
  }
1260
1294
 
1295
+ // ---------------------------------------------------------------------------
1296
+ // Exposed modules (who can import this package from outside)
1297
+ // ---------------------------------------------------------------------------
1298
+
1299
+ /** Work counter for the exposed-module index: one parse per cabal / package.yaml, never per function or per module. Read by the scaling test. */
1300
+ export const EXPOSED_INDEX_WORK = { manifestParses: 0, builds: 0 };
1301
+
1302
+ /**
1303
+ * The set of modules a package's manifests EXPOSE to other packages, over every `.cabal` file (and every `package.yaml` that has no `.cabal` beside
1304
+ * it) in `manifests` (path -> text). Returns `{ modules, reason, packages, libraries }`: `modules` is a Set of module names, or `null` when the
1305
+ * manifests cannot say (none present, one failed to parse or hit a budget, an hpack library that exposes every module by default, a build that uses an
1306
+ * entry-point override, a source preprocessor, a plugin, Backpack signatures, a foreign library, or a build tool that generates code outside the
1307
+ * analysed files). `null` is "unknown", never "nothing exposed": an executable-only package yields an EMPTY set, which is a real answer.
1308
+ * Conditional stanzas are unioned (a module exposed under any flag setting is exposed). Nothing here reads a comment, a name or a path.
1309
+ */
1310
+ export function exposedModuleIndex(manifests, opts = {}) {
1311
+ EXPOSED_INDEX_WORK.builds++;
1312
+ const budgets = budgetsOf(opts);
1313
+ const entries = Object.entries(manifests || {}).map(([p, text]) => [String(p).replace(/\\/g, '/'), typeof text === 'string' ? text : '']).filter(([p]) => { const k = classifyManifest(p); return k === 'cabal' || k === 'hpack'; }).sort((a, b) => (a[0] < b[0] ? -1 : 1));
1314
+ if (!entries.length) return { modules: null, reason: 'no-manifest', packages: 0, libraries: 0 };
1315
+ const cabalDirs = new Set(entries.filter(([p]) => classifyManifest(p) === 'cabal').map(([p]) => path.posix.dirname(p)));
1316
+ const modules = new Set();
1317
+ let packages = 0; let libraries = 0; let unknown = null;
1318
+ const fail = (why) => { if (!unknown) unknown = why; };
1319
+ for (const [p, text] of entries) {
1320
+ const kind = classifyManifest(p);
1321
+ if (kind === 'hpack' && cabalDirs.has(path.posix.dirname(p))) continue; // the generated .cabal beside it is what the build reads
1322
+ EXPOSED_INDEX_WORK.manifestParses++;
1323
+ let r;
1324
+ try { r = kind === 'cabal' ? parseCabalFile(text, { file: p, budgets }) : parseHpackFile(text, { file: p, budgets }); } catch { fail('manifest-parse-failure'); continue; }
1325
+ if (r.incomplete) { fail('manifest-incomplete'); continue; }
1326
+ if ((r.diagnostics || []).some((d) => d.code === 'budget_exceeded' || d.kind === 'budget_exceeded')) { fail('manifest-budget'); continue; }
1327
+ packages++;
1328
+ if ((r.topOpaque || []).length) fail(`build-${r.topOpaque[0]}`);
1329
+ for (const c of r.components) {
1330
+ if (c.kind === 'foreign-library') fail('foreign-library');
1331
+ if ((c.opaque || []).length) fail(`build-${c.opaque[0]}`);
1332
+ const generates = (c.surfaces || []).some((s) => s.kind === 'build-tool-depends') || (c.buildTools || []).length > 0;
1333
+ if (generates && c.kind !== 'test-suite' && c.kind !== 'benchmark') fail('build-tool-generated-sources');
1334
+ if (c.kind !== 'library') continue;
1335
+ libraries++;
1336
+ if (kind === 'hpack' && !c.exposedDeclared) fail('hpack-library-exposes-all-modules');
1337
+ for (const m of c.exposedModules || []) modules.add(m);
1338
+ }
1339
+ }
1340
+ if (unknown) return { modules: null, reason: unknown, packages, libraries };
1341
+ return { modules, reason: null, packages, libraries };
1342
+ }
1343
+
1261
1344
  const WALK_SKIP = new Set(['node_modules', '.git', '.hg', '.svn']);
1262
1345
 
1263
1346
  /** Read manifests under `root` (bounded, symlinks never followed) and analyse them. */
@@ -73,6 +73,7 @@ export const HS_PACKAGES = Object.freeze({
73
73
  HsOpenSSL: { modules: ['OpenSSL.Session'], tested: [] },
74
74
  'wai-extra': { modules: ['Network.Wai.Parse', 'Network.Wai.Conduit'], tested: [] },
75
75
  'scotty-cookie': { modules: ['Web.Scotty.Cookie'], tested: [] },
76
+ ihp: { modules: ['IHP.ControllerPrelude', 'IHP.Controller.Param'], tested: [] },
76
77
  'http-types': { modules: ['Network.HTTP.Types', 'Network.HTTP.Types.Header'], tested: [] },
77
78
  'conduit-extra': { modules: ['Data.Conduit.Binary', 'Data.Conduit.Combinators'], tested: [] },
78
79
  'uri-parsing': { modules: ['Text.URI'], tested: [] },
@@ -85,10 +86,16 @@ const SRC = [];
85
86
  const src = (module, names, provenance, label, extra = {}) => { for (const e of E(module, names)) SRC.push({ ...e, provenance, label: `${label}${names.length > 1 ? '' : ''}`, package: extra.package, framework: extra.framework || null }); };
86
87
 
87
88
  src('Prelude', ['getLine', 'getContents', 'readLn', 'interact'], 'stdin', 'standard input', { package: 'base' });
88
- src('System.IO', ['getLine', 'getContents', 'hGetLine', 'hGetContents', 'hGetChar'], 'stdin', 'handle input', { package: 'base' });
89
- src('Data.Text.IO', ['getLine', 'getContents', 'hGetLine', 'hGetContents', 'interact'], 'stdin', 'text input', { package: 'text' });
90
- src('Data.ByteString', ['getLine', 'getContents', 'hGetLine', 'hGetContents'], 'stdin', 'bytes input', { package: 'bytestring' });
91
- src('Data.ByteString.Char8', ['getLine', 'getContents', 'hGetLine', 'hGetContents'], 'stdin', 'bytes input', { package: 'bytestring' });
89
+ // `getLine` and `getContents` read standard input. `hGetLine`/`hGetContents`/`hGetChar` read WHATEVER handle they are given (a file the program opened,
90
+ // a child process's pipe, a socket, or stdin), so they are modelled as a read through a handle, not as standard input.
91
+ src('System.IO', ['getLine', 'getContents'], 'stdin', 'handle input', { package: 'base' });
92
+ src('System.IO', ['hGetLine', 'hGetContents', 'hGetChar'], 'file-read', 'handle input', { package: 'base' });
93
+ src('Data.Text.IO', ['getLine', 'getContents', 'interact'], 'stdin', 'text input', { package: 'text' });
94
+ src('Data.Text.IO', ['hGetLine', 'hGetContents'], 'file-read', 'text input', { package: 'text' });
95
+ src('Data.ByteString', ['getLine', 'getContents'], 'stdin', 'bytes input', { package: 'bytestring' });
96
+ src('Data.ByteString', ['hGetLine', 'hGetContents'], 'file-read', 'bytes input', { package: 'bytestring' });
97
+ src('Data.ByteString.Char8', ['getLine', 'getContents'], 'stdin', 'bytes input', { package: 'bytestring' });
98
+ src('Data.ByteString.Char8', ['hGetLine', 'hGetContents'], 'file-read', 'bytes input', { package: 'bytestring' });
92
99
  src('System.Environment', ['getArgs'], 'cli', 'command-line argument', { package: 'base' });
93
100
  src('System.Environment', ['getEnv', 'lookupEnv', 'getEnvironment'], 'env', 'environment variable', { package: 'base' });
94
101
  src('Prelude', ['readFile'], 'file-read', 'file content', { package: 'base' });
@@ -144,6 +151,10 @@ for (const mod of ['Web.Scotty', 'Web.Scotty.Trans']) {
144
151
  src(mod, ['bodyReader', 'bodyRaw'], 'http-body', 'scotty request body', { package: 'scotty', framework: 'scotty' });
145
152
  }
146
153
  src('Web.Scotty.Cookie', ['getCookie', 'getCookies'], 'cookie', 'scotty request cookie', { package: 'scotty-cookie', framework: 'scotty' });
154
+ // IHP: a controller action reads the request with `param @Text "name"` (a pure function over the implicit request): the value is whatever the client sent.
155
+ for (const mod of ['IHP.ControllerPrelude', 'IHP.Controller.Param']) {
156
+ src(mod, ['param', 'paramText', 'paramOrNothing', 'paramOrDefault', 'paramList'], 'url-param', 'IHP request parameter', { package: 'ihp', framework: 'ihp' });
157
+ }
147
158
  for (const mod of ['Yesod.Core', 'Yesod']) {
148
159
  src(mod, ['parseJsonBody', 'requireJsonBody', 'parseCheckJsonBody', 'requireCheckJsonBody', 'lookupCookies', 'lookupHeaders'], 'http-body', 'yesod request body', { package: 'yesod', framework: 'yesod' });
149
160
  src(mod, ['lookupBasicAuth'], 'header', 'yesod request header', { package: 'yesod', framework: 'yesod' });
@@ -1142,10 +1142,24 @@ const IR_PREPROCESS_GUARD = /^[ \t]*#\s*(?:if|ifdef|ifndef|elif|else|endif|defin
1142
1142
  * Returns the input unchanged when it has nothing to preprocess or cannot be parsed.
1143
1143
  */
1144
1144
  export function preprocessForSyntax(source, opts = {}) {
1145
- if (typeof source !== 'string' || !IR_PREPROCESS_GUARD.test(source)) return source;
1145
+ return preprocessForSyntaxInfo(source, opts).text;
1146
+ }
1147
+
1148
+ /**
1149
+ * The same preprocessing, plus what it learned about the file's opaque boundaries: `opaque` is `false` when the file has none (including every
1150
+ * Template Haskell splice and quasi-quote the closed allow-lists accepted), `true` when a splice, quasi-quote or TH quotation remains whose expansion
1151
+ * is generated at compile time (code the analysis never sees, which can call any function), and `null` when the file mentions Template Haskell but
1152
+ * could not be parsed to find out. Decided from tokens and extensions, never from comment text (the guard only decides whether to look).
1153
+ */
1154
+ export function preprocessForSyntaxInfo(source, opts = {}) {
1155
+ const none = { text: source, opaque: false };
1156
+ if (typeof source !== 'string') return none;
1157
+ const thQuotesOnly = /\bTemplateHaskellQuotes\b/.test(source);
1158
+ if (!IR_PREPROCESS_GUARD.test(source)) return thQuotesOnly ? { text: source, opaque: null } : none;
1146
1159
  let r;
1147
- try { r = parseHaskell(source, { file: opts.file, cpp: opts.cpp }); } catch { return source; }
1148
- if (!r || !r.ok || typeof r._preprocessed !== 'string' || r._preprocessed.length !== source.length) return source;
1160
+ try { r = parseHaskell(source, { file: opts.file, cpp: opts.cpp }); } catch { return { text: source, opaque: null }; }
1161
+ if (!r || !r.ok || typeof r._preprocessed !== 'string' || r._preprocessed.length !== source.length) return { text: source, opaque: /\b(?:TemplateHaskell|TemplateHaskellQuotes|QuasiQuotes)\b/.test(source) ? null : false };
1162
+ const opaque = (r.boundaries || []).some((b) => b && typeof b.kind === 'string' && (b.kind.startsWith('th-') && b.kind !== 'th-safe-splice' || b.kind === 'quasiquote'));
1149
1163
  let out = r._preprocessed;
1150
1164
  for (const b of r.blankRanges || []) {
1151
1165
  const seg = out.slice(b.startOffset, b.endOffset);
@@ -1153,5 +1167,5 @@ export function preprocessForSyntax(source, opts = {}) {
1153
1167
  if (b.kind === 'quasiquote-inert' && repl.length >= 2) repl = `"${repl.slice(1, -1)}"`;
1154
1168
  out = out.slice(0, b.startOffset) + repl + out.slice(b.endOffset);
1155
1169
  }
1156
- return out;
1170
+ return { text: out, opaque };
1157
1171
  }
@@ -0,0 +1,84 @@
1
+ // Project-reachability adjudication of Haskell taint findings.
2
+ //
3
+ // `haskell-ir.js` `projectReachability` stamps every function with `hs.reachability {status, rootKind, holeReason}`: could any caller exist, judged from
4
+ // the project's entry points over the call graph? This module reads that verdict for the function a taint finding was found in and records it on the
5
+ // finding as `reachability {status, reason, premise, roots, holeReason}`. It is a SEPARATE record from `trustBoundary`: that one answers who controls
6
+ // the input, this one answers whether a caller can exist, and either may be `unknown` independently.
7
+ //
8
+ // status entrypoint-reachable | unreachable-in-project | external-api | unknown
9
+ // premise what the finding's own claim rests on:
10
+ // caller-controlled-parameter the only origin recorded is the assumption that an untrusted caller supplies a parameter of
11
+ // an exported function (the weak label, `hs:caller-controlled`)
12
+ // recorded-read a real environment, command-line, file, standard-input, socket or request read is on the chain
13
+ //
14
+ // What the verdict does to the finding:
15
+ // unreachable-in-project and premise caller-controlled-parameter the premise is withdrawn (an entry-point-rooted walk over a hole-free graph says
16
+ // there is no caller at all): lowered to `info`, never dropped (`lowerFinding`, `_reachLowered`)
17
+ // unreachable-in-project and premise recorded-read NOT lowered. "Nothing calls this" is a claim about a call graph in a language where typeclass
18
+ // dispatch, re-exports and point-free aliasing routinely defeat call graphs; being wrong costs a real
19
+ // vulnerability, so it may cost rank (`posture/relevance.js` reads `f.reachability`) and not visibility
20
+ // unknown, external-api, entrypoint-reachable nothing happens to the severity
21
+ //
22
+ // Nothing here reads a comment, a name or a path: the inputs are the call-graph verdict and the origins the taint engine recorded.
23
+
24
+ import { lowerFinding, originClasses } from './haskell-trust.js';
25
+
26
+ const HS_FILE = /\.(?:l?hs|hsc)$/i;
27
+
28
+ const fnByQid = (perFile, qid) => {
29
+ if (!qid) return null;
30
+ const file = String(qid).split('::')[0];
31
+ const ir = perFile && perFile[file];
32
+ return ir ? ir.functions.find((x) => x.qid === qid) || null : null;
33
+ };
34
+
35
+ const REASONS = Object.freeze({
36
+ 'entrypoint-reachable': 'a call path from a project entry point reaches the function',
37
+ 'external-api': 'the function belongs to a module the package exposes, so its callers are outside the analysed code',
38
+ 'unreachable-in-project': 'no entry point of the project reaches the function through the call graph (complete scan, hole-free graph, a main and a manifest present, module not exposed)',
39
+ });
40
+ const HOLE_TEXT = Object.freeze({
41
+ 'partial-scan': 'the scan covered only part of the project',
42
+ 'policy-excluded': 'Haskell sources were kept out of the scan by the path policy',
43
+ 'size-skipped': 'Haskell sources were too large to read',
44
+ 'no-entrypoint': 'the analysed files contain no `main`, so there is no entry point to walk from',
45
+ 'no-manifest': 'no manifest says which modules the package exposes',
46
+ 'parse-failure': 'a declaration could not be read, so its calls are missing from the graph',
47
+ 'function-cap': 'functions past the per-file cap are not in the graph',
48
+ 'opaque-boundary': 'Template Haskell, a quasi-quote, a foreign export, a build step that generates code or a source format the IR does not read can call functions the graph does not show',
49
+ 'unresolved-call': 'a function reachable from an entry point makes a call the IR could not resolve',
50
+ 'instance-method': 'an instance method can be invoked by anything that can see its type',
51
+ 'unresolved-route': 'a route registration could not be resolved',
52
+ });
53
+
54
+ /** What the finding's own claim rests on (see the header). `null` when the recorded origins do not settle it. */
55
+ function premiseOf(f) {
56
+ const { roots, classes } = originClasses(f);
57
+ if (!roots.length) return null;
58
+ if ([...classes].every((c) => c === 'weak-param')) return 'caller-controlled-parameter';
59
+ if ([...classes].some((c) => c === 'operator' || c === 'external' || c === 'data')) return 'recorded-read';
60
+ return null;
61
+ }
62
+
63
+ /**
64
+ * Stamps `reachability` on every Haskell IR-TAINT finding that has a verdict for its function, and lowers to `info` the ones whose only premise
65
+ * (a caller-controlled parameter) the verdict withdraws. Mutates the findings; returns counts.
66
+ */
67
+ export function applyHaskellReachability(findings, perFile) {
68
+ const stats = { 'entrypoint-reachable': 0, 'unreachable-in-project': 0, 'external-api': 0, unknown: 0, lowered: 0, rankOnly: 0, unstamped: 0 };
69
+ for (const f of findings || []) {
70
+ if (!f || f.parser !== 'IR-TAINT' || !HS_FILE.test(String(f.file || ''))) continue;
71
+ const fn = fnByQid(perFile, f._funcQid);
72
+ const r = fn && fn.hs && fn.hs.reachability;
73
+ if (!r || !r.status) { stats.unstamped++; continue; }
74
+ const premise = premiseOf(f);
75
+ const reason = r.status === 'unknown' ? (HOLE_TEXT[r.holeReason] || 'the call graph cannot settle it') : REASONS[r.status];
76
+ f.reachability = { status: r.status, reason, premise, roots: r.rootKind ? [r.rootKind] : [], holeReason: r.holeReason || null };
77
+ stats[r.status] = (stats[r.status] || 0) + 1;
78
+ if (r.status !== 'unreachable-in-project') continue;
79
+ if (premise === 'caller-controlled-parameter') {
80
+ if (lowerFinding(f, { kind: 'unreachable-in-project', reason: 'the finding rests on a caller of an exported function supplying a parameter, and nothing in the project can call it', controlKind: 'no-caller-in-project', guard: '_reachLowered', keepNote: true })) stats.lowered++;
81
+ } else if (premise === 'recorded-read') stats.rankOnly++;
82
+ }
83
+ return stats;
84
+ }
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { buildHaskellIR } from './haskell-ir.js';
17
17
  import { HS_LOG_SINKS, HS_MODEL_VERSION } from './haskell-models.js';
18
+ import { codeOfLine } from './haskell-comments.js';
18
19
 
19
20
  export const HS_RULES_VERSION = 'haskell-security-rules/1';
20
21
 
@@ -68,7 +69,9 @@ const UNSAFE_CALLS = { 'System.IO.Unsafe.unsafePerformIO': 'unsafePerformIO', 'S
68
69
  const SECRET_KEY_CALLS = new Set(['Web.JWT.hmacSecret', 'Web.JWT.secret', 'Servant.Auth.Server.fromSecret', 'Web.ClientSession.initKey']);
69
70
  const isTrueLit = (e) => !!e && ((e.kind === 'literal' && /^true$/i.test(String(e.value))) || (e.kind === 'ident' && /(?:^|\.)True$/.test(e.name || '')));
70
71
  const TIME_SOURCES = new Set(['Data.Time.Clock.POSIX.getPOSIXTime', 'Data.Time.Clock.getCurrentTime', 'System.CPUTime.getCPUTime']);
71
- const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|salt|apikey|resetcode|passcode|verification)/i;
72
+ // A salt is deliberately absent: it must be unique, not unpredictable or secret (it is stored beside the hash), so a non-cryptographic generator
73
+ // producing one is not a weakness the way it is for a token, nonce or key.
74
+ const SECURITY_FN_SUBSTR = /(?:token|nonce|secret|otp|csrf|session|apikey|resetcode|passcode|verification)/i;
72
75
  // Short credential words that would over-match as substrings (`spin`, `mapping`, `keyboard`): matched as whole camelCase / snake_case words.
73
76
  const SECURITY_WORDS = new Set(['pin', 'password', 'passwd', 'credential', 'credentials']);
74
77
  const SECURITY_FN = { test: (name) => SECURITY_FN_SUBSTR.test(name) || String(name).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[^a-z0-9]+/).some((w) => SECURITY_WORDS.has(w)) };
@@ -89,7 +92,7 @@ const EMPTINESS_CHECK = /(?:^|\.)(?:null|notNull|isEmpty|length|size|isJust|isNo
89
92
  const SESSION_COOKIE = /(?:sess|sid|token|tok|auth|jwt|csrf|login|remember|secret)/i;
90
93
 
91
94
  // What makes a bound variable security-shaped, and what makes a value sensitive for logging.
92
- const SECURITY_VAR = /^(?:.*(?:token|secret|nonce|salt|otp|csrf|apikey|api_?key|sessionid|session_?key|sessiontoken|resettoken)[a-z0-9_']*|key|iv|sessionId|sid)$/i;
95
+ const SECURITY_VAR = /^(?:.*(?:token|secret|nonce|otp|csrf|apikey|api_?key|sessionid|session_?key|sessiontoken|resettoken)[a-z0-9_']*|key|iv|sessionId|sid)$/i;
93
96
  const PASSWORD_VAR = /(?:^|[^a-z])(?:pass(?:word|wd|phrase)?|pwd|pw)(?:[^a-z]|$)|password/i;
94
97
  const LOW_ENTROPY = /(?:pass(?:word|wd|phrase)?|pwd|ssn|creditcard|cardnumber|cvv|\bpin\b)/i;
95
98
  export const SENSITIVE = /(?:pass(?:word|wd|phrase)?|pwd|(?:^|[^a-z])pw(?:[^a-z]|$)|secret|token|api_?key|ssn|credit_?card|card_?number|cvv|authorization|bearer|private_?key|session_?id)/i;
@@ -128,6 +131,15 @@ const isLiteralLike = (e, depth = 0) => {
128
131
  return false;
129
132
  };
130
133
 
134
+ // A concatenation of literals only (`"a=" <> "0" <> "; Path=/"`).
135
+ const constantConcat = (e, depth = 0) => {
136
+ if (!e || depth > 12) return false;
137
+ if (e.kind === 'literal') return true;
138
+ if (e.kind === 'binary' && e.hs && e.hs.concat) return constantConcat(e.left, depth + 1) && constantConcat(e.right, depth + 1);
139
+ if (e.kind === 'call' && /(?:^|\.)(?:pack|fromString|encodeUtf8|fromStrict|toStrict)$/.test(e.callee || '') && e.args && e.args.length === 1) return constantConcat(e.args[0], depth + 1);
140
+ return false;
141
+ };
142
+
131
143
  function fnNodes(fn) { return Object.values(fn.cfg.nodes); }
132
144
 
133
145
  // The text a concatenation spells out, in order, skipping the parts that are not literals (`"session_id=" <> sid <> "; Path=/"` -> `session_id=; Path=/`).
@@ -142,7 +154,100 @@ function literalTextOf(e, depth = 0) {
142
154
  function snippetOf(text, line) {
143
155
  if (!text || !Number.isInteger(line) || line < 1) return '';
144
156
  const l = text.split('\n')[line - 1];
145
- return typeof l === 'string' ? l.trim().slice(0, 240) : '';
157
+ return typeof l === 'string' ? codeOfLine(l).slice(0, 240) : '';
158
+ }
159
+
160
+ // An unbounded read is a resource-exhaustion finding when the party that fills the input is not the operator. A handle opened on a
161
+ // local file, a child process's pipe or a handle passed in is the operator's own (a file's size is what the file system says); a
162
+ // socket or connection handle is filled by the peer. A read of standard input is the operator's in a program's own control flow, and
163
+ // the caller's only in an entry point nothing in the analysed code calls (a library function handing back whatever its caller pipes in).
164
+ const NETWORK_HANDLE_CALL = /(?:^|\.)(?:socketToHandle|connectTo|accept|acceptOn|openTCPConnection)$/;
165
+
166
+ /**
167
+ * Are this function's callers outside the analysed code? With no project-reachability verdict, or an `unknown` one, the answer is exactly what it
168
+ * has always been (nothing in the analysed code calls or references it). A function no caller can reach (`unreachable-in-project`) has none. One
169
+ * reached from an entry point takes its parameters from that path, EXCEPT a root nothing in the analysed code calls (a framework handler, a foreign
170
+ * export, code the parser withheld): its caller is outside.
171
+ *
172
+ * A function of an exposed module (`external-api`) is DELIBERATELY answered as today: the plan for this change said "a public API's callers are
173
+ * external, so true", but for an exposed function that something in the project also calls that turns on the partial-function and unbounded-read
174
+ * rules for every such function, and on the development set it added seven flagged sites and no true positive (precision 35.7% -> 23.8%). The
175
+ * status is still recorded and still keeps the taint finding at full severity; it just does not widen these two rules. Flipping it is the one line
176
+ * below, with that measurement as the cost.
177
+ */
178
+ export function callersUnknown(fn) {
179
+ const hs = (fn && fn.hs) || {};
180
+ const r = hs.reachability || null;
181
+ if (!r || r.status === 'unknown' || r.status === 'external-api') return !hs.reached;
182
+ if (r.status === 'entrypoint-reachable') return !hs.reached && r.rootKind !== 'main';
183
+ return false;
184
+ }
185
+ function readsOutsideInput(fn, callee, args, importsNetwork) {
186
+ const h = args && args[0];
187
+ const isStdin = !!h && h.kind === 'ident' && /(?:^|\.)stdin$/.test(h.name || '');
188
+ const handleRead = /hGetContents$/.test(callee) && !isStdin;
189
+ // the networking packages are not all in the model registry, so a call keeps its bare name; the module imports say which library it is
190
+ if (handleRead) return !!importsNetwork && (fn.calls || []).some((x) => typeof x.callee === 'string' && NETWORK_HANDLE_CALL.test(x.callee));
191
+ const hs = fn.hs || {};
192
+ return callersUnknown(fn) && !/(?:^|\.)main$/.test(fn.name || '');
193
+ }
194
+
195
+ const EQUALITY_OPS = new Set(['==', '/=', '!=']);
196
+ const VERIFY_CALL = /(?:^|\.)(?:constEq|secureCompare|verify\w*|authenticate\w*|checkPassword|validate\w*|hmac\w*|sign\w*|jwt\w*|encode(?:Signed)?)$/i;
197
+ // Whole camelCase / snake_case words: `sig` must not match `design`, `mac` must not match `machine`.
198
+ const CREDENTIAL_WORDS = new Set(['token', 'secret', 'nonce', 'otp', 'csrf', 'apikey', 'session', 'signature', 'sig', 'mac', 'hmac', 'credential', 'credentials', 'auth', 'cookie', 'passcode', 'cert', 'fingerprint']);
199
+ const CREDENTIAL_NAME = { test: (name) => String(name).replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase().split(/[^a-z0-9]+/).some((w) => CREDENTIAL_WORDS.has(w)) };
200
+
201
+ /**
202
+ * Why a weak-hash call is a security matter: the list is empty when the digest is only an identifier or its use is not visible.
203
+ * Everything is read from the function's own IR (a value compared, bound to a credential-shaped name, hashing a secret-shaped value,
204
+ * handed to a signing or verifying call); nothing is read from comments or from the file's name.
205
+ */
206
+ function weakDigestUse(fn, node, hashCall, hashArgs, fnBase) {
207
+ const reasons = [];
208
+ const nodes = Object.values(fn.cfg.nodes);
209
+ const exprsOf = (n) => [n.source, n.value, n.cond, ...(n.args || [])].filter(Boolean);
210
+ const hasNode = (x, target) => { for (const y of walk(x)) if (y === target) return true; return false; };
211
+ // 1. what is hashed
212
+ const hashed = (hashArgs || []).flatMap((a) => [...names(a)]).map((n) => n.replace(/^\./, ''));
213
+ if (hashed.some((n) => SENSITIVE.test(n) && !PASSWORD_VAR.test(n))) reasons.push('hashes a secret-shaped value');
214
+ // 2. values carrying the digest: the assignment target and everything derived from it
215
+ const carriers = new Set();
216
+ // the digest travels through formatting (`show`, `pack`, hex encoding, concatenation), not through an arbitrary call that merely receives it
217
+ const FORMAT = /(?:^|\.)(?:show|pack|unpack|take|drop|toLower|toUpper|showDigest|convertToBase|encode\w*|decode\w*|fromStrict|toStrict|toString|fromString|digestToHexByteString|toHex|hexlify|concat|append|map|fmap|fromText|toText)$/;
218
+ const flowsFrom = (e, depth = 0) => {
219
+ if (!e || depth > 10) return false;
220
+ if (e === hashCall) return true;
221
+ if (e.kind === 'ident') return carriers.has(e.name);
222
+ if (e.kind === 'binary' && e.hs && e.hs.concat) return flowsFrom(e.left, depth + 1) || flowsFrom(e.right, depth + 1);
223
+ if (e.kind === 'call' && typeof e.callee === 'string' && FORMAT.test(e.callee)) return (e.args || []).some((a) => flowsFrom(a, depth + 1));
224
+ if (e.kind === 'call' && e === hashCall) return true;
225
+ return false;
226
+ };
227
+ if (node.kind === 'assign' && node.target && node.source && flowsFrom(node.source)) carriers.add(node.target);
228
+ for (let round = 0; round < 8; round++) {
229
+ let grew = false;
230
+ for (const n of nodes) if (n.kind === 'assign' && n.target && n.source && !carriers.has(n.target) && flowsFrom(n.source)) { carriers.add(n.target); grew = true; }
231
+ if (!grew) break;
232
+ }
233
+ const carries = (x) => !!x && (flowsFrom(x) || [...names(x)].some((y) => carriers.has(y)));
234
+ let compared = false; let signed = false;
235
+ for (const n of nodes) {
236
+ for (const root of exprsOf(n)) {
237
+ for (const x of walk(root)) {
238
+ if (x.kind === 'binary' && EQUALITY_OPS.has(x.op) && (carries(x.left) || carries(x.right))) compared = true;
239
+ if (x.kind === 'call' && typeof x.callee === 'string' && VERIFY_CALL.test(x.callee) && (x.args || []).some((a) => carries(a))) signed = true;
240
+ }
241
+ }
242
+ }
243
+ if (compared) reasons.push('the digest is compared with another value');
244
+ if (signed) reasons.push('the digest is passed to a verifying or signing call');
245
+ // 3. what it becomes: a credential-shaped binding, or the result of a credential-shaped function
246
+ const bound = [...carriers].find((c) => CREDENTIAL_NAME.test(c) || SECURITY_VAR.test(c));
247
+ if (bound) reasons.push(`the digest is bound to \`${bound}\``);
248
+ const returned = nodes.some((n) => n.kind === 'return' && n.value && carries(n.value));
249
+ if (returned && (SECURITY_FN.test(fnBase) || CREDENTIAL_NAME.test(fnBase))) reasons.push(`the digest is the result of \`${fnBase}\``);
250
+ return reasons;
146
251
  }
147
252
 
148
253
  /**
@@ -159,7 +264,7 @@ export function analyzeHaskellRules(files, opts = {}) {
159
264
  const notes = [];
160
265
  if (!Object.keys(hs).length) return { findings, controls, notes };
161
266
 
162
- const ir = buildHaskellIR(hs);
267
+ const ir = buildHaskellIR(hs, opts.reachability || {});
163
268
 
164
269
  // project-level facts
165
270
  let requestLimit = null;
@@ -186,6 +291,7 @@ export function analyzeHaskellRules(files, opts = {}) {
186
291
  };
187
292
 
188
293
  for (const [file, fir] of Object.entries(ir.perFile)) {
294
+ const importsNetwork = (fir.imports || []).some((im) => /^Network(?:\.|$)/.test(im.module));
189
295
  // imports of obsolete primitives (type-directed use is not resolved: disclosed on the finding)
190
296
  for (const imp of fir.imports || []) {
191
297
  if (WEAK_CIPHER_MODULES.has(imp.module)) {
@@ -199,7 +305,14 @@ export function analyzeHaskellRules(files, opts = {}) {
199
305
 
200
306
  const fnBase = fn.name.replace(/^.*\./, '');
201
307
  // text-typed parameters of an exported function, and every local value derived from them
202
- const derivedText = new Set([...((fn.hs && fn.hs.textParams) || []), ...((fn.hs && fn.hs.inputParams) || [])]);
308
+ // A parameter a web framework hands to a handler IS request input wherever the handler is registered. Any other exported
309
+ // function's text parameter is "caller-supplied" only while nothing in the analysed code calls the function: once something does,
310
+ // its parameters take the values those callers pass (a check the taint engine makes per call), and a rule that does not follow
311
+ // calls cannot say the text comes from outside.
312
+ const fnHs = fn.hs || {};
313
+ const strongParam = new Set((fnHs.paramSources || []).filter((p) => /^hs:handler-param:/.test(p.decorator || '')).map((p) => p.name));
314
+ const entryLike = callersUnknown(fn);
315
+ const derivedText = new Set([...(fnHs.textParams || []).filter((n) => strongParam.has(n) || entryLike), ...(entryLike ? (fnHs.inputParams || []) : [])]);
203
316
  if (derivedText.size) {
204
317
  for (let round = 0; round < 8; round++) {
205
318
  let grew = false;
@@ -255,7 +368,7 @@ export function analyzeHaskellRules(files, opts = {}) {
255
368
  if (TIME_SOURCES.has(e.name) && SECURITY_FN.test(fnBase)) emitRandom(node.line, { vuln: 'Clock value used as a security value', detail: `${e.name} feeds \`${fnBase}\``, evidence: { function: fnBase, source: e.name } });
256
369
  // a generator named as the whole value of a security-shaped action (`newCsrfToken = randomIO`): no call is written, the name is the call
257
370
  if (RANDOM_CALLS.has(e.name) && SECURITY_FN.test(fnBase) && !(e.hs && e.hs.functionRef)) emitRandom(node.line, { detail: `${e.name} feeds \`${fnBase}\``, evidence: { function: fnBase, source: e.name } });
258
- if (INPUT_READS.has(e.name) && !bounded) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
371
+ if (INPUT_READS.has(e.name) && !bounded && readsOutsideInput(fn, e.name, [], importsNetwork)) emit('hs-unbounded-input-read', file, node.line, { detail: `${e.name} reads everything`, confidence: 0.6 });
259
372
  continue;
260
373
  }
261
374
  // a Set-Cookie header written by hand: `(hSetCookie, "session_id=" <> sid <> "; Path=/")`
@@ -268,7 +381,8 @@ export function analyzeHaskellRules(files, opts = {}) {
268
381
  const sessionLike = !nm || SESSION_COOKIE.test(nm[1]);
269
382
  const secure = /;\s*Secure\b/i.test(text); const httpOnly = /;\s*HttpOnly\b/i.test(text);
270
383
  // an attribute string that is not written here (a rendered cookie value) cannot be judged: only a cookie whose text is visible is
271
- if (text && sessionLike && (!secure || !httpOnly)) emit('hs-insecure-cookie', file, e.line || node.line, { detail: `${!httpOnly ? 'HttpOnly missing' : ''}${!httpOnly && !secure ? ', ' : ''}${!secure ? 'Secure missing' : ''}`, evidence: { httpOnly, secure, cookie: nm ? nm[1] : null } });
384
+ // a header value that is entirely constant text (`authn=0` written to clear a login) carries no per-user credential
385
+ if (text && sessionLike && !isLiteralLike(v) && !constantConcat(v) && (!secure || !httpOnly)) emit('hs-insecure-cookie', file, e.line || node.line, { detail: `${!httpOnly ? 'HttpOnly missing' : ''}${!httpOnly && !secure ? ', ' : ''}${!secure ? 'Secure missing' : ''}`, evidence: { httpOnly, secure, cookie: nm ? nm[1] : null } });
272
386
  }
273
387
  }
274
388
  if (e.kind === 'object' && e.hs && e.hs.update && Array.isArray(e.props)) {
@@ -324,7 +438,17 @@ export function analyzeHaskellRules(files, opts = {}) {
324
438
  // weak hash
325
439
  if (WEAK_HASH_CALLS.has(c) || (c === 'Crypto.Hash.hashWith' && args[0] && args[0].kind === 'literal' && WEAK_ALGO_CONS.has(args[0].value))) {
326
440
  const pw = args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n)));
327
- if (!pw) emit('hs-weak-hash', file, line, { detail: c });
441
+ // MD5 and SHA-1 are broken for collision and preimage resistance, which only matters where an attacker gains from a
442
+ // collision: a digest that is VERIFIED (compared with an expected value), that authenticates or signs, that hashes a
443
+ // secret, or that is a credential. A digest that only NAMES something (a file name, a cache key, a bookmark, an ETag) is
444
+ // an identifier, and a hash helper handed on to callers says nothing about its use: neither is a finding.
445
+ if (!pw) {
446
+ const use = weakDigestUse(fn, node, e, args, fnBase);
447
+ // The API identity is always reported (a reviewer wants to know where MD5/SHA-1 is computed); it is a vulnerability
448
+ // finding only where a use that depends on the hash's strength is visible. Otherwise it is an `info` note.
449
+ if (use.length) emit('hs-weak-hash', file, line, { detail: `${c}: ${use.join('; ')}`, evidence: { call: c, use } });
450
+ else emit('hs-weak-hash', file, line, { severity: 'info', confidence: 0.4, detail: `${c}: no use that depends on the digest's collision or preimage resistance is visible (it may only name or key something)`, evidence: { call: c, use: [] } });
451
+ }
328
452
  }
329
453
  // password hashed with a fast hash
330
454
  if (ANY_HASH_CALLS.has(c) && !STRONG_KDF.test(c) && args.some((a) => [...names(a)].some((n) => PASSWORD_VAR.test(n.replace(/^\./, ''))))) {
@@ -346,7 +470,7 @@ export function analyzeHaskellRules(files, opts = {}) {
346
470
  emitRandom(line, { detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
347
471
  }
348
472
  if (TIME_SOURCES.has(c) && SECURITY_FN.test(fnBase)) emitRandom(line, { vuln: 'Clock value used as a security value', detail: `${c} feeds \`${fnBase}\``, evidence: { function: fnBase, source: c } });
349
- if (INPUT_READS.has(c) && !bounded) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
473
+ if (INPUT_READS.has(c) && !bounded && readsOutsideInput(fn, c, args, importsNetwork)) emit('hs-unbounded-input-read', file, line, { detail: `${c} reads everything`, confidence: 0.6 });
350
474
  // an allocation whose size is parsed caller text and is not clamped
351
475
  if (ALLOCATORS.has(c) && args[0] && mentionsText(args[0]) && callsIn(args[0]).some((x) => /(?:^|\.)(?:read|readMaybe|readEither)$/.test(x.callee)) && !callsIn(args[0]).some((x) => CLAMPS.test(x.callee))) {
352
476
  emit('hs-unbounded-allocation', file, line, { detail: `${c} sized by parsed caller text`, evidence: { allocator: c } });
@@ -369,7 +493,7 @@ export function analyzeHaskellRules(files, opts = {}) {
369
493
  if (yes) emit('hs-xml-entity-substitution', file, line, { detail: 'withSubstDTDEntities yes' });
370
494
  }
371
495
  // boundaries
372
- if (UNSAFE_CALLS[c]) {
496
+ if (Object.hasOwn(UNSAFE_CALLS, c)) {
373
497
  const b = UNSAFE_CALLS[c];
374
498
  emit('hs-unsafe-io-boundary', file, line, { vuln: `Unsafe boundary: ${b}`, confidence: 0.5, modelNote: BOUNDARIES[b], detail: BOUNDARIES[b] });
375
499
  }
@@ -840,7 +840,8 @@ export function parseSyntax(src, opts = {}) {
840
840
  // `foreign import ccall unsafe "string.h strlen" c_strlen :: ...` names the Haskell-side function.
841
841
  let fname = null;
842
842
  for (let q = a; q < b; q++) if (isRop(T[q], '::')) { if (q > a && T[q - 1].t === 'var') fname = T[q - 1].v; break; }
843
- res.boundaries.push({ kind: 'ffi', line: t0.line, detail: 'foreign declaration; the foreign code is not analyzed', ...(fname ? { name: fname } : {}) });
843
+ const dir = T[a + 1] && (T[a + 1].v === 'import' || T[a + 1].v === 'export') ? T[a + 1].v : null;
844
+ res.boundaries.push({ kind: 'ffi', line: t0.line, detail: 'foreign declaration; the foreign code is not analyzed', ...(dir ? { direction: dir } : {}), ...(fname ? { name: fname } : {}) });
844
845
  continue;
845
846
  }
846
847
  if (t0.v === 'class' || t0.v === 'instance') {