candor-ts 0.5.5 → 0.5.7
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/mcp.mjs +17 -3
- package/package.json +1 -1
- package/scan-core.mjs +13 -1
- package/scan.mjs +134 -16
package/mcp.mjs
CHANGED
|
@@ -45,6 +45,20 @@ function resolvePrefix(args) {
|
|
|
45
45
|
if (!hasReport(p)) throw new Error(`no report at \`${p}\` (.json or .<crate>.scan.json) — run a candor scan first`);
|
|
46
46
|
return p;
|
|
47
47
|
}
|
|
48
|
+
// Truncate a caller-supplied value echoed back in an error (a multi-MB `fn` would otherwise be reflected
|
|
49
|
+
// verbatim — token/memory amplification over the agent transport, the opposite of the list-cap thrift).
|
|
50
|
+
const clip = (s, n = 120) => { s = String(s); return s.length > n ? s.slice(0, n) + "…" : s; };
|
|
51
|
+
// Read a caller-supplied policy file CONFINED to the report's directory tree. The MCP surface is
|
|
52
|
+
// report-query-only (spec §7.12); an arbitrary `policy` path (/etc/passwd, ~/.aws/credentials) whose
|
|
53
|
+
// parsed deny-rule scopes are reflected back in violations[].rule is an arbitrary-file-read exfiltration
|
|
54
|
+
// channel — tie the policy to the project it gates.
|
|
55
|
+
function confinedPolicyRead(policyPath, prefix) {
|
|
56
|
+
const root = nodePath.resolve(nodePath.dirname(prefix));
|
|
57
|
+
const abs = nodePath.resolve(policyPath);
|
|
58
|
+
if (abs !== root && !abs.startsWith(root + nodePath.sep))
|
|
59
|
+
throw new Error(`policy must be within the report's directory (${root}) — refusing to read \`${clip(policyPath)}\``);
|
|
60
|
+
return fs.readFileSync(abs, "utf8");
|
|
61
|
+
}
|
|
48
62
|
|
|
49
63
|
// ---- the tools: name -> {description, schema, run} ------------------------------------------------
|
|
50
64
|
const reportArg = { report: { type: "string", description: "report prefix (optional; defaults to $CANDOR_REPORT)" } };
|
|
@@ -110,9 +124,9 @@ const TOOLS = {
|
|
|
110
124
|
description: "Hypothetically add `effect` to `fn` and report the blast radius; with `policy`, also the deny-rule violations it would cause. Pre-edit gate check.",
|
|
111
125
|
schema: { type: "object", properties: { fn: { type: "string" }, effect: { type: "string" }, policy: { type: "string", description: "path to a CANDOR_POLICY file (optional)" }, ...reportArg }, required: ["fn", "effect"] },
|
|
112
126
|
run: (a, p) => {
|
|
113
|
-
const pol = a.policy && fs.existsSync(a.policy) ? parsePolicy(
|
|
127
|
+
const pol = a.policy && fs.existsSync(a.policy) ? parsePolicy(confinedPolicyRead(a.policy, p)) : null;
|
|
114
128
|
const r = Q.whatif(Q.loadCallgraph(p), a.fn, a.effect, pol, scopeMatches);
|
|
115
|
-
if (r === null) throw new Error(`no function matching \`${a.fn}\` in the call graph`);
|
|
129
|
+
if (r === null) throw new Error(`no function matching \`${clip(a.fn)}\` in the call graph`);
|
|
116
130
|
return r;
|
|
117
131
|
},
|
|
118
132
|
},
|
|
@@ -156,7 +170,7 @@ function handle(msg) {
|
|
|
156
170
|
if (args.fn !== undefined) {
|
|
157
171
|
const names = [...new Set([...Object.keys(Q.loadCallgraph(prefix)), ...Q.loadReport(prefix).map((e) => e.fn)])];
|
|
158
172
|
if (Q.matches(names, args.fn).length === 0)
|
|
159
|
-
return result(id, { content: [{ type: "text", text: `candor: no function matching \`${args.fn}\` in this report` }], isError: true });
|
|
173
|
+
return result(id, { content: [{ type: "text", text: `candor: no function matching \`${clip(args.fn)}\` in this report` }], isError: true });
|
|
160
174
|
}
|
|
161
175
|
const out = t.run(args, prefix);
|
|
162
176
|
// Minified, not pretty-printed: the consumer is an AGENT (it parses the JSON), so the indentation
|
package/package.json
CHANGED
package/scan-core.mjs
CHANGED
|
@@ -40,6 +40,14 @@ export const KAPPA_RULES = [
|
|
|
40
40
|
// these three named validators are freed; every genuine verb (connect/createConnection/createServer…)
|
|
41
41
|
// stays Net (the matcher excludes ONLY new + the three validators, nothing else).
|
|
42
42
|
[/^(node:)?(net|dgram|tls|http2?|https)$/, /^(?!(new|isIP|isIPv4|isIPv6)$)/, "Net"],
|
|
43
|
+
// node:dns — name resolution is NETWORK I/O (lookup/lookupService hit the OS resolver; resolve*/
|
|
44
|
+
// reverse query DNS servers directly). Was unclassified, so a `dns.resolve(...)` read silently pure.
|
|
45
|
+
// Same construction-and-pure-accessor carve-out as the net cluster: `new dns.Resolver()` ("new") is
|
|
46
|
+
// inert, and the SERVER-CONFIG accessors getServers/setServers/get|setDefaultResultOrder touch no
|
|
47
|
+
// network (in-process config) — classifying them Net would FABRICATE the cardinal sin. Every genuine
|
|
48
|
+
// resolver verb (lookup/resolve4/resolveMx/reverse/…) stays Net. Covers node:dns/promises too.
|
|
49
|
+
[/^(node:)?dns(\/promises)?$/,
|
|
50
|
+
/^(?!(new|getServers|setServers|getDefaultResultOrder|setDefaultResultOrder)$)/, "Net"],
|
|
43
51
|
[/^(node:)?child_process$/, null, "Exec"],
|
|
44
52
|
[/^(node:)?sqlite$/, null, "Db"],
|
|
45
53
|
// the curated npm tier
|
|
@@ -52,7 +60,11 @@ export const KAPPA_RULES = [
|
|
|
52
60
|
// entropy: node:crypto's random surface + the password-hashing libs (salted -> Rand). Found by
|
|
53
61
|
// the CTA dogfood on a Nest app: argon2.hash came out SILENTLY PURE (the curated-kappa caveat
|
|
54
62
|
// landing on exactly the call a security review cares about).
|
|
55
|
-
[/^(node:)?crypto$/, /^random/, "Rand"],
|
|
63
|
+
[/^(node:)?crypto$/, /^(random|getRandomValues)/, "Rand"],
|
|
64
|
+
// node:os identity reads — userInfo (the OS user record) and hostname (the machine name) are
|
|
65
|
+
// environment/host reads (Env), like System.getenv's host-identity cousins. The rest of node:os
|
|
66
|
+
// (platform/arch/cpus/totalmem/…) is inert host introspection, left pure.
|
|
67
|
+
[/^(node:)?os$/, /^(userInfo|hostname)$/, "Env"],
|
|
56
68
|
[/^(argon2|bcrypt|bcryptjs)$/, null, "Rand"],
|
|
57
69
|
// The ORM tier — VERB-PRECISE (the CLASSIFIER discipline: tag the execution boundary, not
|
|
58
70
|
// builders; `createQueryBuilder` is pure, its `getMany`/`execute` is the I/O). Found on the
|
package/scan.mjs
CHANGED
|
@@ -242,7 +242,12 @@ const sources = program.getSourceFiles().filter((f) => projectFiles.has(path.res
|
|
|
242
242
|
function declModule(decl) {
|
|
243
243
|
const f = path.resolve(decl.getSourceFile().fileName);
|
|
244
244
|
if (projectFiles.has(f)) return "<local>";
|
|
245
|
-
|
|
245
|
+
// `(.+?)` not `(\w+?)`: a SUBPATH typing (`@types/node/fs/promises.d.ts`, `dns/promises.d.ts`) carries
|
|
246
|
+
// a `/` that `\w` can't cross, so the module collapsed to `@types/node` (via the node_modules branch
|
|
247
|
+
// below) and the `fs(\/promises)?` / `dns(\/promises)?` κ rules — written to cover exactly these — could
|
|
248
|
+
// never fire (`fs/promises` is the dominant modern Node FS API: a silent-pure under-report). Keep the
|
|
249
|
+
// slash so the module reads `fs/promises`, which the rules match.
|
|
250
|
+
let m = f.match(/@types\/node\/(.+?)\.d\.ts$/);
|
|
246
251
|
if (m) return m[1];
|
|
247
252
|
if (/typescript\/lib\/lib\..*\.d\.ts$/.test(f)) return "<es-lib>";
|
|
248
253
|
m = f.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)\//);
|
|
@@ -577,8 +582,10 @@ function realDecl(sym) {
|
|
|
577
582
|
// when the accessor's declaration lives in a project file (a UNIT we minted; edge to it). A resolved
|
|
578
583
|
// accessor we CAN'T see (external/typed-only declaration) returns local:false so the caller follows
|
|
579
584
|
// the existing Unknown/curated-κ posture — never silent-pure for a resolved-but-unseen accessor.
|
|
580
|
-
|
|
581
|
-
|
|
585
|
+
// A property SYMBOL → its accessor declaration of the wanted kind (or null). `local` is true when that
|
|
586
|
+
// declaration lives in a project file (a unit we minted; edge to it). Shared by every property-read
|
|
587
|
+
// shape: dot access, element access, and object destructuring.
|
|
588
|
+
function accessorFromSym(sym, kind /* "get" | "set" */) {
|
|
582
589
|
if (!sym) return null;
|
|
583
590
|
const want = kind === "get" ? ts.isGetAccessorDeclaration : ts.isSetAccessorDeclaration;
|
|
584
591
|
// A symbol is an accessor only if its declarations include an accessor of the wanted kind.
|
|
@@ -586,10 +593,59 @@ function accessorAt(propNode, kind /* "get" | "set" */) {
|
|
|
586
593
|
if (!decl) return null;
|
|
587
594
|
return { decl, local: projectFiles.has(path.resolve(decl.getSourceFile().fileName)) };
|
|
588
595
|
}
|
|
596
|
+
function accessorAt(propNode, kind /* "get" | "set" */) {
|
|
597
|
+
let sym;
|
|
598
|
+
if (ts.isElementAccessExpression(propNode)) {
|
|
599
|
+
// `c["prop"]` carries no `.name`; resolve the LITERAL key as a property on the receiver's type.
|
|
600
|
+
// A dynamic key (`c[k]`) can't be pinned to one property — leave it unresolved (the existing
|
|
601
|
+
// dynamic-access posture stands; resolving it would guess, never fabricate here).
|
|
602
|
+
const arg = propNode.argumentExpression;
|
|
603
|
+
sym = arg && ts.isStringLiteralLike(arg)
|
|
604
|
+
? checker.getTypeAtLocation(propNode.expression)?.getProperty?.(arg.text)
|
|
605
|
+
: null;
|
|
606
|
+
} else {
|
|
607
|
+
sym = checker.getSymbolAtLocation(propNode.name ?? propNode);
|
|
608
|
+
}
|
|
609
|
+
return accessorFromSym(sym, kind);
|
|
610
|
+
}
|
|
611
|
+
// Record a resolved accessor HIT (read or write) as an edge from `owner`: into the accessor UNIT when
|
|
612
|
+
// it's a local declaration we minted; otherwise Unknown (a resolved-but-unseen accessor body — never
|
|
613
|
+
// silent-pure, SPEC §4). `label` tags the §-why disclosure.
|
|
614
|
+
function recordAccessorHit(owner, hit, label) {
|
|
615
|
+
const rec = fns.get(owner);
|
|
616
|
+
const t = nodeName.get(hit.decl);
|
|
617
|
+
if (hit.local && t) {
|
|
618
|
+
rec.edges.add(t); // (EDGE) into the accessor unit — effects propagate
|
|
619
|
+
} else {
|
|
620
|
+
rec.direct.add("Unknown");
|
|
621
|
+
rec.why.add(`accessor:${label}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Object PROPERTY-ENUMERATION (`{...obj}`, `const {...rest} = obj`, `Object.assign(t, obj)`): copying an
|
|
626
|
+
// object's own enumerable props INVOKES each source getter — the whole-object analog of `obj.prop`,
|
|
627
|
+
// invisible to the property-access arm (no PropertyAccess node per key). Edge `owner` to every LOCAL
|
|
628
|
+
// getter on the source type. A rest/spread can't name one key, so ALL getters are enumerated (sound
|
|
629
|
+
// over-approximation); a plain prop resolves to no accessor and adds nothing (no fabrication).
|
|
630
|
+
function enumerateGetters(owner, type) {
|
|
631
|
+
if (!owner || !type || !type.getProperties) return;
|
|
632
|
+
for (const p of type.getProperties()) {
|
|
633
|
+
const hit = accessorFromSym(p, "get");
|
|
634
|
+
if (hit) recordAccessorHit(owner, hit, p.getName());
|
|
635
|
+
}
|
|
636
|
+
}
|
|
589
637
|
|
|
590
638
|
// nearest enclosing analyzed function (closures attribute to it — SEMANTICS §2)
|
|
591
639
|
function enclosing(node) {
|
|
592
640
|
for (let p = node; p; p = p.parent) {
|
|
641
|
+
// A call/effect lexically inside a DECORATOR (`@factory(arg)`) runs at class-DEFINITION time, NOT in
|
|
642
|
+
// the decorated declaration's body. The parent chain of a decorator's expression is
|
|
643
|
+
// CallExpression → Decorator → MethodDeclaration/ClassDeclaration/Parameter, so `enclosing` otherwise
|
|
644
|
+
// lands on the decorated unit and FABRICATES the factory's effects onto that method/class/param and
|
|
645
|
+
// every transitive caller (a cardinal sin — @Entity/@Injectable factories that touch I/O would
|
|
646
|
+
// poison every decorated handler). Stop at the Decorator: the factory's own effects live in its own
|
|
647
|
+
// function unit; the application site attributes to nothing (load-time, like a no-arg decorator).
|
|
648
|
+
if (ts.isDecorator(p)) return null;
|
|
593
649
|
const n = nodeName.get(p);
|
|
594
650
|
if (n) return n;
|
|
595
651
|
}
|
|
@@ -697,6 +753,22 @@ function visitCalls(node) {
|
|
|
697
753
|
}
|
|
698
754
|
} else {
|
|
699
755
|
const mod = declModule(decl);
|
|
756
|
+
// A LOCAL function/method passed BY REFERENCE to a NON-LOCAL (opaque) callee — `xs.map(loadFree)`,
|
|
757
|
+
// `arr.forEach(this.m)`, `setTimeout(handler)`, `external(cb)` — may be INVOKED by that callee, so
|
|
758
|
+
// its effects are reachable here. The precise callback-flow below only resolves a LOCAL callee's
|
|
759
|
+
// params; a non-local HOF dropped the reference entirely (a silent-pure hole — confirmed for
|
|
760
|
+
// `map`/`forEach`/`reduce`). Edge to the referenced unit: the sound over-approximation, matching
|
|
761
|
+
// the Rust engine's fn-as-value edge. An inline closure is already charged lexically; a non-fn
|
|
762
|
+
// argument resolves to no minted unit (`nodeName` miss) and adds nothing — no fabrication. Gated
|
|
763
|
+
// on a non-local callee so a local callee that merely STORES (never invokes) keeps its precision.
|
|
764
|
+
if (mod !== "<local>") {
|
|
765
|
+
for (const a of node.arguments ?? []) {
|
|
766
|
+
if (!ts.isIdentifier(a) && !ts.isPropertyAccessExpression(a)) continue;
|
|
767
|
+
const d2 = realDecl(checker.getSymbolAtLocation(a));
|
|
768
|
+
const t = d2 && nodeName.get(d2);
|
|
769
|
+
if (t) rec.edges.add(t);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
700
772
|
if (mod === "<local>") {
|
|
701
773
|
const targetName = nodeName.get(decl);
|
|
702
774
|
if (targetName) {
|
|
@@ -971,6 +1043,34 @@ function visitCalls(node) {
|
|
|
971
1043
|
const owner = enclosing(node);
|
|
972
1044
|
if (owner) fns.get(owner).direct.add("Env");
|
|
973
1045
|
}
|
|
1046
|
+
// Runtime GLOBALS reached as CALLS with no import for the κ resolver to classify: `process.hrtime()`/
|
|
1047
|
+
// `.hrtime.bigint()` is a monotonic clock read (Clock); `process.send(...)` is the child↔parent IPC
|
|
1048
|
+
// channel (Ipc); the global `fetch(...)` is the standard modern HTTP client (Net). Matched on the
|
|
1049
|
+
// callee — `process.*` by exact text (mirroring the process.env match), `fetch` by identifier whose
|
|
1050
|
+
// symbol is NOT a local declaration (so a project's own `fetch` shadow never fabricates Net).
|
|
1051
|
+
if (ts.isCallExpression(node)) {
|
|
1052
|
+
const callee = node.expression;
|
|
1053
|
+
const ctext = callee.getText().replace(/\s+/g, "");
|
|
1054
|
+
let geff = null;
|
|
1055
|
+
if (ctext === "process.hrtime" || ctext === "process.hrtime.bigint") geff = "Clock";
|
|
1056
|
+
else if (ctext === "process.send") geff = "Ipc";
|
|
1057
|
+
else if (ts.isIdentifier(callee) && callee.text === "fetch"
|
|
1058
|
+
&& !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
|
|
1059
|
+
.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
|
|
1060
|
+
geff = "Net";
|
|
1061
|
+
if (geff) {
|
|
1062
|
+
const owner = enclosing(node);
|
|
1063
|
+
if (owner) fns.get(owner).direct.add(geff);
|
|
1064
|
+
}
|
|
1065
|
+
// Object.assign(target, ...sources) copies each SOURCE's own enumerable props → invokes their
|
|
1066
|
+
// getters (the object-spread twin). Enumerate the sources' local getters.
|
|
1067
|
+
if (callee.getText().replace(/\s+/g, "") === "Object.assign") {
|
|
1068
|
+
const owner = enclosing(node);
|
|
1069
|
+
for (const src of (node.arguments ?? []).slice(1)) {
|
|
1070
|
+
enumerateGetters(owner, checker.getTypeAtLocation(src));
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
974
1074
|
// GET/SET ACCESSOR access (the silent-pure-accessor fix): a property read that resolves to a
|
|
975
1075
|
// getter, or a property assignment whose target resolves to a setter, is effectively a call into
|
|
976
1076
|
// the accessor body — model it as a call EDGE so the accessor's effects propagate (like a method
|
|
@@ -987,16 +1087,31 @@ function visitCalls(node) {
|
|
|
987
1087
|
if (hit) {
|
|
988
1088
|
const owner = enclosing(node);
|
|
989
1089
|
if (owner) {
|
|
990
|
-
const
|
|
991
|
-
const
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1090
|
+
const an = hit.decl.parent?.name?.getText?.() ?? "?";
|
|
1091
|
+
const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
|
|
1092
|
+
recordAccessorHit(owner, hit, `${an}.${pn}`);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
// OBJECT-DESTRUCTURING getter read (`const { prop } = obj`): each bound property is a READ that may
|
|
1097
|
+
// resolve to a getter whose body does I/O — the binding-pattern analog of `obj.prop`, invisible to
|
|
1098
|
+
// the property-access arm above because there is no PropertyAccess/ElementAccess node. (ARRAY
|
|
1099
|
+
// destructuring is ITERATION, handled below; object destructuring copies named own/inherited props,
|
|
1100
|
+
// invoking each getter.) Resolve every bound key as a property on the initializer's type; a rest
|
|
1101
|
+
// element / computed key can't be pinned to one accessor, so it's skipped (no fabrication).
|
|
1102
|
+
if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer) {
|
|
1103
|
+
const owner = enclosing(node);
|
|
1104
|
+
if (owner) {
|
|
1105
|
+
const recvType = checker.getTypeAtLocation(node.initializer);
|
|
1106
|
+
for (const el of node.name.elements) {
|
|
1107
|
+
if (el.dotDotDotToken) { enumerateGetters(owner, recvType); continue; } // `...rest` copies every
|
|
1108
|
+
// remaining prop → invokes every (remaining) getter; enumerate all (the bound ones double-handle).
|
|
1109
|
+
const key = el.propertyName ?? el.name; // `{prop}` shorthand, or `{prop: alias}`
|
|
1110
|
+
const keyName = ts.isIdentifier(key) ? key.text
|
|
1111
|
+
: ts.isStringLiteralLike(key) ? key.text : null;
|
|
1112
|
+
if (keyName === null) continue; // computed key (`{[k]: v}`) — unresolvable to one property
|
|
1113
|
+
const hit = accessorFromSym(recvType?.getProperty?.(keyName), "get");
|
|
1114
|
+
if (hit) recordAccessorHit(owner, hit, keyName);
|
|
1000
1115
|
}
|
|
1001
1116
|
}
|
|
1002
1117
|
}
|
|
@@ -1008,9 +1123,12 @@ function visitCalls(node) {
|
|
|
1008
1123
|
let iterExpr = null, iterAsync = false;
|
|
1009
1124
|
if (ts.isForOfStatement(node)) { iterExpr = node.expression; iterAsync = !!node.awaitModifier; }
|
|
1010
1125
|
else if (ts.isSpreadElement(node)) iterExpr = node.expression; // [...bag] / f(...bag)
|
|
1011
|
-
else if (ts.isSpreadAssignment(node))
|
|
1012
|
-
|
|
1013
|
-
|
|
1126
|
+
else if (ts.isSpreadAssignment(node)) {
|
|
1127
|
+
iterExpr = node.expression; // {...bag} — object spread is NOT iteration (copies own enumerable
|
|
1128
|
+
// props, no [Symbol.iterator]); wellKnownSymbolMember finds none and edges nothing for iteration.
|
|
1129
|
+
// But the copy DOES invoke each source getter — enumerate them (the silent-pure object-spread hole).
|
|
1130
|
+
enumerateGetters(enclosing(node), checker.getTypeAtLocation(node.expression));
|
|
1131
|
+
}
|
|
1014
1132
|
else if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer)
|
|
1015
1133
|
iterExpr = node.initializer; // const [a] = bag
|
|
1016
1134
|
else if (ts.isCallExpression(node) && node.arguments?.[0]
|