@clear-capabilities/agentic-security-scanner 0.195.10 → 0.195.12

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.
@@ -16,6 +16,8 @@ export class SyntaxBudgetError extends Error {}
16
16
  const KW = new Set(['case', 'class', 'data', 'default', 'deriving', 'do', 'else', 'foreign', 'if', 'import', 'in', 'infix', 'infixl', 'infixr', 'instance', 'let', 'module', 'newtype', 'of', 'then', 'type', 'where']);
17
17
  // An annotation naming a plain number or a boolean, alone or inside Maybe / IO / an action monad: a value of that type cannot carry caller text.
18
18
  const NUMERIC_TYPE = /^\(?\s*(?:(?:Maybe|IO|ActionM|Handler|Either\s+String)\s+)?\(?\s*(?:Int|Integer|Int(?:8|16|32|64)|Word|Word(?:8|16|32|64)|Double|Float|Natural|Bool|Scientific)\s*\)?\s*\)?$/;
19
+ // The module enables Template Haskell (a LANGUAGE pragma; a cabal `default-extensions` entry is not seen here, a stated limit).
20
+ const TH_PRAGMA = /\{-#\s*LANGUAGE\b[^#]*\bTemplateHaskell\b/;
19
21
  const RESERVED_OPS = new Set(['=', '|', '->', '<-', '::', '=>', '\\', '@', '~', '..']);
20
22
  const SYM = new Set('!#$%&*+./<=>?@\\^|-~:'.split(''));
21
23
  const OPEN = { '(': ')', '[': ']', '{': '}' };
@@ -30,9 +32,10 @@ const NUM = /0[xX][0-9a-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|\d[\d_]*(?:\.\d[\d_]*)?
30
32
  * t is var|con|op|rop|kw|int|float|str|chr|sp. `first` is true for the first
31
33
  * token on its line; `col` is the 0-based column with tabs at 8.
32
34
  */
33
- export function lexHs(src) {
35
+ export function lexHs(src, opts = {}) {
34
36
  const T = [];
35
37
  const n = src.length;
38
+ const thSplices = !!opts.th;
36
39
  let i = 0;
37
40
  let line = 1;
38
41
  let col = 0;
@@ -142,6 +145,14 @@ export function lexHs(src) {
142
145
  push('sp', c, {}, 1);
143
146
  continue;
144
147
  }
148
+ // A Template Haskell splice in prefix position, `$(..)`, `$$(..)` or `$name`, with the extension on: one `splice` token, so the expression
149
+ // around it still parses (`pure (x, $(widgetFile "add-form"))`, real Yesod code). Without the extension `f $(g x)` is the ordinary `$`.
150
+ if (c === '$' && thSplices) {
151
+ const k = src[i + 1] === '$' ? i + 2 : i + 1;
152
+ const prev = i > 0 ? src[i - 1] : ' ';
153
+ const prefix = !(isIdChar(prev) || isSym(prev) || prev === ')' || prev === ']' || prev === '}' || prev === '"' || prev === "'");
154
+ if (prefix && (src[k] === '(' || (isIdStart(src[k]) && !isUpper(src[k])))) { push('splice', src.slice(i, k), {}, k - i); continue; }
155
+ }
145
156
  if (isSym(c)) {
146
157
  let j = i;
147
158
  while (isSym(src[j])) j++;
@@ -185,7 +196,7 @@ const BT_FIX = { elem: [4, 'l'], notElem: [4, 'l'], div: [7, 'l'], mod: [7, 'l']
185
196
  * @returns {{tokens:object[], module:object|null, imports:object[], decls:object[], sigs:object[], data:object[], classes:object[], instances:object[], errors:object[], boundaries:object[]}}
186
197
  */
187
198
  export function parseSyntax(src, opts = {}) {
188
- const T = lexHs(src);
199
+ const T = lexHs(src, { th: TH_PRAGMA.test(src) });
189
200
  const mate = matchBrackets(T);
190
201
  const N = T.length;
191
202
  const errors = [];
@@ -317,7 +328,9 @@ export function parseSyntax(src, opts = {}) {
317
328
  return [t.v === '_' ? { t: 'pwild' } : { t: 'pvar', name: t.v }, i + 1];
318
329
  }
319
330
  if (t.t === 'con') {
320
- if (isSp(T[i + 1], '{') && mate[i + 1] > i + 1 && T[i + 1].off === t.end) {
331
+ // A `{` after a constructor inside a pattern is always a record pattern (patterns have no record update and no explicit layout block), so
332
+ // `KeterConfig {..}` with a space before the brace is read like `KeterConfig{..}`. Real code (keter, hackage-server) writes the space.
333
+ if (isSp(T[i + 1], '{') && mate[i + 1] > i + 1) {
321
334
  const close = mate[i + 1];
322
335
  const fields = [];
323
336
  for (const [s, e] of splitTop(i + 2, close, comma)) {
@@ -509,7 +522,7 @@ export function parseSyntax(src, opts = {}) {
509
522
  function startsAexp() {
510
523
  const t = peek();
511
524
  if (!t) return false;
512
- if (t.t === 'var' || t.t === 'con' || t.t === 'int' || t.t === 'float' || t.t === 'str' || t.t === 'chr') return true;
525
+ if (t.t === 'var' || t.t === 'con' || t.t === 'int' || t.t === 'float' || t.t === 'str' || t.t === 'chr' || t.t === 'splice') return true;
513
526
  return isSp(t, '(') || isSp(t, '[');
514
527
  }
515
528
  function fexp() {
@@ -535,6 +548,13 @@ export function parseSyntax(src, opts = {}) {
535
548
  else if (t.t === 'int' || t.t === 'float' || t.t === 'str' || t.t === 'chr') { pos++; e = { t: 'lit', kind: t.t, v: t.v, line: t.line }; }
536
549
  else if (isSp(t, '(') && mate[pos] > pos && mate[pos] < lim) { const close = mate[pos]; e = parenExpr(pos, close); pos = close + 1; }
537
550
  else if (isSp(t, '[') && mate[pos] > pos && mate[pos] < lim) { const close = mate[pos]; e = listExpr(pos, close); pos = close + 1; }
551
+ else if (t.t === 'splice') {
552
+ // The spliced code is generated at compile time: an opaque value here, disclosed by the parser's `th-splice` boundary.
553
+ pos++;
554
+ if (isSp(peek(), '(') && mate[pos] > pos && mate[pos] < lim) pos = mate[pos] + 1;
555
+ else if (peek() && peek().t === 'var') pos++;
556
+ return { t: 'splice', line: t.line };
557
+ }
538
558
  else { err('unexpected-token', t, `unexpected token ${t.v}`); pos++; return { t: 'unknown', line: t.line }; }
539
559
  while (isSp(peek(), '{') && mate[pos] > pos && mate[pos] < lim && (e.t === 'con' || e.t === 'var' || e.t === 'rec' || e.t === 'paren')) {
540
560
  const close = mate[pos];
@@ -577,7 +597,14 @@ export function parseSyntax(src, opts = {}) {
577
597
  const line = T[open].line;
578
598
  if (a === close) return { t: 'list', items: [], line };
579
599
  const bar = findTop(a, close, (x) => isRop(x, '|'));
580
- if (bar > a) {
600
+ // A `|` after a top-level `of` (case alternatives), `\case` or a multiway `if` inside the brackets is a guard of that construct, not the
601
+ // bar of a comprehension: `[ a, case m of { Nothing -> ""; Just p | p > 0 -> "" }, b ]` is a three-element list (real code, keter).
602
+ let guardOwner = false;
603
+ for (let i = a; bar > a && i < bar; i = Math.min(bar, groupEnd(i))) {
604
+ const x = T[i];
605
+ if (isKw(x, 'of') || (isRop(x, '\\') && isKw(T[i + 1], 'case')) || (isKw(x, 'if') && isRop(T[i + 1], '|'))) { guardOwner = true; break; }
606
+ }
607
+ if (bar > a && !guardOwner) {
581
608
  const head = sub(a, bar, expr0);
582
609
  const quals = splitTop(bar + 1, close, comma).map(([s, e]) => parseQual(s, e));
583
610
  return { t: 'listcomp', head, quals, line };
@@ -689,7 +716,8 @@ export function parseSyntax(src, opts = {}) {
689
716
  name = T[infixAt].v;
690
717
  pats = [patRange(a, infixAt === infixEnd - 1 ? infixAt : infixAt), patRange(infixEnd, mark)];
691
718
  if (isSp(T[infixAt - 1], '`')) pats[0] = patRange(a, infixAt - 1);
692
- } else if (T[a].t === 'var' && !T[a].q) {
719
+ } else if (T[a].t === 'var' && !T[a].q && !(isRop(T[a + 1], '@') && T[a + 1].off === T[a].end)) {
720
+ // (`hooks@TLS.ServerHooks{..} = ...` is an as-pattern binding, handled as a pattern binding below, not a function named `hooks`.)
693
721
  name = T[a].v;
694
722
  for (let q = a + 1; q < mark;) {
695
723
  const r = apatAt(q, mark);
@@ -47,9 +47,21 @@ function _any(scan, predicate) {
47
47
  return false;
48
48
  }
49
49
 
50
+ // A finding the scanner itself has PROVED clean (`proof.verdict` `proven-*`: the sink is reached only through a sanitizer that covers the
51
+ // finding's class, or only through a parameterizer) is a refuted report, not a detection: the fix lifecycle already drops it and the report
52
+ // marks it refuted. It scores on neither side, so a fix that sanitises through a recognised escaper reads as `post:TN` and a vulnerable
53
+ // shape the engine wrongly proves clean reads as `pre:FN`, both the honest direction. Added for the first real-advisory Haskell entry (keter,
54
+ // HSEC-2024-0001, whose fix is an HTML escaper); the full baseline was re-run and no synthetic entry's verdict moved.
55
+ function _refuted(f) { return !!(f && f.proof && typeof f.proof.verdict === 'string' && f.proof.verdict.startsWith('proven-')); }
56
+ // A finding at `info` with a guard note is one the engine lowered on evidence it read (a non-HTML content type, a redirect body, a fixed
57
+ // host): kept in the report so the lowering is visible, not a vulnerability it reports. It scores on neither side either. An `info`
58
+ // finding WITHOUT such a note (a rule that is informational by design) still scores, so no synthetic entry's verdict moves.
59
+ function _loweredOnEvidence(f) { return !!(f && f.severity === 'info' && f.guardNote && typeof f.guardNote === 'object'); }
60
+
50
61
  // One predicate, both sides. See the header for why the symmetric form is the
51
62
  // LOOSE one.
52
63
  function _matches(f, manifest, matcher) {
64
+ if (_refuted(f) || _loweredOnEvidence(f)) return false;
53
65
  return (matcher.test(f.vuln || '') || matcher.test(f.family || '')) &&
54
66
  (manifest?.cwe ? f.cwe === manifest.cwe || matcher.test(f.cwe || '') : true);
55
67
  }