@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.
- package/CHANGELOG.md +19 -0
- package/dist/{deabc47d0e27e21bf99b.json → 051c3705cb9cb458cd2f.json} +7 -4
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -4
- package/src/dataflow/CLAUDE.md +13 -0
- package/src/dataflow/catalog.js +14 -0
- package/src/dataflow/engine.js +133 -11
- package/src/engine.js +33 -5
- package/src/ir/index.js +6 -5
- package/src/language/CLAUDE.md +9 -0
- package/src/language/haskell-comments.js +65 -0
- package/src/language/haskell-findings.js +2 -1
- package/src/language/haskell-fix.js +118 -2
- package/src/language/haskell-guards.js +2 -2
- package/src/language/haskell-ir.js +354 -16
- package/src/language/haskell-manifests.js +88 -5
- package/src/language/haskell-models.js +15 -4
- package/src/language/haskell-parser.js +18 -4
- package/src/language/haskell-reachability.js +84 -0
- package/src/language/haskell-security-rules.js +134 -10
- package/src/language/haskell-syntax.js +2 -1
- package/src/language/haskell-trust.js +286 -0
- package/src/language/haskell-web.js +5 -4
- package/src/posture/proof-fabric/container.js +13 -2
- package/src/posture/relevance.js +21 -1
- package/src/sast/cloud-iam.js +3 -1
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
src('
|
|
91
|
-
src('
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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') {
|