@clear-capabilities/agentic-security-scanner 0.162.0 → 0.163.1
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 +87 -0
- package/dist/1340.index.js +1871 -0
- package/dist/3477.index.js +727 -0
- package/dist/3499.index.js +23 -3
- package/dist/4113.index.js +1 -1
- package/dist/5014.index.js +833 -0
- package/dist/5756.index.js +2 -2
- package/dist/6257.index.js +1 -1
- package/dist/6907.index.js +248 -0
- package/dist/6994.index.js +1 -1
- package/dist/7033.index.js +33 -5
- package/dist/7439.index.js +1822 -0
- package/dist/7838.index.js +1 -1
- package/dist/9560.index.js +2318 -23
- package/dist/957.index.js +1 -1
- package/dist/9999.index.js +43 -7
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -6
- package/src/capabilities/CLAUDE.md +114 -0
- package/src/capabilities/address.js +73 -0
- package/src/capabilities/attack-coverage.js +93 -0
- package/src/capabilities/decide.js +256 -0
- package/src/capabilities/delegate.js +69 -0
- package/src/capabilities/egress.js +20 -0
- package/src/capabilities/hook-advice.js +50 -0
- package/src/capabilities/index.js +32 -0
- package/src/capabilities/manifest.js +344 -0
- package/src/capabilities/outbound.js +189 -0
- package/src/capabilities/paths.js +57 -0
- package/src/capabilities/probes.js +211 -0
- package/src/capabilities/proxy.js +182 -0
- package/src/capabilities/reasons.js +67 -0
- package/src/capabilities/receipts.js +356 -0
- package/src/capabilities/records.js +58 -0
- package/src/capabilities/recovery.js +367 -0
- package/src/capabilities/report.js +81 -0
- package/src/capabilities/runner.js +236 -0
- package/src/capabilities/secrets.js +26 -0
- package/src/capabilities/tool-gate.js +119 -0
- package/src/capabilities/tool-registry.js +60 -0
- package/src/dataflow/CLAUDE.md +30 -0
- package/src/dataflow/catalog.js +42 -1
- package/src/dataflow/engine.js +67 -2
- package/src/dataflow/guard-dominance.js +352 -0
- package/src/discovery/CLAUDE.md +9 -0
- package/src/discovery/index.js +15 -1
- package/src/discovery/lenses.js +18 -0
- package/src/engine.js +80 -15
- package/src/integrations/index.js +5 -2
- package/src/ir/CLAUDE.md +14 -0
- package/src/ir/implicit-handler-params.js +74 -0
- package/src/ir/parser-cs.js +20 -0
- package/src/ir/parser-go.js +30 -13
- package/src/ir/parser-java.js +11 -0
- package/src/language/nix-eval-isolation.js +33 -5
- package/src/lineage/CLAUDE.md +31 -0
- package/src/lineage/deployment/adapter-cloud.js +202 -0
- package/src/lineage/deployment/adapter-compose.js +90 -0
- package/src/lineage/deployment/adapter-kit.js +142 -0
- package/src/lineage/deployment/adapter-kubernetes.js +247 -0
- package/src/lineage/deployment/attack-paths.js +443 -0
- package/src/lineage/deployment/boundaries-run.js +223 -0
- package/src/lineage/deployment/boundary-graph.js +567 -0
- package/src/lineage/deployment/cross-repo.js +253 -0
- package/src/lineage/deployment/drift.js +383 -0
- package/src/lineage/deployment/ingest.js +221 -0
- package/src/lineage/deployment/projection.js +244 -0
- package/src/lineage/deployment/trace-correlation.js +541 -0
- package/src/lineage/source-registry.js +19 -0
- package/src/mcp/CLAUDE.md +5 -2
- package/src/mcp/invariant-tools.js +64 -0
- package/src/mcp/portfolio-tools.js +79 -0
- package/src/mcp/redact.js +15 -0
- package/src/mcp/server.js +24 -3
- package/src/mcp/tools.js +13 -1
- package/src/posture/CLAUDE.md +332 -1
- package/src/posture/accuracy-scorecard.js +95 -4
- package/src/posture/assurance/rollback.js +214 -0
- package/src/posture/assurance/rollout.js +200 -0
- package/src/posture/business-logic.js +26 -4
- package/src/posture/evaluation/bakeoff.js +148 -0
- package/src/posture/evaluation/custody.js +200 -0
- package/src/posture/evaluation/deployment-ablation.js +416 -0
- package/src/posture/evaluation/dev-cases.js +65 -0
- package/src/posture/evaluation/economics.js +85 -0
- package/src/posture/evaluation/gates.js +161 -0
- package/src/posture/evaluation/grouping.js +101 -0
- package/src/posture/evaluation/interval.js +132 -0
- package/src/posture/evaluation/invariant-ablation.js +325 -0
- package/src/posture/evaluation/labels.js +200 -0
- package/src/posture/evaluation/protocol.js +308 -0
- package/src/posture/evaluation/release-gate.js +209 -0
- package/src/posture/evaluation/report.js +158 -0
- package/src/posture/evaluation/runner.js +273 -0
- package/src/posture/evaluation/scan-child.js +80 -0
- package/src/posture/evaluation/score.js +174 -0
- package/src/posture/evaluation/synthetic.js +90 -0
- package/src/posture/evaluation/why-missed.js +219 -0
- package/src/posture/fleet.js +11 -2
- package/src/posture/invariants/cli.js +95 -0
- package/src/posture/invariants/coverage.js +164 -0
- package/src/posture/invariants/export.js +178 -0
- package/src/posture/invariants/expressions.js +46 -0
- package/src/posture/invariants/lifecycle.js +289 -0
- package/src/posture/invariants/project-input.js +55 -0
- package/src/posture/invariants/repair.js +340 -0
- package/src/posture/invariants/run.js +45 -0
- package/src/posture/invariants/scenarios.js +311 -0
- package/src/posture/invariants/schema.js +167 -0
- package/src/posture/invariants/shrink.js +407 -0
- package/src/posture/invariants/state-assertions.js +165 -0
- package/src/posture/logic-claims.js +12 -0
- package/src/posture/model-routing.js +40 -0
- package/src/posture/oracles/adapters.js +172 -1
- package/src/posture/oracles/conformance.js +3 -2
- package/src/posture/oracles/oracle.js +23 -3
- package/src/posture/oracles/registry.js +1 -1
- package/src/posture/portfolio/backend.js +279 -0
- package/src/posture/portfolio/bundle.js +314 -0
- package/src/posture/portfolio/cli.js +142 -0
- package/src/posture/portfolio/manifest.js +263 -0
- package/src/posture/portfolio/progress.js +267 -0
- package/src/posture/portfolio/resume.js +130 -0
- package/src/posture/portfolio/retention.js +207 -0
- package/src/posture/portfolio/scheduler.js +488 -0
- package/src/posture/portfolio/signing.js +155 -0
- package/src/posture/portfolio/wording.js +59 -0
- package/src/posture/portfolio/work-units.js +405 -0
- package/src/posture/replay/replay.js +1 -0
- package/src/posture/routing/adapter.js +140 -0
- package/src/posture/routing/calibration.js +278 -0
- package/src/posture/routing/canary.js +141 -0
- package/src/posture/routing/control.js +35 -0
- package/src/posture/routing/decide.js +211 -0
- package/src/posture/routing/drift.js +137 -0
- package/src/posture/routing/economics.js +185 -0
- package/src/posture/routing/feedback.js +207 -0
- package/src/posture/routing/index.js +31 -0
- package/src/posture/routing/outcomes.js +281 -0
- package/src/posture/routing/promotion.js +291 -0
- package/src/posture/routing/receipts.js +59 -0
- package/src/posture/routing/report.js +142 -0
- package/src/posture/routing/shadow.js +53 -0
- package/src/posture/specification-mining.js +26 -0
- package/src/posture/verification/patch-negative.js +16 -4
- package/src/posture/verification/patch-promotion.js +10 -2
- package/src/report/index.js +51 -1
- package/src/sandbox/CLAUDE.md +10 -0
- package/src/sandbox/backend-userspace.js +91 -13
- package/dist/5613.index.js +0 -500
- package/dist/6239.index.js +0 -469
package/src/dataflow/engine.js
CHANGED
|
@@ -166,6 +166,11 @@ function _fullyFlattenMemberChain(calleeExpr) {
|
|
|
166
166
|
// "unknown", never as a signal to suppress or refuse (see this file's
|
|
167
167
|
// "Unknown ≠ clean" global constraint).
|
|
168
168
|
function _receiverTypeFor(calleeExpr, callContext) {
|
|
169
|
+
// A bare identifier receiver that every binding in this file initialises to a
|
|
170
|
+
// regex is a RegExp (see _regexNamesForFile). Checked before CHA, which
|
|
171
|
+
// cannot see a module-scope const from inside the handler that uses it.
|
|
172
|
+
if (calleeExpr && calleeExpr.kind === 'member' && calleeExpr.object && calleeExpr.object.kind === 'ident'
|
|
173
|
+
&& _currentFile && _regexNamesByFile.get(_currentFile)?.has(calleeExpr.object.name)) return 'RegExp';
|
|
169
174
|
if (!callContext || !callContext._cha) return null;
|
|
170
175
|
const flat = _fullyFlattenMemberChain(calleeExpr);
|
|
171
176
|
if (!flat || !flat.includes('.')) return null;
|
|
@@ -197,6 +202,45 @@ function _receiverTypeFor(calleeExpr, callContext) {
|
|
|
197
202
|
return classOfVar(callContext._cha, _currentFile, callContext._currentFnQid, parts[0]);
|
|
198
203
|
}
|
|
199
204
|
|
|
205
|
+
// File -> Set of identifier names that are provably RegExp objects: the name
|
|
206
|
+
// is assigned at least once, EVERY assignment to it anywhere in the file (any
|
|
207
|
+
// function, any scope, including module scope) is a regex literal or a
|
|
208
|
+
// `RegExp(...)` / `new RegExp(...)` construction, and it is never a function
|
|
209
|
+
// parameter. Name-based and file-wide on purpose: the IR has no scope
|
|
210
|
+
// resolution, so a name that is ever bound to anything else is treated as
|
|
211
|
+
// unknown, which keeps the catalog's conservative behaviour. Used so
|
|
212
|
+
// `SEMVER.exec(s)` is not read as child_process.exec. Residual gap: a name
|
|
213
|
+
// bound by an import or a destructuring that lowers to no assign node, AND
|
|
214
|
+
// also assigned a regex in an unrelated scope of the same file.
|
|
215
|
+
const _regexNamesByFile = new Map();
|
|
216
|
+
|
|
217
|
+
function _isRegexInit(src) {
|
|
218
|
+
if (!src || typeof src !== 'object') return false;
|
|
219
|
+
if (src.kind === 'literal' && src.isRegex) return true;
|
|
220
|
+
return src.kind === 'call' && !!src.callee && src.callee.kind === 'ident' && src.callee.name === 'RegExp';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _buildRegexNames(fns) {
|
|
224
|
+
_regexNamesByFile.clear();
|
|
225
|
+
const byFile = new Map(); // file -> Map(name -> boolean allRegex)
|
|
226
|
+
for (const fn of fns) {
|
|
227
|
+
if (!fn || !fn.file || !fn.cfg || !fn.cfg.nodes) continue;
|
|
228
|
+
let names = byFile.get(fn.file);
|
|
229
|
+
if (!names) { names = new Map(); byFile.set(fn.file, names); }
|
|
230
|
+
for (const p of fn.params || []) if (typeof p === 'string') names.set(p, false);
|
|
231
|
+
for (const n of Object.values(fn.cfg.nodes)) {
|
|
232
|
+
if (!n || n.kind !== 'assign' || typeof n.target !== 'string' || n.target.includes('.')) continue;
|
|
233
|
+
const prev = names.get(n.target);
|
|
234
|
+
names.set(n.target, (prev === undefined || prev === true) && _isRegexInit(n.source));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
for (const [file, names] of byFile) {
|
|
238
|
+
const ok = new Set();
|
|
239
|
+
for (const [name, allRegex] of names) if (allRegex) ok.add(name);
|
|
240
|
+
if (ok.size) _regexNamesByFile.set(file, ok);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
200
244
|
// Narrower than _flattenCalleeName: the name to hand to callGraph.resolve().
|
|
201
245
|
// Only a bare identifier call (`helper()`) — or a pre-flattened STRING, which
|
|
202
246
|
// is how the Go/PHP/Ruby/Python/C++ parsers already emit call targets —
|
|
@@ -983,6 +1027,25 @@ function _literalArgSatisfied(argExprs, requireLiteralArg) {
|
|
|
983
1027
|
return !!checkArg && checkArg.kind === 'literal' && new RegExp(pattern).test(String(checkArg.value));
|
|
984
1028
|
}
|
|
985
1029
|
|
|
1030
|
+
// QA-006: `match.requireStaticPrefix` -- the leading STATIC text of an argument that is partly tainted.
|
|
1031
|
+
// `header("Location: " . $next)` is an open redirect while `header("X-Trace: " . $v)` is only header injection;
|
|
1032
|
+
// both are the same callee, told apart by the literal text the tainted value is appended to. The prefix is
|
|
1033
|
+
// read from the leftmost literal of a concatenation or template; a leading non-literal means the prefix is
|
|
1034
|
+
// unknown and the requirement is NOT satisfied (fails closed, like requireLiteralArg).
|
|
1035
|
+
function _leadingLiteral(expr) {
|
|
1036
|
+
if (!expr) return null;
|
|
1037
|
+
if (expr.kind === 'literal') return typeof expr.value === 'string' ? expr.value : String(expr.value ?? '');
|
|
1038
|
+
if (expr.kind === 'binary') return _leadingLiteral(expr.left);
|
|
1039
|
+
if (expr.kind === 'tpl') { const p0 = (expr.parts || [])[0]; return p0 && p0.kind === 'literal' ? String(p0.value ?? '') : null; }
|
|
1040
|
+
return null;
|
|
1041
|
+
}
|
|
1042
|
+
function _staticPrefixSatisfied(argExprs, requireStaticPrefix) {
|
|
1043
|
+
const { index, pattern } = requireStaticPrefix;
|
|
1044
|
+
const lead = _leadingLiteral((argExprs || [])[index]);
|
|
1045
|
+
// Some frontends keep the literal's own quote characters in its value (PHP does); the prefix is the text inside them.
|
|
1046
|
+
return lead !== null && new RegExp(pattern, 'i').test(lead.replace(/^\s*["'`]/, ''));
|
|
1047
|
+
}
|
|
1048
|
+
|
|
986
1049
|
// SARD_80_F1 W5.49 — PHP's `sprintf("...%d...", $tainted)` is a PER-ARGUMENT
|
|
987
1050
|
// coercion sanitizer, distinct from `_isCoercionCall`'s whole-call model
|
|
988
1051
|
// (intval/floatval/etc, where the ENTIRE call's return value is the coerced
|
|
@@ -1111,6 +1174,7 @@ function _sinkFindingsForCall(calleeExpr, argExprs, cat, argTaints, state, callC
|
|
|
1111
1174
|
// precision annotator in this codebase take when evidence is
|
|
1112
1175
|
// simply unavailable rather than affirmatively clean.
|
|
1113
1176
|
if (e.match && e.match.requireLiteralArg && !_literalArgSatisfied(argExprs, e.match.requireLiteralArg)) continue;
|
|
1177
|
+
if (e.match && e.match.requireStaticPrefix && !_staticPrefixSatisfied(argExprs, e.match.requireStaticPrefix)) continue;
|
|
1114
1178
|
// `match.requireKeyword` — the KEYWORD-argument analogue of the
|
|
1115
1179
|
// positional gate above, for languages where the dangerous form is
|
|
1116
1180
|
// selected by a named argument rather than a positional literal.
|
|
@@ -1967,7 +2031,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1967
2031
|
for (const [nid, ctx] of ictx) {
|
|
1968
2032
|
const node = nodes[nid];
|
|
1969
2033
|
if (!node || node.kind !== 'call') continue;
|
|
1970
|
-
const cat = matchSinkOrSanitizer(node.callee, _currentFile);
|
|
2034
|
+
const cat = matchSinkOrSanitizer(node.callee, _currentFile, _receiverTypeFor(node.callee, callContext));
|
|
1971
2035
|
const sink = cat && cat.find((e) => e.kind === 'sink');
|
|
1972
2036
|
if (!sink) continue;
|
|
1973
2037
|
const inS = inStates.get(nid) || new Set();
|
|
@@ -1981,7 +2045,7 @@ function analyzeFunction(fn, entryState, callContext) {
|
|
|
1981
2045
|
if (implicitState.size) {
|
|
1982
2046
|
for (const [nid, node] of Object.entries(nodes)) {
|
|
1983
2047
|
if (!node || node.kind !== 'call') continue;
|
|
1984
|
-
const cat = matchSinkOrSanitizer(node.callee, _currentFile);
|
|
2048
|
+
const cat = matchSinkOrSanitizer(node.callee, _currentFile, _receiverTypeFor(node.callee, callContext));
|
|
1985
2049
|
const sink = cat && cat.find((e) => e.kind === 'sink');
|
|
1986
2050
|
if (!sink) continue;
|
|
1987
2051
|
const inS = inStates.get(nid) || new Set();
|
|
@@ -2089,6 +2153,7 @@ export function runTaintEngine(perFileIR, callGraph, opts = {}) {
|
|
|
2089
2153
|
const fnList = [...callGraph.functions.values()].sort((a, b) =>
|
|
2090
2154
|
a.qid < b.qid ? -1 : a.qid > b.qid ? 1 : 0
|
|
2091
2155
|
);
|
|
2156
|
+
_buildRegexNames(fnList);
|
|
2092
2157
|
// PRD W0.1 (SARD_80_F1_EXECUTION_PRD.md) — the main per-function loop below
|
|
2093
2158
|
// silently `break`s once `n > fnLimit` (default 5000), which for a single
|
|
2094
2159
|
// large SARD CWE directory (Java CWE-89 alone: 3668 files, 17604
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
// Does a guard actually DOMINATE the sink it is said to protect? (QA-005.AC02)
|
|
2
|
+
//
|
|
3
|
+
// `dropGuardedFindings` and the safe-sink-shape check recognise a guard by shape inside a window of source lines
|
|
4
|
+
// around the sink, then correlate on a shared identifier. That answers "is there guard-looking text near this sink",
|
|
5
|
+
// which is not the question that justifies dropping a finding: "is every path that reaches the sink forced through
|
|
6
|
+
// this guard". Text can be near a sink without protecting it:
|
|
7
|
+
// - a guard written AFTER the sink (the window reaches several lines below it);
|
|
8
|
+
// - a guard in a DIFFERENT function that happens to sit above;
|
|
9
|
+
// - a guard inside a block that has already closed (`if (dev) { check(x); } fetch(x)`);
|
|
10
|
+
// - an `if (allowed(x)) { log() }` whose failing branch does not stop anything, followed by an unconditional sink;
|
|
11
|
+
// - a membership test whose boolean result is computed and never branched on.
|
|
12
|
+
// This module decides that, over the same source lines, with no new dependency. It is deliberately conservative toward
|
|
13
|
+
// KEEPING a finding: when it cannot establish dominance it says so, and the caller leaves the finding in place. The
|
|
14
|
+
// cost of being wrong in that direction is a reviewable false positive; the other direction deletes a real defect.
|
|
15
|
+
//
|
|
16
|
+
// Pure: lines in, verdict out. It reads no file name beyond an extension, no label and no answer key, and works on text alone.
|
|
17
|
+
// It is NOT a control-flow graph: it recognises the structured shapes real handlers use (straight-line statements,
|
|
18
|
+
// early-exit checks, a sink nested in the guarded branch) and reports `unknown-shape` for anything else it cannot
|
|
19
|
+
// place, which counts as "not proven".
|
|
20
|
+
|
|
21
|
+
const TERMINATOR = /\b(?:return|throw|raise|continue|break|exit|abort|die|panic|halt|fail|next)\b|\b(?:sys\.exit|os\._exit|process\.exit|http\.Error|t\.Fatal|log\.Fatal)\b|\.\s*(?:status|sendStatus|abort|AbortWithStatus|AbortWithStatusJSON|Error)\s*\(/;
|
|
22
|
+
const CONDITIONAL_HEAD = /^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard|switch)\b/;
|
|
23
|
+
const MODIFIER_EXIT = /^\s*(?:return|raise|throw|next|break|continue|abort|halt|fail|die)\b[^\n]*\b(?:if|unless)\b|\b(?:or|\|\|)\s+(?:raise|die|throw|return|abort|exit)\b/;
|
|
24
|
+
// A function or method DECLARATION line, across the languages this engine reads. A guard above one is in another function.
|
|
25
|
+
const NOT_A_DECL_HEAD = /^\s*(?:return|await|new|throw|else|yield|typeof|delete|case|if|for|while|switch|catch|try|do|elif|elsif|unless|until|when|guard|print|echo)\b/;
|
|
26
|
+
const MODIFIER = /^(?:public|private|protected|internal|static|final|abstract|override|async|export|default|virtual|sealed|extern|pub|suspend)\s+/;
|
|
27
|
+
const ANNOTATION = /^@\w+(?:\([^)]*\))?\s*/;
|
|
28
|
+
const DECL_START = /^(?:function\s*\*?\s*\w+\s*\(|def\s+\w+|fn\s+\w+|fun\s+\w+|func\s+(?:\([^)]*\)\s*)?\w+\s*\(|sub\s+\w+)/;
|
|
29
|
+
// A typed method header (`public String find(String q) throws E {`) or a bare class-method shorthand (`handle(req, res) {`): the head before the
|
|
30
|
+
// parenthesis is a few words, the opening brace ends the line. Written without nested quantifiers and capped in length, because these run per line.
|
|
31
|
+
const BRACE_METHOD = /^([^(){};=]{0,120}?)\(([^;)]{0,300})\)\s*(?:throws\s+[\w.,\s]{1,120})?\s*\{\s*$/;
|
|
32
|
+
const MAX_DECL_LINE = 400;
|
|
33
|
+
function isFunctionBoundary(line) {
|
|
34
|
+
if (line.length > MAX_DECL_LINE || NOT_A_DECL_HEAD.test(line)) return false;
|
|
35
|
+
// peel annotations and modifiers one at a time (a loop, not a repeated group)
|
|
36
|
+
let rest = line.trimStart();
|
|
37
|
+
for (let n = 0; n < 8; n++) { const m = ANNOTATION.exec(rest); if (!m) break; rest = rest.slice(m[0].length); }
|
|
38
|
+
for (let n = 0; n < 8; n++) { const m = MODIFIER.exec(rest); if (!m) break; rest = rest.slice(m[0].length); }
|
|
39
|
+
if (DECL_START.test(rest)) return true;
|
|
40
|
+
const m = BRACE_METHOD.exec(line);
|
|
41
|
+
if (!m) return false;
|
|
42
|
+
const words = m[1].trim().split(/\s+/).filter(Boolean);
|
|
43
|
+
// the last word is the method name; any earlier words are modifiers or a return type
|
|
44
|
+
return words.length >= 1 && words.length <= 6 && /^\w+$/.test(words[words.length - 1]) && !/^(?:if|for|while|switch|catch|else|function|return)$/.test(words[words.length - 1]);
|
|
45
|
+
}
|
|
46
|
+
// A pure DATA declaration: a name bound to a literal, a collection or a constructor (`const ALLOWED = new Set([...])`, `DENY = ["a"]`,
|
|
47
|
+
// `static final Set<String> ALLOW = Set.of(...)`, `var allowed = map[string]bool{...}`). It defines the allow-list; it does not CHECK anything
|
|
48
|
+
// against it, so it cannot guard a sink however guard-shaped its name is.
|
|
49
|
+
const DATA_DECL = /^\s*(?:(?:export|public|private|protected|internal|static|final|readonly|const|let|var|val|def|pub|mut)\s+)*(?:[\w$.<>\[\],?:&* ]+?\s+)?[A-Za-z_$][\w$]*\s*(?::[^=\n]+)?(?::=|=)\s*(?:new\b|\[|\{|%[wi]|Set\.of|List\.of|Map\.of|Arrays\.asList|listOf|setOf|mapOf|hashSetOf|set\(|frozenset\(|list\(|dict\(|tuple\(|vec!|HashSet|map\[|[A-Z]\w*\{|["'`])(?!.*(?:=>|->|\bfunction\b))/;
|
|
50
|
+
// An assignment whose value is a membership/prefix/equality TEST: a boolean, which protects nothing until something branches on it.
|
|
51
|
+
const BOOLEAN_ASSIGN = /^\s*(?:(?:const|let|var|val|final|bool|boolean|auto)\s+)?([A-Za-z_$][\w$]*)\s*(?::=|=)\s*(?!.*\b(?:basename|abspath|realpath|resolve|normalize|secure_filename|safe_join|sanitize\w*|escape\w*)\b)[^=\n]*(?:\.\s*(?:has|includes|contains|startsWith|startswith|StartsWith|endsWith|HasPrefix|starts_with|match|test|matches)\s*\(|\bin\s|===|!==|==|!=)/;
|
|
52
|
+
|
|
53
|
+
/** Source line with string literals and trailing line comments blanked, so brace counting is not fooled by either. */
|
|
54
|
+
function code(line) {
|
|
55
|
+
return String(line ?? '')
|
|
56
|
+
.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, '""')
|
|
57
|
+
.replace(/\/\/.*$/, '')
|
|
58
|
+
.replace(/(^|\s)#.*$/, '$1');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const indentOf = (line) => { const m = /^[ \t]*/.exec(line)[0]; return m.replace(/\t/g, ' ').length; };
|
|
62
|
+
const countBraces = (s) => ({ open: (s.match(/\{/g) || []).length, close: (s.match(/\}/g) || []).length });
|
|
63
|
+
const blank = (s) => !/\S/.test(s);
|
|
64
|
+
|
|
65
|
+
// Block style of the file: indentation (Python, Ruby) or braces. The extension decides when known; otherwise the whole file votes, never just
|
|
66
|
+
// the few lines between guard and sink (two brace-free lines of JavaScript are not an indentation language).
|
|
67
|
+
function looksIndented(lines, file) {
|
|
68
|
+
if (/\.(?:py|pyi|rb|rake|ru)$/i.test(file || '')) return true;
|
|
69
|
+
if (/\.[A-Za-z0-9]+$/.test(file || '')) return false;
|
|
70
|
+
let brace = 0; let colon = 0;
|
|
71
|
+
for (const l of lines) {
|
|
72
|
+
const c = code(l);
|
|
73
|
+
if (/[{}]\s*$/.test(c)) brace++;
|
|
74
|
+
else if (/^\s*(?:def|if|elif|else|for|while|class|try|except|finally|with)\b.*:\s*$/.test(c)) colon++;
|
|
75
|
+
}
|
|
76
|
+
return colon > brace;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function result(dominates, reason) { return { dominates, reason }; }
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {object} o
|
|
83
|
+
* @param {string[]} o.lines the file's source lines (original text)
|
|
84
|
+
* @param {number} o.guardIdx 0-based index of the line the guard-shaped text matched on
|
|
85
|
+
* @param {number} o.sinkIdx 0-based index of the sink line
|
|
86
|
+
* @param {string} [o.file] path, used ONLY to pick the block style (indentation vs braces) from its extension
|
|
87
|
+
* @returns {{dominates: boolean, reason: string}}
|
|
88
|
+
*/
|
|
89
|
+
export function guardDominatesSink({ lines, guardIdx, sinkIdx, file = null, depth = 0 }) {
|
|
90
|
+
if (!Array.isArray(lines) || !Number.isInteger(guardIdx) || !Number.isInteger(sinkIdx)) return result(false, 'unknown-shape');
|
|
91
|
+
if (guardIdx < 0 || sinkIdx < 0 || guardIdx >= lines.length || sinkIdx >= lines.length) return result(false, 'unknown-shape');
|
|
92
|
+
if (guardIdx > sinkIdx) return result(false, 'guard-after-sink');
|
|
93
|
+
|
|
94
|
+
const indentStyle = looksIndented(lines, file);
|
|
95
|
+
// Which named function holds the guard? A guard in a function that has ENDED before the sink protects the sink only through a call to
|
|
96
|
+
// that function (a validator helper); a guard at module level does not reach into a function declared after it.
|
|
97
|
+
const fn = enclosingFunction(lines, guardIdx, indentStyle);
|
|
98
|
+
if (fn && sinkIdx > fn.endIdx) return calleeGuardDominates({ lines, guardIdx, sinkIdx, fn, file, depth });
|
|
99
|
+
if (!fn) {
|
|
100
|
+
for (let i = guardIdx + 1; i <= sinkIdx; i++) if (isFunctionBoundary(code(lines[i]))) return result(false, 'different-function');
|
|
101
|
+
}
|
|
102
|
+
const gl = code(lines[guardIdx]);
|
|
103
|
+
|
|
104
|
+
// Same line: `if (!ok(x)) return; fetch(x)` or `x = safe(x); use(x)`. Order within the line is not tracked, but a guard that
|
|
105
|
+
// shares the sink's line is positioned by the author as part of the same statement run.
|
|
106
|
+
if (guardIdx === sinkIdx) return result(true, 'same-line');
|
|
107
|
+
|
|
108
|
+
// A line that only DEFINES the allow-list is not a check.
|
|
109
|
+
if (DATA_DECL.test(gl) && !/\.\s*(?:has|includes|contains|startsWith|startswith|StartsWith)\s*\(/.test(gl)) return result(false, 'data-declaration-not-a-check');
|
|
110
|
+
|
|
111
|
+
// A membership/equality result that is computed and never branched on protects nothing.
|
|
112
|
+
const boolAssign = BOOLEAN_ASSIGN.exec(gl);
|
|
113
|
+
if (boolAssign && !CONDITIONAL_HEAD.test(gl)) {
|
|
114
|
+
const v = boolAssign[1].replace(/[$]/g, '\\$');
|
|
115
|
+
const used = new RegExp(`(?:\\b(?:if|unless|while|assert|require|ensure|guard)\\b|&&|\\|\\||\\?|\\bnot\\b|!)[^\\n]*\\b${v}\\b|\\b${v}\\b[^\\n]*(?:&&|\\|\\||\\?)`);
|
|
116
|
+
let branched = false;
|
|
117
|
+
for (let i = guardIdx + 1; i <= sinkIdx && !branched; i++) branched = used.test(code(lines[i]));
|
|
118
|
+
if (!branched) return result(false, 'result-never-branched-on');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (indentStyle) return indentDominates(lines, guardIdx, sinkIdx, gl);
|
|
122
|
+
return braceDominates(lines, guardIdx, sinkIdx, gl);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function declaredName(declLine) {
|
|
126
|
+
const c = code(declLine);
|
|
127
|
+
const kw = /(?:function\s*\*?\s*|def\s+|fn\s+|fun\s+|func\s+(?:\([^)]*\)\s*)?|sub\s+)([A-Za-z_$][\w$]*)/.exec(c);
|
|
128
|
+
if (kw) return kw[1];
|
|
129
|
+
const typed = /([A-Za-z_$][\w$]*)\s*\(/.exec(c);
|
|
130
|
+
return typed ? typed[1] : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The named function that contains line `idx`, as `{ declIdx, endIdx }`, or null at module level. */
|
|
134
|
+
function enclosingFunction(lines, idx, indentStyle) {
|
|
135
|
+
if (indentStyle) return enclosingFunctionByIndent(lines, idx, indentOf(lines[idx]));
|
|
136
|
+
let need = 0;
|
|
137
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
138
|
+
const c = code(lines[i]);
|
|
139
|
+
for (let k = c.length - 1; k >= 0; k--) {
|
|
140
|
+
if (c[k] === '}') need++;
|
|
141
|
+
else if (c[k] === '{') {
|
|
142
|
+
if (need > 0) { need--; continue; }
|
|
143
|
+
const head = isFunctionBoundary(c) ? i : (/^\s*\{\s*$/.test(c) && i > 0 && isFunctionBoundary(code(lines[i - 1])) ? i - 1 : -1);
|
|
144
|
+
if (head >= 0) {
|
|
145
|
+
let depth = 0; let end = lines.length - 1;
|
|
146
|
+
scan: for (let m = i; m < lines.length; m++) {
|
|
147
|
+
const t = code(lines[m]);
|
|
148
|
+
for (const ch of t) { if (ch === '{') depth++; else if (ch === '}') { depth--; if (depth === 0) { end = m; break scan; } } }
|
|
149
|
+
}
|
|
150
|
+
return { declIdx: head, endIdx: end };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function enclosingFunctionByIndent(lines, idx, ind) {
|
|
159
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
160
|
+
if (blank(lines[i])) continue;
|
|
161
|
+
const cur = indentOf(lines[i]);
|
|
162
|
+
if (cur >= ind) continue;
|
|
163
|
+
if (isFunctionBoundary(code(lines[i]))) {
|
|
164
|
+
let end = i;
|
|
165
|
+
for (let k = i + 1; k < lines.length; k++) { if (blank(lines[k])) continue; if (indentOf(lines[k]) <= cur) break; end = k; }
|
|
166
|
+
return { declIdx: i, endIdx: end };
|
|
167
|
+
}
|
|
168
|
+
ind = cur;
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The guard is in a helper function that ended before the sink. It protects the sink only if the sink's code calls that helper first and the guard cannot be skipped inside it. */
|
|
174
|
+
function calleeGuardDominates({ lines, guardIdx, sinkIdx, fn, file, depth }) {
|
|
175
|
+
if (depth >= 3) return result(false, 'call-chain-too-deep');
|
|
176
|
+
const name = declaredName(lines[fn.declIdx]);
|
|
177
|
+
if (!name) return result(false, 'different-function');
|
|
178
|
+
const call = new RegExp(`\\b${name.replace(/[$]/g, '\\$')}\\s*\\(`);
|
|
179
|
+
let callIdx = -1;
|
|
180
|
+
for (let i = fn.endIdx + 1; i <= sinkIdx; i++) { if (call.test(code(lines[i])) && !isFunctionBoundary(code(lines[i]))) { callIdx = i; break; } }
|
|
181
|
+
if (callIdx < 0) return result(false, 'different-function');
|
|
182
|
+
// 1. inside the helper the guard must be on every path to the helper's exit: at the function's own level, and either a straight-line
|
|
183
|
+
// statement or a check whose failing branch leaves. (The helper's exit is not a "sink", so this is judged on its own.)
|
|
184
|
+
const forced = guardForcedBeforeExit(lines, guardIdx, fn, looksIndented(lines, file));
|
|
185
|
+
if (!forced.ok) return result(false, `callee-guard-not-forced:${forced.reason}`);
|
|
186
|
+
// 2. in the sink's code the call must itself run before the sink
|
|
187
|
+
const outside = guardDominatesSink({ lines, guardIdx: callIdx, sinkIdx, file, depth: depth + 1 });
|
|
188
|
+
if (!outside.dominates) return result(false, `callee-not-forced-before-sink:${outside.reason}`);
|
|
189
|
+
return result(true, 'validator-helper-called-before-sink');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Is the guard on every path to the end of its own function? */
|
|
193
|
+
function guardForcedBeforeExit(lines, guardIdx, fn, indentStyle) {
|
|
194
|
+
const gl = code(lines[guardIdx]);
|
|
195
|
+
if (DATA_DECL.test(gl)) return { ok: false, reason: 'data-declaration-not-a-check' };
|
|
196
|
+
// The guard must sit at the function body's own nesting level, not inside an `if`, loop or `try` of its own.
|
|
197
|
+
if (indentStyle) {
|
|
198
|
+
let bodyIndent = -1;
|
|
199
|
+
for (let i = fn.declIdx + 1; i <= fn.endIdx; i++) if (!blank(lines[i])) { bodyIndent = indentOf(lines[i]); break; }
|
|
200
|
+
if (indentOf(lines[guardIdx]) !== bodyIndent) return { ok: false, reason: 'guard-nested-in-helper' };
|
|
201
|
+
} else {
|
|
202
|
+
let depth = 0; let opened = false;
|
|
203
|
+
for (let i = fn.declIdx; i < guardIdx; i++) {
|
|
204
|
+
for (const ch of code(lines[i])) { if (ch === '{') { depth++; opened = true; } else if (ch === '}') depth--; }
|
|
205
|
+
}
|
|
206
|
+
if (!opened || depth !== 1) return { ok: false, reason: 'guard-nested-in-helper' };
|
|
207
|
+
}
|
|
208
|
+
const conditional = CONDITIONAL_HEAD.test(gl);
|
|
209
|
+
if (!conditional) return { ok: true, reason: 'straight-line-in-helper' };
|
|
210
|
+
if (branchExits(lines, guardIdx, indentStyle)) return { ok: true, reason: 'failing-branch-exits' };
|
|
211
|
+
return { ok: false, reason: 'failing-branch-does-not-exit' };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const LITERAL_ASSIGN = /^\s*(?:[\w$.]+\s+)?([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\s*=\s*(?:'[^']*'|"[^"]*"|`[^`]*`|""|null|None|nil|undefined|false|0)\s*;?\s*$/;
|
|
215
|
+
const identsOf = (text) => new Set((String(text).match(/[A-Za-z_$][\w$]*/g) || []).map((x) => x.replace(/^\$/, '')));
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Does the failing branch REPLACE the value the condition checked with a literal (`if (bad($next)) { $next = "/"; }`)? Then no path reaches
|
|
219
|
+
* the sink carrying the unchecked value, which is what a guard is for. The assigned name must be one the condition names.
|
|
220
|
+
*/
|
|
221
|
+
function branchNeutralizes(lines, guardIdx, indentStyle) {
|
|
222
|
+
const gl = code(lines[guardIdx]);
|
|
223
|
+
const checked = identsOf(gl.replace(/^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard)\b/, ''));
|
|
224
|
+
const bodyIdx = [];
|
|
225
|
+
if (indentStyle) {
|
|
226
|
+
const gi = indentOf(lines[guardIdx]);
|
|
227
|
+
for (let i = guardIdx + 1; i < lines.length; i++) { if (blank(lines[i])) continue; if (indentOf(lines[i]) <= gi) break; bodyIdx.push(i); }
|
|
228
|
+
} else {
|
|
229
|
+
const block = branchRange(lines, guardIdx);
|
|
230
|
+
if (block) for (let i = block.start; i <= block.end; i++) bodyIdx.push(i);
|
|
231
|
+
}
|
|
232
|
+
for (const i of bodyIdx) {
|
|
233
|
+
const stmt = code(lines[i]).replace(/^[^{]*\{/, '').replace(/\}\s*$/, '');
|
|
234
|
+
for (const part of stmt.split(';')) {
|
|
235
|
+
const m = LITERAL_ASSIGN.exec(part.trim());
|
|
236
|
+
if (m && checked.has(m[1].replace(/^\$/, '').split('.')[0])) return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Does the branch a conditional guard opens leave the function (throw, return, exit, abort)? Polarity is not judged. */
|
|
243
|
+
function branchExits(lines, guardIdx, indentStyle) {
|
|
244
|
+
if (branchNeutralizes(lines, guardIdx, indentStyle)) return true;
|
|
245
|
+
const gl = code(lines[guardIdx]);
|
|
246
|
+
if (indentStyle) {
|
|
247
|
+
if (MODIFIER_EXIT.test(gl)) return true;
|
|
248
|
+
const gi = indentOf(lines[guardIdx]);
|
|
249
|
+
if (/:\s*\S/.test(gl) && TERMINATOR.test(gl.slice(gl.lastIndexOf(':') + 1))) return true;
|
|
250
|
+
const body = [];
|
|
251
|
+
for (let i = guardIdx + 1; i < lines.length; i++) { if (blank(lines[i])) continue; if (indentOf(lines[i]) <= gi) break; body.push(i); }
|
|
252
|
+
const inner = body.length ? indentOf(lines[body[0]]) : -1;
|
|
253
|
+
return body.some((i) => indentOf(lines[i]) === inner && TERMINATOR.test(code(lines[i])));
|
|
254
|
+
}
|
|
255
|
+
const block = branchRange(lines, guardIdx);
|
|
256
|
+
const afterCond = gl.replace(/^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard)\b/, '');
|
|
257
|
+
if (!block || block.start === guardIdx) {
|
|
258
|
+
if (TERMINATOR.test(afterCond.replace(/^\s*\([^)]*\)/, '')) || /\breturn\b|\bthrow\b/.test(afterCond)) return true;
|
|
259
|
+
}
|
|
260
|
+
return !!(block && branchTerminates(lines, block));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function braceDominates(lines, guardIdx, sinkIdx, gl) {
|
|
264
|
+
// Depth at the START of each line, relative to the guard line's own start.
|
|
265
|
+
const depthStart = new Array(sinkIdx - guardIdx + 2).fill(0);
|
|
266
|
+
let depth = 0;
|
|
267
|
+
for (let i = guardIdx; i <= sinkIdx; i++) {
|
|
268
|
+
depthStart[i - guardIdx] = depth;
|
|
269
|
+
const c = code(lines[i]);
|
|
270
|
+
const { open, close } = countBraces(c);
|
|
271
|
+
depth += open - close;
|
|
272
|
+
}
|
|
273
|
+
// The guard's enclosing block must still be open at the sink: nothing between may dip below the guard's own level.
|
|
274
|
+
for (let i = guardIdx + 1; i <= sinkIdx; i++) {
|
|
275
|
+
if (depthStart[i - guardIdx] < 0) return result(false, 'guard-block-closed-before-sink');
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (!CONDITIONAL_HEAD.test(gl) && !/\?[^:]*:/.test(gl)) return result(true, 'straight-line-before-sink');
|
|
279
|
+
if (/\?[^:]*:/.test(gl) && !CONDITIONAL_HEAD.test(gl)) return result(false, 'unknown-shape');
|
|
280
|
+
|
|
281
|
+
// A conditional guard. Case 1: the sink sits INSIDE the branch the condition opens.
|
|
282
|
+
const block = branchRange(lines, guardIdx);
|
|
283
|
+
if (block && sinkIdx > block.start && sinkIdx <= block.end) return result(true, 'sink-inside-guarded-branch');
|
|
284
|
+
// Case 2: the condition guards an early exit and the sink comes after it.
|
|
285
|
+
const afterCond = gl.replace(/^\s*(?:\}\s*)?(?:else\s+)?(?:if|unless|elif|elsif|while|until|when|guard)\b/, '');
|
|
286
|
+
if (!block || block.start === guardIdx) {
|
|
287
|
+
// Single-line form: `if (!allowed(h)) return;` : an exit on the same line, after the condition.
|
|
288
|
+
if (TERMINATOR.test(afterCond.replace(/^\s*\([^)]*\)/, '')) || /\breturn\b|\bthrow\b/.test(afterCond)) return result(true, 'early-exit-on-guard-line');
|
|
289
|
+
}
|
|
290
|
+
if (block && branchTerminates(lines, block)) return result(true, 'failing-branch-exits');
|
|
291
|
+
if (branchNeutralizes(lines, guardIdx, false)) return result(true, 'failing-branch-neutralizes-value');
|
|
292
|
+
return result(false, 'failing-branch-does-not-exit');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** [start, end] line indices of the `{ ... }` block opened at or after `idx`, or null. */
|
|
296
|
+
function branchRange(lines, idx) {
|
|
297
|
+
let depth = 0; let start = -1;
|
|
298
|
+
for (let i = idx; i < Math.min(lines.length, idx + 40); i++) {
|
|
299
|
+
const c = code(lines[i]);
|
|
300
|
+
for (const ch of c) {
|
|
301
|
+
if (ch === '{') { if (start < 0) start = i; depth++; }
|
|
302
|
+
else if (ch === '}') { depth--; if (start >= 0 && depth === 0) return { start, end: i }; }
|
|
303
|
+
}
|
|
304
|
+
// A brace-less single statement ends the search at the next line that is not part of the condition.
|
|
305
|
+
if (start < 0 && i > idx && !/[(&|,]\s*$/.test(code(lines[i - 1]))) return null;
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function branchTerminates(lines, block) {
|
|
311
|
+
let depth = 0;
|
|
312
|
+
for (let i = block.start; i <= block.end; i++) {
|
|
313
|
+
const c = code(lines[i]);
|
|
314
|
+
// Only a statement at the branch's own top level counts: a `return` buried in a nested `if` inside it does not stop the branch.
|
|
315
|
+
const startDepth = depth;
|
|
316
|
+
depth += countBraces(c).open - countBraces(c).close;
|
|
317
|
+
const topLevel = i === block.start ? true : startDepth === 1;
|
|
318
|
+
if (topLevel && TERMINATOR.test(i === block.start ? c.slice(c.indexOf('{') + 1) : c)) return true;
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function indentDominates(lines, guardIdx, sinkIdx, gl) {
|
|
324
|
+
const gi = indentOf(lines[guardIdx]);
|
|
325
|
+
// The guard's block must still enclose the sink: no non-blank line between may be LESS indented than the guard.
|
|
326
|
+
for (let i = guardIdx + 1; i <= sinkIdx; i++) {
|
|
327
|
+
if (blank(lines[i])) continue;
|
|
328
|
+
if (indentOf(lines[i]) < gi) return result(false, 'guard-block-closed-before-sink');
|
|
329
|
+
}
|
|
330
|
+
if (MODIFIER_EXIT.test(gl)) return result(true, 'early-exit-on-guard-line');
|
|
331
|
+
const head = CONDITIONAL_HEAD.test(gl) || /\bif\b|\bunless\b/.test(gl) && /:\s*$/.test(gl);
|
|
332
|
+
if (!head) return result(true, 'straight-line-before-sink');
|
|
333
|
+
|
|
334
|
+
// A conditional: collect the indented block that follows the header.
|
|
335
|
+
const bodyLines = [];
|
|
336
|
+
for (let i = guardIdx + 1; i < lines.length; i++) {
|
|
337
|
+
if (blank(lines[i])) continue;
|
|
338
|
+
if (indentOf(lines[i]) <= gi) break;
|
|
339
|
+
bodyLines.push(i);
|
|
340
|
+
}
|
|
341
|
+
if (bodyLines.includes(sinkIdx) || (bodyLines.length && sinkIdx > bodyLines[0] && sinkIdx <= bodyLines[bodyLines.length - 1])) {
|
|
342
|
+
return result(true, 'sink-inside-guarded-branch');
|
|
343
|
+
}
|
|
344
|
+
// One-liner `if not ok(x): raise ...`.
|
|
345
|
+
const afterColon = gl.slice(gl.lastIndexOf(':') + 1);
|
|
346
|
+
if (/:\s*\S/.test(gl) && TERMINATOR.test(afterColon)) return result(true, 'early-exit-on-guard-line');
|
|
347
|
+
// Direct statements of the block (one indent level in) that exit.
|
|
348
|
+
const inner = bodyLines.length ? indentOf(lines[bodyLines[0]]) : -1;
|
|
349
|
+
if (bodyLines.some((i) => indentOf(lines[i]) === inner && TERMINATOR.test(code(lines[i])))) return result(true, 'failing-branch-exits');
|
|
350
|
+
if (branchNeutralizes(lines, guardIdx, true)) return result(true, 'failing-branch-neutralizes-value');
|
|
351
|
+
return result(false, 'failing-branch-does-not-exit');
|
|
352
|
+
}
|
package/src/discovery/CLAUDE.md
CHANGED
|
@@ -53,6 +53,15 @@ panel · `judge.js` shapes findings and dedupes against the prior scan ·
|
|
|
53
53
|
one area), merged areas are capped, and hunter prompts for Haskell and Nix are redacted (split literals joined first) before they
|
|
54
54
|
leave the process. `hunt` includes `.hs`, `.lhs` and `.nix`. With no endpoint every run is degraded and says so.
|
|
55
55
|
|
|
56
|
+
## Invariant proposals (X-402)
|
|
57
|
+
|
|
58
|
+
`lenses.js` `invariantProposalFor(candidate)` maps a candidate from a state-oriented lens (`authz`, `business-logic`, and
|
|
59
|
+
`feature-abuse` when it is about tenants) to a PROPOSED invariant skeleton (`posture/invariants/lifecycle.js`): authored by the
|
|
60
|
+
model, with the candidate as source evidence and a high uncertainty. `runDiscovery` reports them as `invariantProposals` only when
|
|
61
|
+
`opts.invariantProposals = { config }` is passed AND the `invariant-scenarios` feature is enabled in that config; nothing else in the
|
|
62
|
+
report changes. A proposal is a hypothesis: it can be run as a scenario and its violations are reported as candidates, but only a
|
|
63
|
+
human reviewer's signed approval in the invariant ledger makes it a requirement, so a hunter never establishes its own ground truth.
|
|
64
|
+
|
|
56
65
|
## Advisory/gating separation (X-207)
|
|
57
66
|
|
|
58
67
|
Hunt output stays advisory in code, not just by convention. `memory.js` writes only through
|
package/src/discovery/index.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// pass that half failed and reports "no findings" is indistinguishable from a
|
|
9
9
|
// clean codebase unless it says so.
|
|
10
10
|
import { partitionCallGraph, partitionNixFiles } from './partition.js';
|
|
11
|
-
import { LENSES, lensByKey } from './lenses.js';
|
|
11
|
+
import { LENSES, lensByKey, invariantProposalFor } from './lenses.js';
|
|
12
12
|
import { runHunter } from './hunter.js';
|
|
13
13
|
import { confirmAll } from './confirm.js';
|
|
14
14
|
import { disprovePanel } from './disprove.js';
|
|
@@ -16,6 +16,7 @@ import { judgeCandidates } from './judge.js';
|
|
|
16
16
|
import { loadMemory, saveMemory, rememberRun, previouslyRefuted, nextWavePlan } from './memory.js';
|
|
17
17
|
import { emitVerification, headCommit } from '../posture/verification/emit.js';
|
|
18
18
|
import { promoteHypothesis } from '../posture/verification/hypothesis-promotion.js';
|
|
19
|
+
import { featureStatus } from '../posture/assurance/config.js';
|
|
19
20
|
|
|
20
21
|
// Bridge a candidate to the deterministic layer. A taint finding at or within
|
|
21
22
|
// two lines of the candidate corroborates it; a modelled sink on the line
|
|
@@ -295,6 +296,18 @@ export async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
295
296
|
if (e.ok) verificationRecords.push(e.record);
|
|
296
297
|
}
|
|
297
298
|
|
|
299
|
+
// X-402: opt-in invariant proposals. A candidate from a state-oriented lens can suggest a contract; the suggestion is advisory,
|
|
300
|
+
// `proposed`, authored by the model and never approved here. Off unless `opts.invariantProposals = { config }` and the
|
|
301
|
+
// `invariant-scenarios` feature is enabled in that config.
|
|
302
|
+
let invariantProposals = null;
|
|
303
|
+
if (opts.invariantProposals && featureStatus(opts.invariantProposals.config, 'invariant-scenarios').status === 'ok') {
|
|
304
|
+
invariantProposals = [];
|
|
305
|
+
for (const f of fresh) {
|
|
306
|
+
const inv = invariantProposalFor({ lens: f.discovery?.lens, title: f.vuln, description: f.description, file: f.file, line: f.line });
|
|
307
|
+
if (inv) invariantProposals.push({ hypothesisId: f.stableId, invariant: inv });
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
298
311
|
// X-207: opt-in promotion. A hypothesis crosses into a finding ONLY through `promoteHypothesis` (trusted verifier receipt,
|
|
299
312
|
// explicit policy, audit record). `fresh` is unchanged; promoted findings and every decision's audit record are reported
|
|
300
313
|
// separately, and nothing here writes `last-scan.json`. `opts.promote = { policy, verify(hypothesis) -> runOracle result }`.
|
|
@@ -316,6 +329,7 @@ export async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
316
329
|
fresh,
|
|
317
330
|
verificationRecords,
|
|
318
331
|
...(promotions ? { promotions } : {}),
|
|
332
|
+
...(invariantProposals ? { invariantProposals } : {}),
|
|
319
333
|
duplicates,
|
|
320
334
|
suppressed,
|
|
321
335
|
// `refutedCandidates` holds RAW candidates straight from `disprovePanel`,
|
package/src/discovery/lenses.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// no single prompt reaches. `wildcard` exists because a fixed taxonomy is a
|
|
9
9
|
// ceiling, and the classes worth finding are the ones not on the list.
|
|
10
10
|
import { redactPayload } from '../egress/redact.js';
|
|
11
|
+
import { inferredInvariant } from '../posture/invariants/lifecycle.js';
|
|
11
12
|
export const LENSES = Object.freeze([
|
|
12
13
|
{ key: 'injection', title: 'Injection', family: 'injection', cwe: 'CWE-74',
|
|
13
14
|
brief: 'Untrusted input reaching an interpreter: SQL, shell, template, XPath, LDAP, or deserialization. Follow the value, not the function name.' },
|
|
@@ -25,6 +26,23 @@ export const LENSES = Object.freeze([
|
|
|
25
26
|
brief: 'Anything the other lenses do not cover. Prefer the surprising and specific over the generic; report nothing rather than something already obvious.' },
|
|
26
27
|
]);
|
|
27
28
|
|
|
29
|
+
// X-402: the lenses that reason about who may do what to whose data can propose an INVARIANT for a candidate they raise. The
|
|
30
|
+
// proposal is a skeleton contract authored by the model (`proposed`, with the candidate as source evidence and a high uncertainty),
|
|
31
|
+
// advisory until a reviewer approves it in the signed ledger. A lens whose candidates are not about state (injection, crypto) has
|
|
32
|
+
// no mapping and proposes nothing.
|
|
33
|
+
const TENANT_HINT = /tenant|idor|ownership|object reference|cross[- ]?(?:account|user|org)|leak|another user|other user/i;
|
|
34
|
+
const WORKFLOW_HINT = /refund|order|status|workflow|state machine|out[- ]of[- ]order|step|approval|checkout|paid|ship/i;
|
|
35
|
+
|
|
36
|
+
export function invariantProposalFor(candidate) {
|
|
37
|
+
const text = `${candidate?.title || ''} ${candidate?.description || ''} ${candidate?.rationale || ''}`;
|
|
38
|
+
let cls = null;
|
|
39
|
+
if (candidate?.lens === 'authz') cls = TENANT_HINT.test(text) ? 'tenant-isolation' : 'privilege-constraint';
|
|
40
|
+
else if (candidate?.lens === 'feature-abuse' && TENANT_HINT.test(text)) cls = 'tenant-isolation';
|
|
41
|
+
else if (candidate?.lens === 'business-logic' && WORKFLOW_HINT.test(text)) cls = 'workflow-order';
|
|
42
|
+
if (!cls || typeof candidate.file !== 'string') return null;
|
|
43
|
+
return inferredInvariant({ class: cls, source: 'discovery-lens', evidence: { file: candidate.file, line: Number.isInteger(candidate.line) ? candidate.line : null, lens: candidate.lens }, uncertainty: 0.85 });
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
export function lensByKey(key) {
|
|
29
47
|
if (typeof key !== 'string') return null;
|
|
30
48
|
return LENSES.find(l => l.key === key) || null;
|