@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.
- package/CHANGELOG.md +32 -0
- package/dist/{312ef9976a988b322ade.json → 2bbc3428df6b8d66516c.json} +3 -3
- package/dist/agentic-security.mjs +4 -4
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog-expanded.js +5 -1
- package/src/dataflow/engine.js +7 -0
- package/src/engine.js +6 -1
- package/src/language/CLAUDE.md +7 -3
- package/src/language/haskell-fix.js +108 -2
- package/src/language/haskell-guards.js +10 -0
- package/src/language/haskell-ir.js +253 -10
- package/src/language/haskell-models.js +14 -2
- package/src/language/haskell-parser.js +4 -1
- package/src/language/haskell-security-rules.js +82 -7
- package/src/language/haskell-syntax.js +34 -6
- package/src/posture/corpus-match.js +12 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|