@clear-capabilities/agentic-security-scanner 0.185.0 → 0.186.2
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 +34 -0
- package/bin/agentic-security.js +66 -18
- package/dist/1128.index.js +25 -21
- package/dist/{5817.index.js → 1602.index.js} +378 -29
- package/dist/2584.index.js +1 -1
- package/dist/4171.index.js +2 -2
- package/dist/{0b301d45b231f144a1b4.json → 4d06c8944ae121d7085c.json} +8 -8
- package/dist/5752.index.js +466 -0
- package/dist/7405.index.js +1 -1
- package/dist/8158.index.js +1713 -0
- package/dist/8798.index.js +1 -1
- package/dist/9382.index.js +2 -2
- package/dist/9560.index.js +1 -1
- package/dist/9579.index.js +1 -1
- package/dist/agentic-security.mjs +29 -29
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +8 -8
- package/src/dataflow/CLAUDE.md +6 -3
- package/src/dataflow/access-paths.js +12 -2
- package/src/dataflow/demand-query.js +279 -40
- package/src/dataflow/dep-fingerprint.js +1 -1
- package/src/dataflow/exploit-prover.js +6 -2
- package/src/dataflow/ir-cache.js +268 -0
- package/src/dataflow/privacy-taint.js +7 -0
- package/src/engine.js +62 -5
- package/src/ir/CLAUDE.md +4 -0
- package/src/ir/callgraph.js +36 -8
- package/src/ir/index.js +18 -4
- package/src/ir/parser-js.js +10 -0
- package/src/language/CLAUDE.md +13 -0
- package/src/language/nix-option-types.js +170 -0
- package/src/language/nix-script-taint.js +46 -3
- package/src/language/nix-secrets.js +20 -2
- package/src/lineage/CLAUDE.md +2 -1
- package/src/lineage/smt-orchestrator.js +47 -11
- package/src/lineage/smt-z3-backend.js +294 -0
- package/src/posture/CLAUDE.md +5 -1
- package/src/posture/business-logic.js +16 -7
- package/src/posture/dag-commands.js +442 -0
- package/src/posture/finding-defaults.js +19 -0
- package/src/posture/git-history.js +91 -1
- package/src/posture/proof-fabric/cli.js +1 -1
- package/src/posture/proof-fabric/container.js +2 -2
- package/src/posture/proof-fabric/generate-haskell.js +205 -0
- package/src/posture/proof-fabric/generate.js +6 -3
- package/src/posture/proof-fabric/reproducer.js +4 -2
- package/src/posture/proof-fabric/runner.js +4 -4
- package/src/sast/CLAUDE.md +2 -0
- package/src/sast/_call-args.js +70 -0
- package/src/sast/init-scope.js +87 -0
- package/src/sast/listen-bind-address.js +49 -0
- package/src/sast/redirect-target.js +106 -0
- package/src/sast/reflected-input.js +76 -0
- package/src/sast/send-file-root.js +27 -0
- package/src/sast/sensitive-data-logging.js +38 -1
- package/dist/119.index.js +0 -197
- package/dist/128.index.js +0 -129
- package/dist/1293.index.js +0 -5689
- package/dist/1336.index.js +0 -16331
- package/dist/1351.index.js +0 -133
- package/dist/1387.index.js +0 -271
- package/dist/1437.index.js +0 -23
- package/dist/1445.index.js +0 -110
- package/dist/1505.index.js +0 -151
- package/dist/1557.index.js +0 -293
- package/dist/1621.index.js +0 -311
- package/dist/1751.index.js +0 -160
- package/dist/1765.index.js +0 -727
- package/dist/1861.index.js +0 -690
- package/dist/1906.index.js +0 -433
- package/dist/208.index.js +0 -1631
- package/dist/2223.index.js +0 -159
- package/dist/2238.index.js +0 -295
- package/dist/2530.index.js +0 -152
- package/dist/2565.index.js +0 -116
- package/dist/2574.index.js +0 -127
- package/dist/2620.index.js +0 -345
- package/dist/2773.index.js +0 -98
- package/dist/2775.index.js +0 -32
- package/dist/2966.index.js +0 -171
- package/dist/2972.index.js +0 -87
- package/dist/2991.index.js +0 -131
- package/dist/3063.index.js +0 -833
- package/dist/3075.index.js +0 -227
- package/dist/3121.index.js +0 -297
- package/dist/3314.index.js +0 -263
- package/dist/3480.index.js +0 -1822
- package/dist/3481.index.js +0 -24
- package/dist/3506.index.js +0 -268
- package/dist/3533.index.js +0 -591
- package/dist/3653.index.js +0 -450
- package/dist/3740.index.js +0 -722
- package/dist/3822.index.js +0 -131
- package/dist/3868.index.js +0 -292
- package/dist/3918.index.js +0 -192
- package/dist/4090.index.js +0 -288
- package/dist/4113.index.js +0 -1286
- package/dist/4121.index.js +0 -1190
- package/dist/4142.index.js +0 -690
- package/dist/4168.index.js +0 -509
- package/dist/4281.index.js +0 -993
- package/dist/4336.index.js +0 -436
- package/dist/4387.index.js +0 -532
- package/dist/4413.index.js +0 -302
- package/dist/4476.index.js +0 -4510
- package/dist/4491.index.js +0 -133
- package/dist/4538.index.js +0 -185
- package/dist/4684.index.js +0 -319
- package/dist/4740.index.js +0 -165
- package/dist/4780.index.js +0 -209
- package/dist/4802.index.js +0 -225
- package/dist/4812.index.js +0 -379
- package/dist/4835.index.js +0 -126
- package/dist/494.index.js +0 -226
- package/dist/4991.index.js +0 -549
- package/dist/5199.index.js +0 -305
- package/dist/5216.index.js +0 -109
- package/dist/5439.index.js +0 -301
- package/dist/5443.index.js +0 -97
- package/dist/5496.index.js +0 -377
- package/dist/5738.index.js +0 -240
- package/dist/5801.index.js +0 -588
- package/dist/5858.index.js +0 -223
- package/dist/5933.index.js +0 -143
- package/dist/601.index.js +0 -23
- package/dist/6173.index.js +0 -273
- package/dist/6291.index.js +0 -1871
- package/dist/6312.index.js +0 -666
- package/dist/634.index.js +0 -16182
- package/dist/644.index.js +0 -87
- package/dist/6508.index.js +0 -283
- package/dist/6560.index.js +0 -645
- package/dist/6576.index.js +0 -422
- package/dist/6579.index.js +0 -271
- package/dist/6655.index.js +0 -3221
- package/dist/6745.index.js +0 -305
- package/dist/6818.index.js +0 -21642
- package/dist/6932.index.js +0 -1190
- package/dist/6972.index.js +0 -167
- package/dist/6995.index.js +0 -163
- package/dist/7249.index.js +0 -191
- package/dist/7372.index.js +0 -1197
- package/dist/7546.index.js +0 -541
- package/dist/76.index.js +0 -448
- package/dist/7879.index.js +0 -163
- package/dist/8025.index.js +0 -130
- package/dist/8054.index.js +0 -248
- package/dist/808.index.js +0 -1330
- package/dist/8097.index.js +0 -201
- package/dist/8126.index.js +0 -191
- package/dist/8178.index.js +0 -78
- package/dist/8382.index.js +0 -93
- package/dist/8408.index.js +0 -261
- package/dist/8540.index.js +0 -174
- package/dist/8635.index.js +0 -802
- package/dist/8688.index.js +0 -410
- package/dist/8699.index.js +0 -779
- package/dist/8891.index.js +0 -457
- package/dist/8916.index.js +0 -885
- package/dist/9146.index.js +0 -170
- package/dist/9168.index.js +0 -841
- package/dist/9292.index.js +0 -369
- package/dist/9422.index.js +0 -866
- package/dist/9440.index.js +0 -453
- package/dist/9479.index.js +0 -152
- package/dist/9481.index.js +0 -220
- package/dist/9539.index.js +0 -100
- package/dist/9547.index.js +0 -103
- package/dist/9692.index.js +0 -90
- package/dist/9800.index.js +0 -462
- package/dist/9899.index.js +0 -138
- package/dist/9981.index.js +0 -206
- package/dist/999.index.js +0 -117
- package/dist/9999.index.js +0 -1698
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Reproducer generation for Haskell (PRD 1), the third language of `generate.js`. Same contract as JavaScript and Python: a
|
|
2
|
+
// DETERMINISTIC proposal built from the flagged file alone, then run through the ordinary runner, oracle, 3-of-3 repetition and
|
|
3
|
+
// independent replay. A generated reproducer is a proposal that execution either confirms or does not.
|
|
4
|
+
//
|
|
5
|
+
// The program is run by GHC's interpreter (`ghc -e`), not compiled. Why: it needs no C compiler and no linker (so no dependence on
|
|
6
|
+
// the platform's developer tools), it leaves no object files or binaries to account for, it starts in about half a second where a
|
|
7
|
+
// compile-then-run takes a second or more, and there is one process tree to confine instead of a build stage followed by a run
|
|
8
|
+
// stage. Interpreted bytecode calls the same installed libraries a compiled program would, so a shell string reaches the same
|
|
9
|
+
// `System.Process` either way. The tradeoff is stated, not hidden: a program whose behaviour depends on compiled code (FFI, an
|
|
10
|
+
// optimisation-sensitive race) is refused, and `-O` is never in play. A hand-written reproducer may still use `build`/`run` with
|
|
11
|
+
// `{{GHC}}` to compile; both paths run under the same sandbox, timeout and output cap.
|
|
12
|
+
//
|
|
13
|
+
// Two shapes, as in the other languages:
|
|
14
|
+
// script the file reads `getArgs`: it is run as `:main "<payload>"`, so the payload arrives as the first argument;
|
|
15
|
+
// function a top-level function around the flagged line that has a type signature of `String`/`FilePath` parameters and an `IO`
|
|
16
|
+
// result: `ghc -e 'fn "<payload>" ...'` loads the module with every top-level name in scope (the module need not export
|
|
17
|
+
// it) and calls it with the payload in every parameter.
|
|
18
|
+
//
|
|
19
|
+
// Only what the proof environment can load is accepted: the file's imports must be modules of the packages that ship with GHC (the
|
|
20
|
+
// global package database, read from the installed GHC) or other `.hs` files in the repository. A web framework, a database binding
|
|
21
|
+
// or any Hackage package is refused with the package named where it is known. Template Haskell, quasi-quotes, CPP, the foreign
|
|
22
|
+
// function interface and compiler-plugin options are refused too: they run code at load time, before the payload is involved.
|
|
23
|
+
//
|
|
24
|
+
// Classes: command injection (CWE-78) and path traversal by a write (CWE-22). SSRF (CWE-918) is refused for Haskell on purpose: no
|
|
25
|
+
// HTTP client ships with GHC, so a request-making program necessarily imports a package the stdlib-only rule already refuses, and
|
|
26
|
+
// nothing could be proven honestly. Nothing is executed on a refusal.
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import { parseHaskell } from '../../language/haskell-parser.js';
|
|
31
|
+
import { validateReproducer } from './reproducer.js';
|
|
32
|
+
import { hostTools } from './runner.js';
|
|
33
|
+
|
|
34
|
+
const MAX_SOURCE = 256 * 1024;
|
|
35
|
+
const MAX_FILES = 8;
|
|
36
|
+
const fail = (reason, extra = {}) => ({ ok: false, reason, ...extra });
|
|
37
|
+
|
|
38
|
+
// Well-known packages that are NOT part of a GHC install, by module prefix, so a refusal can name the package to install.
|
|
39
|
+
const THIRD_PARTY = [
|
|
40
|
+
['Web.Scotty', 'scotty'], ['Network.Wai', 'wai'], ['Network.HTTP.Types', 'http-types'], ['Network.HTTP.Client', 'http-client'],
|
|
41
|
+
['Network.HTTP.Simple', 'http-conduit'], ['Network.HTTP.Conduit', 'http-conduit'], ['Network.Wreq', 'wreq'], ['Network.Socket', 'network'],
|
|
42
|
+
['Servant', 'servant'], ['Yesod', 'yesod'], ['Snap', 'snap'], ['Happstack', 'happstack-server'], ['Data.Aeson', 'aeson'],
|
|
43
|
+
['Database.SQLite.Simple', 'sqlite-simple'], ['Database.PostgreSQL.Simple', 'postgresql-simple'], ['Database.Persist', 'persistent'],
|
|
44
|
+
['Database.MySQL', 'mysql-simple'], ['System.Random', 'random'], ['Data.Vector', 'vector'], ['Data.Hashable', 'hashable'],
|
|
45
|
+
['Data.HashMap', 'unordered-containers'], ['Control.Lens', 'lens'], ['Test.', 'a test framework'], ['Turtle', 'turtle'],
|
|
46
|
+
['Shelly', 'shelly'], ['System.Process.Typed', 'typed-process'], ['Conduit', 'conduit'], ['Control.Monad.Logger', 'monad-logger'],
|
|
47
|
+
['Options.Applicative', 'optparse-applicative'], ['Data.Text.Lazy.Builder', null],
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const libCache = new Map();
|
|
51
|
+
/** The modules the installed GHC exposes from its global package database (what a program can import with no extra packages). */
|
|
52
|
+
export function bootModuleSet(ghc) {
|
|
53
|
+
if (libCache.has(ghc)) return libCache.get(ghc);
|
|
54
|
+
let set = null;
|
|
55
|
+
try {
|
|
56
|
+
const r = spawnSync(ghc, ['--print-libdir'], { encoding: 'utf8', timeout: 10_000 });
|
|
57
|
+
const dir = r.status === 0 ? path.join(r.stdout.trim(), 'package.conf.d') : null;
|
|
58
|
+
if (dir && fs.existsSync(dir)) {
|
|
59
|
+
set = new Set();
|
|
60
|
+
for (const f of fs.readdirSync(dir)) {
|
|
61
|
+
if (!f.endsWith('.conf')) continue;
|
|
62
|
+
const text = fs.readFileSync(path.join(dir, f), 'utf8');
|
|
63
|
+
if (/^exposed:\s*False/m.test(text)) continue;
|
|
64
|
+
const m = /^exposed-modules:\s*\n?([\s\S]*?)(?=^\S)/m.exec(text);
|
|
65
|
+
if (!m) continue;
|
|
66
|
+
for (const tok of m[1].split(/[\s,]+/)) if (/^[A-Z][\w']*(?:\.[A-Z][\w']*)*$/.test(tok)) set.add(tok);
|
|
67
|
+
}
|
|
68
|
+
if (!set.has('Prelude')) set = null; // a database that does not even list Prelude was not read correctly
|
|
69
|
+
}
|
|
70
|
+
} catch { set = null; }
|
|
71
|
+
libCache.set(ghc, set);
|
|
72
|
+
return set;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readInside(root, rel) {
|
|
76
|
+
let real; try { real = fs.realpathSync(path.resolve(root, rel)); } catch { return null; }
|
|
77
|
+
const base = fs.realpathSync(root);
|
|
78
|
+
if (!(real === base || real.startsWith(base + path.sep))) return null;
|
|
79
|
+
const st = fs.statSync(real);
|
|
80
|
+
return st.isFile() && st.size <= MAX_SOURCE ? { real, text: fs.readFileSync(real, 'utf8') } : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Split a type on its top-level arrows (arrows inside parentheses or brackets belong to a function-typed argument). */
|
|
84
|
+
function splitArrows(type) {
|
|
85
|
+
const parts = []; let depth = 0; let cur = '';
|
|
86
|
+
for (let i = 0; i < type.length; i++) {
|
|
87
|
+
const c = type[i];
|
|
88
|
+
if ('([{'.includes(c)) depth++; else if (')]}'.includes(c)) depth--;
|
|
89
|
+
if (depth === 0 && c === '-' && type[i + 1] === '>') { parts.push(cur.trim()); cur = ''; i++; continue; }
|
|
90
|
+
cur += c;
|
|
91
|
+
}
|
|
92
|
+
parts.push(cur.trim());
|
|
93
|
+
return parts;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Compile-time code execution and options that change what runs before the payload is involved.
|
|
97
|
+
function loadTimeHazard(text) {
|
|
98
|
+
if (/\{-#\s*LANGUAGE[^#]*\b(?:TemplateHaskell|TemplateHaskellQuotes|QuasiQuotes|CPP|ForeignFunctionInterface|CApiFFI)\b/i.test(text)) return 'it enables Template Haskell, quasi-quotes, CPP or the foreign function interface, which run or require code at load time';
|
|
99
|
+
if (/\{-#\s*OPTIONS(?:_GHC)?\b[^#]*(?:-fplugin|-pgm|-opt[a-zA-Z]|-F\b|-cpp|-fobject-code)/i.test(text)) return 'its OPTIONS_GHC pragma names a plugin, a preprocessor or an external program';
|
|
100
|
+
if (/^\s*foreign\s+(?:import|export)\b/m.test(text)) return 'it uses the foreign function interface';
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function classify(mod, pkg, boot) {
|
|
105
|
+
if (pkg) return { ok: false, name: String(pkg).replace(/^"|"$/g, '') };
|
|
106
|
+
if (boot.has(mod)) return { ok: true };
|
|
107
|
+
for (const [prefix, name] of THIRD_PARTY) if (mod === prefix || mod.startsWith(prefix.endsWith('.') ? prefix : `${prefix}.`)) return { ok: false, name: name ?? mod };
|
|
108
|
+
return { ok: false, name: mod };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @param {{finding: object, root: string, cls: {name: string, payload: string, oracle: object, network?: string, watch?: object, needsHs?: RegExp}, bootModules?: Set<string>}} o */
|
|
112
|
+
export function generateHaskellReproducer({ finding, root, cls, bootModules }) {
|
|
113
|
+
const rel = String(finding.file);
|
|
114
|
+
if (cls.name === 'ssrf') return fail('ssrf: no HTTP client ships with GHC, so any program that makes a request imports a package outside the proof environment; this class is not proven for Haskell');
|
|
115
|
+
const src = readInside(root, rel);
|
|
116
|
+
if (!src) return fail('the flagged file is missing, outside the repository, or too large');
|
|
117
|
+
const hazard = loadTimeHazard(src.text);
|
|
118
|
+
if (hazard) return fail(`the flagged file is refused: ${hazard}`);
|
|
119
|
+
const info = parseHaskell(src.text);
|
|
120
|
+
if (!info || info.status !== 'parsed') return fail('the flagged file could not be parsed as Haskell');
|
|
121
|
+
const lines = src.text.split('\n');
|
|
122
|
+
const sinkLine = lines[finding.line - 1] ?? '';
|
|
123
|
+
const around = lines.slice(Math.max(0, finding.line - 3), finding.line + 2).join('\n');
|
|
124
|
+
if (cls.needsHs && !cls.needsHs.test(sinkLine) && !cls.needsHs.test(around)) return fail(`${cls.name}: the flagged code is not a write, and this class is only proven for write sinks`);
|
|
125
|
+
|
|
126
|
+
const tools = hostTools();
|
|
127
|
+
const boot = bootModules ?? (tools.ghc ? bootModuleSet(tools.ghc) : null);
|
|
128
|
+
if (!boot) return fail('no working GHC was found to list the modules the proof environment can import (install ghc, or run `agentic-security proof doctor`)');
|
|
129
|
+
|
|
130
|
+
// the entry module is placed at its module path so that other modules can import it; Main goes to Main.hs
|
|
131
|
+
const modName = info.module?.name ?? 'Main';
|
|
132
|
+
const entry = `${modName.replaceAll('.', '/')}.hs`;
|
|
133
|
+
const rootReal = fs.realpathSync(root);
|
|
134
|
+
const entryDir = path.dirname(src.real);
|
|
135
|
+
const modRel = modName.replaceAll('.', path.sep);
|
|
136
|
+
const srcRoot = src.real.endsWith(`${modRel}.hs`) ? src.real.slice(0, src.real.length - modRel.length - 3) : `${entryDir}${path.sep}`;
|
|
137
|
+
const searchRoots = [...new Set([srcRoot.replace(/[\\/]$/, ''), ...['src', 'app', 'lib', ''].map((d) => path.join(rootReal, d))])];
|
|
138
|
+
const sources = {}; const external = []; const seen = new Set([modName]);
|
|
139
|
+
let count = 0;
|
|
140
|
+
const visit = (imports) => {
|
|
141
|
+
for (const imp of imports) {
|
|
142
|
+
const mod = imp.module;
|
|
143
|
+
if (seen.has(mod)) continue;
|
|
144
|
+
seen.add(mod);
|
|
145
|
+
let local = null;
|
|
146
|
+
for (const base of searchRoots) { const cand = path.join(base, `${mod.replaceAll('.', '/')}.hs`); const r = readInside(root, path.relative(rootReal, cand)); if (r) { local = r; break; } }
|
|
147
|
+
if (local && !imp.package) {
|
|
148
|
+
if (++count > MAX_FILES) return fail(`the file pulls in more than ${MAX_FILES} repository modules`);
|
|
149
|
+
const dep = parseHaskell(local.text);
|
|
150
|
+
if (!dep || dep.status !== 'parsed') return fail(`the module ${mod} could not be parsed as Haskell`);
|
|
151
|
+
const h = loadTimeHazard(local.text); if (h) return fail(`the module ${mod} is refused: ${h}`);
|
|
152
|
+
sources[`${mod.replaceAll('.', '/')}.hs`] = path.relative(rootReal, local.real);
|
|
153
|
+
const r = visit(dep.imports ?? []); if (r) return r;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const c = classify(mod, imp.package, boot);
|
|
157
|
+
if (!c.ok) external.push(c.name);
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
const bad = visit(info.imports ?? []);
|
|
162
|
+
if (bad) return bad;
|
|
163
|
+
if (external.length) return fail(`the flagged code needs ${[...new Set(external)].slice(0, 4).join(', ')}, which is not available in the proof environment; only the packages that ship with GHC and files in the repository can be loaded`, { missing: [...new Set(external)] });
|
|
164
|
+
sources[entry] = rel;
|
|
165
|
+
|
|
166
|
+
const assumptions = [];
|
|
167
|
+
const base = { schema: 'agentic-security/reproducer', version: 1, findingId: String(finding.stableId ?? finding.id ?? 'generated'), lang: 'haskell', oracle: cls.oracle, network: cls.network ?? 'none', ...(cls.watch ? { watch: cls.watch } : {}) };
|
|
168
|
+
if (cls.watch) assumptions.push('the write target is assumed to be one directory below the working directory (the payload is ../escaped.txt); if the code uses another base, the proof will not confirm it');
|
|
169
|
+
const ghcRun = (expr) => ['{{GHC}}', '-ignore-dot-ghci', '-e', expr, entry];
|
|
170
|
+
const literal = (s) => JSON.stringify(s); // payloads are fixed strings with no quote or backslash; this is a plain Haskell string literal
|
|
171
|
+
|
|
172
|
+
// shape 1: a program that reads its payload from the command line
|
|
173
|
+
const hasMain = (info.functions ?? []).some((f) => f.scope === 'top' && f.name === 'main');
|
|
174
|
+
if (/\bgetArgs\b/.test(src.text) && hasMain) {
|
|
175
|
+
assumptions.push('the payload is passed as the first and only command-line argument; a program that expects other arguments will not reach the flagged line');
|
|
176
|
+
const reproducer = { ...base, sources, run: ghcRun(`:main ${literal(cls.payload)}`) };
|
|
177
|
+
const errs = validateReproducer(reproducer);
|
|
178
|
+
return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'script', class: cls.name, entry: rel, line: finding.line, assumptions } };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// shape 2: a top-level function around the flagged line
|
|
182
|
+
const fn = (info.functions ?? []).filter((f) => f.scope === 'top' && f.name !== 'main' && /^[a-z_][\w']*$/.test(f.name)).find((f) => {
|
|
183
|
+
const end = Math.max(f.span.endLine, ...(f.clauses ?? []).map((c) => c.span?.endLine ?? 0));
|
|
184
|
+
return f.span.startLine <= finding.line && end >= finding.line;
|
|
185
|
+
});
|
|
186
|
+
if (!fn) return fail('the flagged line is not inside a top-level function, and the file does not read getArgs in a main');
|
|
187
|
+
const sig = (info.signatures ?? []).find((s) => s.scope === 'top' && s.names.includes(fn.name));
|
|
188
|
+
if (!sig) return fail(`${fn.name} has no type signature, so the proof cannot tell what to pass it`);
|
|
189
|
+
const sigText = lines.slice(sig.span.startLine - 1, sig.span.endLine).join(' ');
|
|
190
|
+
const m = new RegExp(`${fn.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^:]*::\\s*([\\s\\S]*)$`).exec(sigText);
|
|
191
|
+
if (!m) return fail(`the type signature of ${fn.name} could not be read`);
|
|
192
|
+
const type = m[1].replace(/--.*$/, '').trim();
|
|
193
|
+
if (type.includes('=>') || /\bforall\b/.test(type)) return fail(`${fn.name} is polymorphic or constrained (${type.slice(0, 60)}), so the proof cannot choose its arguments`);
|
|
194
|
+
const parts = splitArrows(type);
|
|
195
|
+
const result = parts[parts.length - 1]; const params = parts.slice(0, -1);
|
|
196
|
+
if (!/^IO\b/.test(result)) return fail(`${fn.name} returns ${result.slice(0, 40)}, not IO, so calling it would not perform the flagged operation`);
|
|
197
|
+
if (!params.length) return fail(`${fn.name} takes no parameters, so the payload has no way in`);
|
|
198
|
+
const bad2 = params.find((p) => !/^(?:String|FilePath|\[Char\])$/.test(p));
|
|
199
|
+
if (bad2) return fail(`${fn.name} takes a parameter of type ${bad2.slice(0, 40)}; only String and FilePath can be filled with a payload`);
|
|
200
|
+
assumptions.push('the payload is passed in every parameter (a String has no request-like stand-in), which is an approximation of how the function is really called');
|
|
201
|
+
const call = `${fn.name} ${params.map(() => literal(cls.payload)).join(' ')} >>= \\_ -> return ()`;
|
|
202
|
+
const reproducer = { ...base, sources, run: ghcRun(call) };
|
|
203
|
+
const errs = validateReproducer(reproducer);
|
|
204
|
+
return errs.length ? fail(`the generated reproducer is invalid: ${errs[0]}`) : { ok: true, reproducer, basis: { shape: 'function', class: cls.name, entry: rel, line: finding.line, function: `${fn.name}(${params.length} String)`, assumptions } };
|
|
205
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// independent replay as any other. A generated reproducer is not a verdict; it is a proposal that is either confirmed by execution
|
|
7
7
|
// or not.
|
|
8
8
|
//
|
|
9
|
-
// What this is not: a general exploit generator. It handles JavaScript (here)
|
|
9
|
+
// What this is not: a general exploit generator. It handles JavaScript (here), Python (`generate-python.js`) and Haskell (`generate-haskell.js`), and only code the proof environment can actually load:
|
|
10
10
|
// the file's imports must be Node built-ins or relative files inside the repository (a package from node_modules is not there in
|
|
11
11
|
// the sandbox, so a handler that needs `express` is refused, with the package named). Anything outside that returns `{ ok: false }`
|
|
12
12
|
// with the reason, and nothing is executed.
|
|
@@ -27,10 +27,11 @@ import { builtinModules } from 'node:module';
|
|
|
27
27
|
import { parse } from '@babel/parser';
|
|
28
28
|
import { validateReproducer } from './reproducer.js';
|
|
29
29
|
import { generatePythonReproducer } from './generate-python.js';
|
|
30
|
+
import { generateHaskellReproducer } from './generate-haskell.js';
|
|
30
31
|
|
|
31
32
|
const CLASSES = {
|
|
32
33
|
'CWE-78': { name: 'command-injection', payload: 'x; touch {{CANARY:command}}', oracle: { type: 'command_execution', params: { canary: 'command' } } },
|
|
33
|
-
'CWE-22': { needsPy: /\bopen\s*\([^)]*['"][wax]b?\+?['"]|\.write_(?:text|bytes)\s*\(|\bshutil\.(?:copy|move)/, name: 'path-traversal', payload: '../escaped.txt', oracle: { type: 'filesystem_mutation', params: { canary: 'fs' } }, watch: { fs: 'escaped.txt' }, needs: /\b(?:writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream)\b/ },
|
|
34
|
+
'CWE-22': { needsPy: /\bopen\s*\([^)]*['"][wax]b?\+?['"]|\.write_(?:text|bytes)\s*\(|\bshutil\.(?:copy|move)/, name: 'path-traversal', payload: '../escaped.txt', oracle: { type: 'filesystem_mutation', params: { canary: 'fs' } }, watch: { fs: 'escaped.txt' }, needsHs: /\b(?:writeFile|appendFile|copyFile|copyFileWithMetadata|renameFile|openBinaryFile)\b|\b(?:openFile|withFile|withBinaryFile)\b[^\n]*\b(?:WriteMode|AppendMode|ReadWriteMode)\b/, needs: /\b(?:writeFile|writeFileSync|appendFile|appendFileSync|createWriteStream)\b/ },
|
|
34
35
|
'CWE-918': { name: 'ssrf', payload: '{{CALLBACK_URL}}', oracle: { type: 'outbound_callback' }, network: 'callback' },
|
|
35
36
|
};
|
|
36
37
|
const MAX_SOURCE = 256 * 1024;
|
|
@@ -138,9 +139,11 @@ export function generateReproducer({ finding, root }) {
|
|
|
138
139
|
if (!cls) return fail(`no payload and oracle exist for ${cwe ?? 'this finding'}; supported: ${Object.keys(CLASSES).join(', ')} (others need a database, a deserializer or a native harness)`);
|
|
139
140
|
const rel = String(finding?.file ?? '');
|
|
140
141
|
const isPy = /\.py$/i.test(rel);
|
|
141
|
-
|
|
142
|
+
const isHs = /\.hs$/i.test(rel);
|
|
143
|
+
if (!isPy && !isHs && !/\.(?:js|mjs|cjs)$/i.test(rel)) return fail(`no harness builder for ${path.extname(rel) || 'this file type'}; JavaScript, Python and Haskell only`);
|
|
142
144
|
if (!Number.isInteger(finding?.line)) return fail('the finding has no line number');
|
|
143
145
|
if (isPy) return generatePythonReproducer({ finding, root, cls });
|
|
146
|
+
if (isHs) return generateHaskellReproducer({ finding, root, cls });
|
|
144
147
|
const src = readSource(root, rel);
|
|
145
148
|
if (!src) return fail('the flagged file is missing, outside the repository, or too large');
|
|
146
149
|
const ast = parseSource(src.text);
|
|
@@ -8,12 +8,12 @@ import { validateAttackFacts } from '../dnr/attack-facts.js';
|
|
|
8
8
|
|
|
9
9
|
const REPRODUCER_SCHEMA = 'agentic-security/reproducer';
|
|
10
10
|
const REPRODUCER_VERSION = 1;
|
|
11
|
-
export const LANGUAGES = Object.freeze(['javascript', 'typescript', 'python', 'java', 'c', 'cpp', 'csharp']);
|
|
11
|
+
export const LANGUAGES = Object.freeze(['javascript', 'typescript', 'python', 'java', 'c', 'cpp', 'csharp', 'haskell']);
|
|
12
12
|
export const MAX_FILES = 64;
|
|
13
13
|
export const MAX_FILE_BYTES = 256 * 1024;
|
|
14
14
|
const MAX_TOTAL_BYTES = 1024 * 1024;
|
|
15
15
|
const PLACEHOLDER_RE = /\{\{([A-Z_]+)(?::([a-z0-9_-]{1,32}))?\}\}/g;
|
|
16
|
-
const ALLOWED_PLACEHOLDERS = new Set(['NONCE', 'SECRET', 'CANARY', 'CALLBACK_URL', 'CALLBACK_PORT', 'ROOT', 'NODE', 'PYTHON', 'JAVA', 'JAVAC', 'CC']);
|
|
16
|
+
const ALLOWED_PLACEHOLDERS = new Set(['NONCE', 'SECRET', 'CANARY', 'CALLBACK_URL', 'CALLBACK_PORT', 'ROOT', 'NODE', 'PYTHON', 'JAVA', 'JAVAC', 'CC', 'GHC', 'RUNGHC']);
|
|
17
17
|
|
|
18
18
|
export function isSafeRelPath(rel) {
|
|
19
19
|
if (typeof rel !== 'string' || !rel || rel.length > 200) return false;
|
|
@@ -105,6 +105,8 @@ export function substitute(text, ctx) {
|
|
|
105
105
|
case 'JAVA': return ctx.tools.java ?? whole;
|
|
106
106
|
case 'JAVAC': return ctx.tools.javac ?? whole;
|
|
107
107
|
case 'CC': return ctx.tools.cc ?? whole;
|
|
108
|
+
case 'GHC': return ctx.tools.ghc ?? whole;
|
|
109
|
+
case 'RUNGHC': return ctx.tools.runghc ?? whole;
|
|
108
110
|
default: return whole;
|
|
109
111
|
}
|
|
110
112
|
});
|
|
@@ -24,13 +24,13 @@ export const DEFAULT_LIMITS = Object.freeze({
|
|
|
24
24
|
|
|
25
25
|
const CONTAINER_IMAGES = Object.freeze({
|
|
26
26
|
javascript: 'node:24-slim', typescript: 'node:24-slim', python: 'python:3-slim', java: 'eclipse-temurin:21',
|
|
27
|
-
c: 'gcc:latest', cpp: 'gcc:latest', csharp: 'mcr.microsoft.com/dotnet/sdk:8.0',
|
|
27
|
+
c: 'gcc:latest', cpp: 'gcc:latest', csharp: 'mcr.microsoft.com/dotnet/sdk:8.0', haskell: 'haskell:9',
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
const SIGNALS = { 134: 'SIGABRT', 135: 'SIGBUS', 136: 'SIGFPE', 132: 'SIGILL', 139: 'SIGSEGV' };
|
|
31
31
|
|
|
32
32
|
// only language toolchains are probed; the container engine has its own, richer health check (`container.js`)
|
|
33
|
-
const PROBED_TOOLS = new Set(['java', 'javac', 'python3', 'cc', 'gcc', 'dotnet']);
|
|
33
|
+
const PROBED_TOOLS = new Set(['java', 'javac', 'python3', 'cc', 'gcc', 'dotnet', 'ghc', 'runghc']);
|
|
34
34
|
const probeCache = new Map();
|
|
35
35
|
/** A launcher that exists is not a toolchain that works: macOS ships /usr/bin/java and /usr/bin/python3 as stubs that only prompt for an
|
|
36
36
|
* install. A tool counts only if it answers its version flag, within a short timeout, with exit 0. */
|
|
@@ -61,12 +61,12 @@ function findTool(name, pathEnv = process.env.PATH || '') {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
export function hostTools() {
|
|
64
|
-
return { node: process.execPath, python: findTool('python3'), java: findTool('java'), javac: findTool('javac'), cc: findTool('cc') || findTool('gcc'), dotnet: findTool('dotnet') };
|
|
64
|
+
return { node: process.execPath, python: findTool('python3'), java: findTool('java'), javac: findTool('javac'), cc: findTool('cc') || findTool('gcc'), dotnet: findTool('dotnet'), ghc: findTool('ghc'), runghc: findTool('runghc') };
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
/** Language -> tools it needs. Missing tools make the language `unsupported` with a typed reason, never a pass. */
|
|
68
68
|
export function languageSupport(lang, tools = hostTools()) {
|
|
69
|
-
const need = { javascript: ['node'], typescript: ['node'], python: ['python'], java: ['java', 'javac'], c: ['cc'], cpp: ['cc'], csharp: ['dotnet'] }[lang];
|
|
69
|
+
const need = { javascript: ['node'], typescript: ['node'], python: ['python'], java: ['java', 'javac'], c: ['cc'], cpp: ['cc'], csharp: ['dotnet'], haskell: ['ghc', 'runghc'] }[lang];
|
|
70
70
|
if (!need) return { supported: false, reason: `unsupported language: ${lang}` };
|
|
71
71
|
const missing = need.filter((t) => !tools[t]);
|
|
72
72
|
return missing.length ? { supported: false, reason: `missing toolchain: ${missing.join(', ')}` } : { supported: true, reason: null };
|
package/src/sast/CLAUDE.md
CHANGED
|
@@ -39,6 +39,8 @@ Both key on the SECRET-NESS of the identifier, never on the comparison or the ty
|
|
|
39
39
|
|
|
40
40
|
**Bench-shape adapters (label-leakage, OFF by default)** — `bench-shape/` directory, plus `cpp-bench-extras.js`, `java-bench-extras.js`, `juliet-shape.js`, `primary-cwe-java.js`. These read Juliet / OWASP-Benchmark answer keys. Disabled unless `AGENTIC_SECURITY_BENCH_SHAPE=1`; `AGENTIC_SECURITY_BLIND_BENCH=1` overrides to force everything off. **Never use their emissions as a quality signal.**
|
|
41
41
|
|
|
42
|
+
**Precision guards (bench/dag).** Small, general recognisers that answer one semantic question each about the TEXT of a call, shared by every emitter that can fire on it. Each is tested as a pair (the safe construct stays quiet, the near-miss that changes the semantics keeps its verdict) in `test/dag-precision.test.js` and `bench/mutation/cases.mjs` (`DAG_PRECISION_CASES`). `_call-args.js` (quote- and bracket-aware argument splitter; a window that ends inside the call yields the arguments completed so far, flagged `partial`), `redirect-target.js` (CWE-601: a literal prefix that fixes the origin, `'/user/' + id` or `'https://host/' + p`, cannot be steered off-origin; `'/' + x`, `'//' + x` and a host with no closing slash can), `reflected-input.js` (CWE-79: only the properties of `req` that carry the client's input count, and not inside an HTML escaper; `req.session`/`req.user`/`req.app` are server state), `send-file-root.js` (CWE-22: `sendFile`/`download` with an inline `root` option confine the path), `init-scope.js` (CWE-400: is a position inside a function body, including expression-bodied arrows and class bodies, or at module load; a block is a function unless it is positively a control block or a literal, because the error that matters is hiding a finding), `listen-bind-address.js` (the `address` a `listen` callback receives is the bound socket address, not personal data). Wired in `../engine.js` (`dropGuardedFindings` for redirect and file serving, the `predicate` hook of the structural patterns for reflection and blocking I/O, `_detectSafeSinkShape`), `sensitive-data-logging.js` and `../dataflow/privacy-taint.js`. `engine.js` `_usedIdentifiers` is the same idea for the flow-pair detector: words inside string literals and property names after a dot are not reads of a variable of that name.
|
|
43
|
+
|
|
42
44
|
**Helpers** — `_comment-strip.js` strips comments while preserving line numbers (so finding `line` stays accurate). `index.js` is the public re-export.
|
|
43
45
|
|
|
44
46
|
## Gotchas
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Argument splitting for guard recognisers that must look at WHAT a call is
|
|
2
|
+
// given, not at the text around it. Quote- and bracket-aware; handles template
|
|
3
|
+
// literal substitutions. Not a parser: it answers "where does each top-level
|
|
4
|
+
// argument start and end" for text that begins right after a call's '('.
|
|
5
|
+
|
|
6
|
+
const isQuote = (c) => c === '"' || c === "'" || c === '`';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Split the text that follows a call's opening paren into its top-level
|
|
10
|
+
* arguments (source text, quotes intact). Returns null when the closing paren
|
|
11
|
+
* is not in the text given, so a caller never reasons about a truncated call.
|
|
12
|
+
*/
|
|
13
|
+
export function callArguments(text) {
|
|
14
|
+
const args = [];
|
|
15
|
+
let depth = 0;
|
|
16
|
+
let start = 0;
|
|
17
|
+
let i = 0;
|
|
18
|
+
while (i < text.length) {
|
|
19
|
+
const c = text[i];
|
|
20
|
+
if (isQuote(c)) {
|
|
21
|
+
const q = c;
|
|
22
|
+
i++;
|
|
23
|
+
while (i < text.length && text[i] !== q) {
|
|
24
|
+
if (text[i] === '\\') { i += 2; continue; }
|
|
25
|
+
if (q === '`' && text[i] === '$' && text[i + 1] === '{') {
|
|
26
|
+
let d = 1; i += 2;
|
|
27
|
+
while (i < text.length && d > 0) { if (text[i] === '{') d++; else if (text[i] === '}') d--; i++; }
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
i++;
|
|
31
|
+
}
|
|
32
|
+
i++;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (c === '(' || c === '[' || c === '{') depth++;
|
|
36
|
+
else if (c === ')' || c === ']' || c === '}') {
|
|
37
|
+
if (depth === 0) {
|
|
38
|
+
const last = text.slice(start, i);
|
|
39
|
+
if (last.trim() !== '' || args.length > 0) args.push(last);
|
|
40
|
+
return args;
|
|
41
|
+
}
|
|
42
|
+
depth--;
|
|
43
|
+
} else if (c === ',' && depth === 0) {
|
|
44
|
+
args.push(text.slice(start, i));
|
|
45
|
+
start = i + 1;
|
|
46
|
+
}
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
// The window ended inside the call (a long callback, a wrapped call). The
|
|
50
|
+
// arguments completed so far are still exactly what the call was given; the
|
|
51
|
+
// unfinished last one is dropped rather than guessed. `partial` lets a caller
|
|
52
|
+
// that needs the whole call refuse.
|
|
53
|
+
if (args.length === 0) return null;
|
|
54
|
+
args.partial = true;
|
|
55
|
+
return args;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find the first call on or just after `line` (1-based) whose callee matches
|
|
60
|
+
* `calleeRe` (which must end at the opening paren) and return its arguments.
|
|
61
|
+
* Looks at the finding line and the next three, because a call is routinely
|
|
62
|
+
* wrapped. Returns null when there is no such call or it is not closed.
|
|
63
|
+
*/
|
|
64
|
+
export function callArgumentsAt(lines, line, calleeRe) {
|
|
65
|
+
if (!Array.isArray(lines) || !Number.isInteger(line) || line < 1) return null;
|
|
66
|
+
const text = lines.slice(line - 1, line + 3).join('\n');
|
|
67
|
+
const m = calleeRe.exec(text);
|
|
68
|
+
if (!m) return null;
|
|
69
|
+
return callArguments(text.slice(m.index + m[0].length));
|
|
70
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Is a source position inside a function body, or at module/initialisation
|
|
2
|
+
// level? Some hazards exist only while the process is serving work: a blocking
|
|
3
|
+
// call made once at module load (reading a TLS key, a config file) delays
|
|
4
|
+
// startup once, and nothing is waiting on the event loop yet. The same call in
|
|
5
|
+
// a request callback stalls every connection.
|
|
6
|
+
//
|
|
7
|
+
// A position is inside a function when it is inside
|
|
8
|
+
// * a `{ ... }` block opened by `=>`, a `function` header, a method or class
|
|
9
|
+
// member header (`name(params) {`), or an async/generator form,
|
|
10
|
+
// * the body of an expression-bodied arrow (`(p) => fs.readFileSync(p)`),
|
|
11
|
+
// which runs when the arrow is CALLED, not where it is written, or
|
|
12
|
+
// * a class body (field initialisers run per instance).
|
|
13
|
+
// Plain blocks (`if`, `for`, `try`, `else`) and object literals are not function scopes.
|
|
14
|
+
//
|
|
15
|
+
// Operates on a comment- and string-blanked view so braces in strings or
|
|
16
|
+
// comments never count. It is a lexical approximation (no parse). The one error
|
|
17
|
+
// that matters is calling a function body "module level", because that hides a
|
|
18
|
+
// finding, so a block is classed as a function unless it is positively a control
|
|
19
|
+
// block or a literal, and parameter lists are matched by bracket depth, not by a
|
|
20
|
+
// pattern that stops at a fixed nesting.
|
|
21
|
+
|
|
22
|
+
const CONTROL_KEYWORDS = new Set(['if', 'for', 'while', 'switch', 'catch', 'with', 'else', 'try', 'finally', 'do']);
|
|
23
|
+
const CLASS_HEAD = /\bclass\b\s*[\w$]*(?:\s+extends\s+[\w$.()]+)?\s*$/;
|
|
24
|
+
|
|
25
|
+
/** Index of the '(' matching the ')' at `closeIdx`, scanning backwards; -1 if unbalanced. */
|
|
26
|
+
function matchingOpenParen(text, closeIdx) {
|
|
27
|
+
let depth = 0;
|
|
28
|
+
for (let i = closeIdx; i >= 0; i--) {
|
|
29
|
+
const c = text[i];
|
|
30
|
+
if (c === ')') depth++;
|
|
31
|
+
else if (c === '(') { depth--; if (depth === 0) return i; }
|
|
32
|
+
}
|
|
33
|
+
return -1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function opensFunction(text, braceIdx) {
|
|
37
|
+
let end = braceIdx;
|
|
38
|
+
while (end > 0 && /\s/.test(text[end - 1])) end--;
|
|
39
|
+
const before = text.slice(Math.max(0, end - 400), end);
|
|
40
|
+
if (/=>$/.test(before)) return true;
|
|
41
|
+
if (CLASS_HEAD.test(before)) return true;
|
|
42
|
+
// `else {`, `try {`, `finally {`, `do {`, `catch {` carry no parenthesised head
|
|
43
|
+
const bare = /(?:^|[^\w$.])([A-Za-z_$][\w$]*)$/.exec(before);
|
|
44
|
+
if (bare && CONTROL_KEYWORDS.has(bare[1])) return false;
|
|
45
|
+
// find the ')' that ends a parameter list, allowing a return type annotation `): Type {`
|
|
46
|
+
let closeIdx = end - 1;
|
|
47
|
+
if (text[closeIdx] !== ')') {
|
|
48
|
+
const m = /\)\s*:\s*[^(){};=]*$/.exec(before);
|
|
49
|
+
if (!m) return false;
|
|
50
|
+
closeIdx = end - before.length + m.index;
|
|
51
|
+
}
|
|
52
|
+
const openIdx = matchingOpenParen(text, closeIdx);
|
|
53
|
+
if (openIdx < 0) return true; // cannot match the head: keep findings, so call it a function
|
|
54
|
+
const head = text.slice(Math.max(0, openIdx - 80), openIdx).replace(/\s+$/, '');
|
|
55
|
+
const word = /([A-Za-z_$][\w$]*)$/.exec(head);
|
|
56
|
+
if (word) return !CONTROL_KEYWORDS.has(word[1]); // `if (` `while (` ...: control; any other name( ... ) { is a function or method
|
|
57
|
+
if (/\bfunction\s*\*?$/.test(head)) return true; // `function (` and `function* (`
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} blanked source with comments and string bodies blanked
|
|
63
|
+
* @param {number} offset index into `blanked`
|
|
64
|
+
* @returns {boolean} true when `offset` is lexically inside a function body
|
|
65
|
+
*/
|
|
66
|
+
export function insideFunctionBody(blanked, offset) {
|
|
67
|
+
// entries: 'F' function block, 'B' other block, 'P' paren/bracket, 'A' expression-bodied arrow
|
|
68
|
+
const stack = [];
|
|
69
|
+
const end = Math.min(offset, blanked.length);
|
|
70
|
+
for (let i = 0; i < end; i++) {
|
|
71
|
+
const c = blanked[i];
|
|
72
|
+
if (c === '{') stack.push(opensFunction(blanked, i) ? 'F' : 'B');
|
|
73
|
+
else if (c === '(' || c === '[') stack.push('P');
|
|
74
|
+
else if (c === '}' || c === ')' || c === ']') {
|
|
75
|
+
while (stack.length && stack[stack.length - 1] === 'A') stack.pop(); // an arrow body ends where its enclosing bracket closes
|
|
76
|
+
stack.pop();
|
|
77
|
+
} else if ((c === ',' || c === ';') && stack[stack.length - 1] === 'A') {
|
|
78
|
+
stack.pop(); // ... or at a separator at its own nesting level
|
|
79
|
+
} else if (c === '=' && blanked[i + 1] === '>') {
|
|
80
|
+
let j = i + 2;
|
|
81
|
+
while (j < blanked.length && /\s/.test(blanked[j])) j++;
|
|
82
|
+
if (blanked[j] !== '{') stack.push('A');
|
|
83
|
+
i++;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return stack.some((e) => e === 'F' || e === 'A');
|
|
87
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The `address` a `listen` callback receives.
|
|
2
|
+
//
|
|
3
|
+
// server.listen({ port }, (err, address) => console.log(`listening on ${address}`))
|
|
4
|
+
//
|
|
5
|
+
// `address` here is the socket address the server just bound (host and port),
|
|
6
|
+
// handed to the callback by the server. It is not a person's postal address, so a
|
|
7
|
+
// rule that classifies a value by the NAME `address` must not treat this binding
|
|
8
|
+
// as personal data. The rule is about the binding site, not about the word: the
|
|
9
|
+
// same name bound anywhere else (a form field, a column, a request property) is
|
|
10
|
+
// unaffected.
|
|
11
|
+
|
|
12
|
+
/** Index just past the paren that closes the one opened at `openIdx`, or -1 when the text ends first. */
|
|
13
|
+
function closeOfParen(text, openIdx) {
|
|
14
|
+
let depth = 0;
|
|
15
|
+
for (let i = openIdx; i < text.length; i++) {
|
|
16
|
+
const c = text[i];
|
|
17
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
18
|
+
const q = c; i++;
|
|
19
|
+
while (i < text.length && text[i] !== q) { if (text[i] === '\\') i++; i++; }
|
|
20
|
+
} else if (c === '(') depth++;
|
|
21
|
+
else if (c === ')') { depth--; if (depth === 0) return i + 1; }
|
|
22
|
+
}
|
|
23
|
+
return -1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Is the identifier at `idx` in `raw` a parameter declared by a callback passed
|
|
28
|
+
* to a `.listen(` call? `idx` is any position at or after the callback header
|
|
29
|
+
* (the use site); the header is searched backwards within a bounded window, and
|
|
30
|
+
* must sit inside the listen call's own parentheses, so a function declared after
|
|
31
|
+
* the call has closed does not count.
|
|
32
|
+
*/
|
|
33
|
+
export function isListenCallbackParam(raw, idx, name) {
|
|
34
|
+
if (typeof raw !== 'string' || !name) return false;
|
|
35
|
+
const from = Math.max(0, idx - 800);
|
|
36
|
+
const at = raw.slice(from, idx).lastIndexOf('.listen(');
|
|
37
|
+
if (at < 0) return false;
|
|
38
|
+
const start = from + at;
|
|
39
|
+
const openIdx = start + '.listen'.length;
|
|
40
|
+
const windowEnd = idx + name.length + 80;
|
|
41
|
+
const close = closeOfParen(raw.slice(start, windowEnd), openIdx - start);
|
|
42
|
+
// the call closed before the use site: the use is not inside the callback
|
|
43
|
+
if (close !== -1 && start + close <= idx) return false;
|
|
44
|
+
const after = raw.slice(start, close === -1 ? windowEnd : Math.min(windowEnd, start + close));
|
|
45
|
+
const id = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
46
|
+
const arrow = new RegExp(String.raw`\([^)]*\b${id}\b[^)]*\)\s*=>|\b${id}\s*=>`);
|
|
47
|
+
const fn = new RegExp(String.raw`\bfunction\b[^(]*\([^)]*\b${id}\b[^)]*\)`);
|
|
48
|
+
return arrow.test(after) || fn.test(after);
|
|
49
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Open-redirect target shape (CWE-601): can the part of a redirect target the
|
|
2
|
+
// program does not control change the ORIGIN the browser is sent to?
|
|
3
|
+
//
|
|
4
|
+
// A redirect target is attacker-steerable to another origin only if the
|
|
5
|
+
// attacker controls the authority (scheme, host, port) of the resulting URL.
|
|
6
|
+
// A target that begins with a literal which already fixes the authority cannot
|
|
7
|
+
// be moved off-origin by whatever is appended after it:
|
|
8
|
+
//
|
|
9
|
+
// '/user/' + id path-absolute, second character is a name
|
|
10
|
+
// character: same origin, whatever `id` is
|
|
11
|
+
// 'https://app.example.com/' + p the whole authority is in the literal and a
|
|
12
|
+
// '/' closes it, so `p` is only path/query
|
|
13
|
+
//
|
|
14
|
+
// and these are NOT pinned, because the dynamic part decides the authority:
|
|
15
|
+
//
|
|
16
|
+
// '/' + x x = '/evil.example' gives '//evil.example'
|
|
17
|
+
// '//' + x, '/\\' + x protocol-relative (browsers treat '\' as '/')
|
|
18
|
+
// 'https://app.example.com' + x x = '.evil.example' or '@evil.example'
|
|
19
|
+
// '' + x, x, x + '/path' no literal prefix at all
|
|
20
|
+
//
|
|
21
|
+
// This is deliberately the ONLY thing decided here. It reads the text of the
|
|
22
|
+
// redirect call's first argument; it does not decide whether the dynamic part
|
|
23
|
+
// is attacker-controlled (that is the taint layer's question) and it never
|
|
24
|
+
// reads a file name, a fixture name or a label.
|
|
25
|
+
|
|
26
|
+
import { callArgumentsAt } from './_call-args.js';
|
|
27
|
+
|
|
28
|
+
const REDIRECT_CALL_RE = /\b(?:redirect|sendRedirect|Redirect|redirect_to|HttpResponseRedirect|RedirectResponse|redirectTo|permanentRedirect)\s*\(/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Leading literal text of an expression, and whether anything dynamic follows.
|
|
32
|
+
* Handles 'a' + x, "a" + x + y, `a${x}`, and a lone literal.
|
|
33
|
+
* `literal` is the decoded-enough prefix (escapes kept verbatim, which is safe
|
|
34
|
+
* here because the checks below only look at slashes and the first characters).
|
|
35
|
+
*/
|
|
36
|
+
export function leadingLiteral(expr) {
|
|
37
|
+
const s = String(expr).trim();
|
|
38
|
+
let i = 0;
|
|
39
|
+
let prefix = '';
|
|
40
|
+
let dynamic = false;
|
|
41
|
+
for (;;) {
|
|
42
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
43
|
+
if (i >= s.length) break;
|
|
44
|
+
const c = s[i];
|
|
45
|
+
if (c === '"' || c === "'") {
|
|
46
|
+
let j = i + 1; let body = '';
|
|
47
|
+
while (j < s.length && s[j] !== c) { if (s[j] === '\\') { body += s[j] + (s[j + 1] || ''); j += 2; } else body += s[j++]; }
|
|
48
|
+
if (j >= s.length) return { literal: prefix, dynamic: true, closed: false };
|
|
49
|
+
prefix += body; i = j + 1;
|
|
50
|
+
} else if (c === '`') {
|
|
51
|
+
let j = i + 1; let body = '';
|
|
52
|
+
while (j < s.length && s[j] !== '`') {
|
|
53
|
+
if (s[j] === '\\') { body += s[j] + (s[j + 1] || ''); j += 2; continue; }
|
|
54
|
+
if (s[j] === '$' && s[j + 1] === '{') { dynamic = true; break; }
|
|
55
|
+
body += s[j++];
|
|
56
|
+
}
|
|
57
|
+
prefix += body;
|
|
58
|
+
if (dynamic) return { literal: prefix, dynamic: true, closed: true };
|
|
59
|
+
if (j >= s.length) return { literal: prefix, dynamic: true, closed: false };
|
|
60
|
+
i = j + 1;
|
|
61
|
+
} else {
|
|
62
|
+
return { literal: prefix, dynamic: true, closed: true };
|
|
63
|
+
}
|
|
64
|
+
while (i < s.length && /\s/.test(s[i])) i++;
|
|
65
|
+
if (i >= s.length) return { literal: prefix, dynamic, closed: true };
|
|
66
|
+
if (s[i] === '+') { i++; continue; }
|
|
67
|
+
// anything else after a literal (a call suffix, `.concat`, `%`, ...): not a plain literal
|
|
68
|
+
return { literal: prefix, dynamic: true, closed: true };
|
|
69
|
+
}
|
|
70
|
+
return { literal: prefix, dynamic, closed: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A literal prefix that fixes the origin of the URL it begins. */
|
|
74
|
+
export function prefixPinsOrigin(prefix) {
|
|
75
|
+
// Path-absolute: '/' then a character that is neither '/' nor '\' (both are
|
|
76
|
+
// read as a second slash, turning the reference into a network-path one).
|
|
77
|
+
if (/^\/[^\/\\\s]/.test(prefix)) return true;
|
|
78
|
+
// Absolute URL whose authority is complete: scheme://authority/ .
|
|
79
|
+
if (/^[A-Za-z][A-Za-z0-9+.\-]*:\/\/[^\/?#\\\s]+\//.test(prefix)) return true;
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Does the redirect argument keep the destination on a fixed origin?
|
|
85
|
+
* `arg` is the first argument of the redirect call. A numeric first argument
|
|
86
|
+
* (`res.redirect(302, url)`) is skipped by the caller, not here.
|
|
87
|
+
*/
|
|
88
|
+
export function redirectArgumentPinsOrigin(arg) {
|
|
89
|
+
const { literal, dynamic, closed } = leadingLiteral(arg);
|
|
90
|
+
if (!closed) return false;
|
|
91
|
+
if (!dynamic) return literal.length > 0; // a fully static target
|
|
92
|
+
return prefixPinsOrigin(literal);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Inspect source lines starting at `line` (1-based) for a redirect call and
|
|
97
|
+
* decide whether its target pins the origin. Returns null when no redirect
|
|
98
|
+
* call is found (no opinion), else true/false.
|
|
99
|
+
*/
|
|
100
|
+
export function redirectTargetPinsOriginAt(lines, line) {
|
|
101
|
+
const args = callArgumentsAt(lines, line, REDIRECT_CALL_RE);
|
|
102
|
+
if (!args || args.length === 0) return null;
|
|
103
|
+
// res.redirect(302, '/x'): the status code is not the target
|
|
104
|
+
const target = args.length > 1 && /^\s*\d{3}\s*$/.test(args[0]) ? args[1] : args[0];
|
|
105
|
+
return redirectArgumentPinsOrigin(target);
|
|
106
|
+
}
|