candor-ts 0.5.13 → 0.5.14
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/package.json +1 -1
- package/policy.mjs +5 -1
- package/scan-core.mjs +8 -1
- package/scan.mjs +105 -24
package/package.json
CHANGED
package/policy.mjs
CHANGED
|
@@ -14,7 +14,11 @@ const ASCII_WS = /[ \t\n\v\f\r]+/;
|
|
|
14
14
|
const ASCII_WS_TRIM = /^[ \t\n\v\f\r]+|[ \t\n\v\f\r]+$/g;
|
|
15
15
|
export function parsePolicy(text) {
|
|
16
16
|
const deny = [], allow = [], forbid = [];
|
|
17
|
-
|
|
17
|
+
// Split LINES on \n / \r\n / bare \r — the three forms Java's Files.readAllLines (the reference parser)
|
|
18
|
+
// breaks on. Splitting on \n ONLY let a classic-Mac (bare-\r) file collapse to one line: \r is also an
|
|
19
|
+
// in-line ASCII-ws token separator (below), so every rule after the first was glued into the first rule's
|
|
20
|
+
// tokens and dropped — a gateless-green divergence (sweep [16]/[17]). \v/\f stay in-line separators.
|
|
21
|
+
for (const rawLine of text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
|
|
18
22
|
const line = rawLine.split("#")[0].replace(ASCII_WS_TRIM, "");
|
|
19
23
|
if (!line) continue;
|
|
20
24
|
const t = line.split(ASCII_WS);
|
package/scan-core.mjs
CHANGED
|
@@ -39,7 +39,14 @@ export const KAPPA_RULES = [
|
|
|
39
39
|
// FABRICATED Net — the cardinal sin — purely from this classification, with no local Net edge). Only
|
|
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
|
+
// ALSO exempt the pure CONFIG/METADATA members the whole-module rule fabricated Net on (sweep [9], the
|
|
43
|
+
// cardinal sin — none touch a socket/fd/syscall): tls.getCiphers/createSecureContext/checkServerIdentity
|
|
44
|
+
// (cipher-list + cert helpers), http.validateHeaderName/validateHeaderValue (string validators, like
|
|
45
|
+
// isIP), and a Socket/Server's setKeepAlive/setNoDelay/ref/unref/address (TCP-option + bound-address
|
|
46
|
+
// metadata — no I/O). Every genuine verb still classifies; only these proven-pure names are freed.
|
|
47
|
+
[/^(node:)?(net|dgram|tls|http2?|https)$/,
|
|
48
|
+
/^(?!(new|isIP|isIPv4|isIPv6|getCiphers|createSecureContext|checkServerIdentity|validateHeaderName|validateHeaderValue|setKeepAlive|setNoDelay|ref|unref|address)$)/,
|
|
49
|
+
"Net"],
|
|
43
50
|
// node:dns — name resolution is NETWORK I/O (lookup/lookupService hit the OS resolver; resolve*/
|
|
44
51
|
// reverse query DNS servers directly). Was unclassified, so a `dns.resolve(...)` read silently pure.
|
|
45
52
|
// Same construction-and-pure-accessor carve-out as the net cluster: `new dns.Resolver()` ("new") is
|
package/scan.mjs
CHANGED
|
@@ -305,6 +305,25 @@ function firstStringLiteral(node) {
|
|
|
305
305
|
return null;
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
// Is this property/element access a SETTER target reached through a destructuring assignment —
|
|
309
|
+
// `({ k: x.prop } = src)` or `[x.prop] = arr` (sweep [32])? Walk up through PropertyAssignment /
|
|
310
|
+
// Object|ArrayLiteral wrappers to the enclosing `=`; it is a target only when the wrapping literal is the
|
|
311
|
+
// LHS (`.left`) of the assignment. A property access on the RHS (`src = { k: x.prop }`) walks to a literal
|
|
312
|
+
// that is `.right`, so it stays a getter READ — no false setter attribution.
|
|
313
|
+
function isDestructuringAssignTarget(node) {
|
|
314
|
+
let cur = node, parent = node.parent;
|
|
315
|
+
while (parent) {
|
|
316
|
+
if (ts.isPropertyAssignment(parent) && parent.initializer === cur) { cur = parent; parent = parent.parent; continue; }
|
|
317
|
+
if (ts.isShorthandPropertyAssignment(parent)) return false; // `{prop}` has no access node to attribute
|
|
318
|
+
if (ts.isSpreadAssignment(parent) || ts.isSpreadElement(parent)) { cur = parent; parent = parent.parent; continue; }
|
|
319
|
+
if (ts.isObjectLiteralExpression(parent) || ts.isArrayLiteralExpression(parent)) { cur = parent; parent = parent.parent; continue; }
|
|
320
|
+
if (ts.isBinaryExpression(parent) && parent.operatorToken.kind === ts.SyntaxKind.EqualsToken)
|
|
321
|
+
return parent.left === cur;
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
308
327
|
// The literal PROGRAM head a subprocess call NAMES — argv[0] specifically, never a later argument.
|
|
309
328
|
// Unlike firstStringLiteral (the first literal ANYWHERE in the args), this refuses to refine when
|
|
310
329
|
// the program (arg0) is a runtime value but a trailing arg is a literal whose basename hits the head
|
|
@@ -754,8 +773,22 @@ function visitCalls(node) {
|
|
|
754
773
|
// `new ExternalClass()` with an implicit ctor: same posture as an explicit external ctor
|
|
755
774
|
// the classifier doesn't know — OPAQUE (contributes nothing), not Unknown. Consistency:
|
|
756
775
|
// whether a library declares its ctor must not change the verdict.
|
|
757
|
-
else if (cd && ts.isClassDeclaration(cd) && !projectFiles.has(path.resolve(cd.getSourceFile().fileName)))
|
|
776
|
+
else if (cd && ts.isClassDeclaration(cd) && !projectFiles.has(path.resolve(cd.getSourceFile().fileName))) {
|
|
758
777
|
externalClass = true;
|
|
778
|
+
// …but the construction DOES reach the class's package — disclose it as `invisible` (sweep
|
|
779
|
+
// [13]) so the pure verdict is qualified, exactly like an unmodeled METHOD call below. Without
|
|
780
|
+
// this, `new Pool()` from an unmodeled pkg read plain pure with no disclosure, no κ-ledger.
|
|
781
|
+
const cfile = cd.getSourceFile().fileName;
|
|
782
|
+
const cmod = declModule(cd);
|
|
783
|
+
const cpkg = cmod.startsWith("@types/") ? cmod.slice("@types/".length) : cmod;
|
|
784
|
+
const cdeclared = packageManifestEffects(cfile);
|
|
785
|
+
if (cdeclared !== null) { for (const e of cdeclared) rec.direct.add(e); }
|
|
786
|
+
else if (!cmod.startsWith("<") && !kappaKnows(cpkg) && !depCoveredPkgs.has(cpkg)
|
|
787
|
+
&& /node_modules\//.test(cfile) && !/node_modules\/(@types\/node|typescript)\//.test(cfile)) {
|
|
788
|
+
unlistedSeen.set(cpkg, (unlistedSeen.get(cpkg) ?? 0) + 1);
|
|
789
|
+
rec.blind.add(cpkg);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
759
792
|
}
|
|
760
793
|
if (!edged && !externalClass) {
|
|
761
794
|
rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
|
|
@@ -981,8 +1014,24 @@ function visitCalls(node) {
|
|
|
981
1014
|
// still synthesize "new" and stay pure.
|
|
982
1015
|
const CONNECTING_CTORS = new Set(["ClientRequest"]);
|
|
983
1016
|
// Host-ESTABLISHING Net call names (the masking-fix allowlist): a Net call by one of these whose
|
|
984
|
-
// host is not a captured literal leaves the host invisible. Excludes use-verbs (write/end/send
|
|
985
|
-
|
|
1017
|
+
// host is not a captured literal leaves the host invisible. Excludes use-verbs (write/end/send on
|
|
1018
|
+
// a connected socket). `post/put/patch/delete/head/options` cover the axios/got/undici tier whose
|
|
1019
|
+
// URL is the call arg (sweep [18]); `dgram.send(buf,port,host)` is added module-aware below (UDP
|
|
1020
|
+
// has no connect, so send carries the destination — sweep [12]).
|
|
1021
|
+
const NET_ESTABLISHING = new Set(["request", "get", "post", "put", "patch", "delete", "head",
|
|
1022
|
+
"options", "connect", "createConnection", "fetch"]);
|
|
1023
|
+
// Fs/Exec USE-verbs whose LOCATOR was fixed earlier, not an arg of THIS call — so a missing literal
|
|
1024
|
+
// here is the legitimate split-construct/use shape, never the masking signal (the establishing-
|
|
1025
|
+
// allowlist discipline, generalized from Net to all 4 effects; sweep [11]). Fs: the fd/FileHandle
|
|
1026
|
+
// ops (fd came from open()); the path-taking fs.* fns are establishing. Exec: ChildProcess methods
|
|
1027
|
+
// (the command was fixed at spawn); the spawn fns are establishing.
|
|
1028
|
+
const FS_USE_VERBS = new Set(["write", "writeSync", "read", "readSync", "close", "closeSync",
|
|
1029
|
+
"fsync", "fsyncSync", "fdatasync", "fdatasyncSync", "ftruncate", "ftruncateSync", "fchmod",
|
|
1030
|
+
"fchmodSync", "fchown", "fchownSync", "futimes", "futimesSync", "fstat", "fstatSync"]);
|
|
1031
|
+
const EXEC_USE_VERBS = new Set(["kill", "send", "disconnect", "ref", "unref"]);
|
|
1032
|
+
const netEstablishing = (member) =>
|
|
1033
|
+
CONNECTING_CTORS.has(ctorClassName) || NET_ESTABLISHING.has(member)
|
|
1034
|
+
|| (/^(node:)?dgram$/.test(mod) && member === "send");
|
|
986
1035
|
const ctorClassName = ts.isNewExpression(node)
|
|
987
1036
|
? (ts.isConstructorDeclaration(decl) ? decl.parent?.name?.getText?.()
|
|
988
1037
|
: (decl.name ? decl.name.getText() : ""))
|
|
@@ -1010,15 +1059,16 @@ function visitCalls(node) {
|
|
|
1010
1059
|
if (h) rec.hosts.add(h);
|
|
1011
1060
|
// MASKING fix: a host-ESTABLISHING Net call whose host is NOT a captured literal (runtime URL, or
|
|
1012
1061
|
// built elsewhere) leaves the host invisible to the gate → mark the surface incomplete so a
|
|
1013
|
-
// benign literal can't mask it. ALLOWLIST of establishing forms only
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1016
|
-
//
|
|
1017
|
-
else if (
|
|
1062
|
+
// benign literal can't mask it. ALLOWLIST of establishing forms only — NEVER use-calls
|
|
1063
|
+
// (write/end/non-dgram send), which would false-positive on `socket.connect("h").write(data)`
|
|
1064
|
+
// (the host is captured at connect). Under-catches an unlisted establishing verb (safe
|
|
1065
|
+
// direction); never over-flags a use-call.
|
|
1066
|
+
else if (netEstablishing(member))
|
|
1018
1067
|
rec.incomplete.add("Net");
|
|
1019
1068
|
}
|
|
1020
1069
|
if (eff === "Db") {
|
|
1021
1070
|
const lit = firstStringLiteral(node);
|
|
1071
|
+
const before = rec.tables.size;
|
|
1022
1072
|
for (const t of lit ? tablesInSql(lit) : []) rec.tables.add(t);
|
|
1023
1073
|
// ORM route: `this.userRepository.find(…)` — the receiver's `Repository<UserEntity>`
|
|
1024
1074
|
// type argument names the entity; its `@Entity("user")` decorator names the table.
|
|
@@ -1030,6 +1080,11 @@ function visitCalls(node) {
|
|
|
1030
1080
|
if (tbl) rec.tables.add(tbl);
|
|
1031
1081
|
}
|
|
1032
1082
|
}
|
|
1083
|
+
// masking: a Db call that surfaced NO table (no SQL literal, no entity-typed receiver) reaches a
|
|
1084
|
+
// runtime/invisible table — a benign sibling query's literal table must not mask it. The entity
|
|
1085
|
+
// route above is NOT a literal so it still counts as visible (a captured table); only a fully
|
|
1086
|
+
// invisible query marks incomplete. `new` (a connection ctor) carries no table — skip it.
|
|
1087
|
+
if (rec.tables.size === before && member !== "new") rec.incomplete.add("Db");
|
|
1033
1088
|
}
|
|
1034
1089
|
if (eff === "Exec") {
|
|
1035
1090
|
const lit = firstStringLiteral(node);
|
|
@@ -1039,10 +1094,18 @@ function visitCalls(node) {
|
|
|
1039
1094
|
// names no static program, so its trailing literal must not fabricate Net (spec §4).
|
|
1040
1095
|
const head = programHeadLiteral(node);
|
|
1041
1096
|
if (head) for (const e of commandHeadEffects(head)) rec.direct.add(e);
|
|
1097
|
+
// masking (sweep [11]): an Exec call whose program head is NOT a static literal (runtime
|
|
1098
|
+
// command) leaves the command invisible. Establishing = the spawn fns; ChildProcess use-verbs
|
|
1099
|
+
// (kill/send/disconnect/ref/unref) carry no command and are excluded.
|
|
1100
|
+
else if (!EXEC_USE_VERBS.has(member)) rec.incomplete.add("Exec");
|
|
1042
1101
|
}
|
|
1043
1102
|
if (eff === "Fs") {
|
|
1044
1103
|
const lit = firstStringLiteral(node);
|
|
1045
|
-
|
|
1104
|
+
const pathCaptured = lit && /[/\\]|^[.~]/.test(lit); // path-shaped literals only
|
|
1105
|
+
if (pathCaptured) rec.paths.add(lit);
|
|
1106
|
+
// masking (sweep [11]): a path-taking fs.* call whose path is NOT a captured literal (runtime
|
|
1107
|
+
// path) leaves it invisible. fd/FileHandle USE-verbs (fd came from a prior open()) are excluded.
|
|
1108
|
+
else if (!FS_USE_VERBS.has(member)) rec.incomplete.add("Fs");
|
|
1046
1109
|
}
|
|
1047
1110
|
// CANDOR_DEPS: an unclassified call into a package with a loaded sibling report inherits
|
|
1048
1111
|
// that function's recorded transitive effects (+ literal surfaces) by `hash`.
|
|
@@ -1131,8 +1194,19 @@ function visitCalls(node) {
|
|
|
1131
1194
|
const callee = node.expression;
|
|
1132
1195
|
const ctext = callee.getText().replace(/\s+/g, "");
|
|
1133
1196
|
let geff = null;
|
|
1134
|
-
|
|
1135
|
-
|
|
1197
|
+
// `process.*` is matched by exact text, so a project's OWN `const process = {…}` shadow would
|
|
1198
|
+
// fabricate Clock/Ipc on a pure local method (sweep [31]) — guard it like the `fetch` arm: resolve the
|
|
1199
|
+
// ROOT `process` identifier and fire only when it is NOT a project-local declaration (i.e. the global).
|
|
1200
|
+
const processIsGlobal = () => {
|
|
1201
|
+
if (!ts.isPropertyAccessExpression(callee)) return false;
|
|
1202
|
+
let root = callee.expression;
|
|
1203
|
+
while (ts.isPropertyAccessExpression(root)) root = root.expression; // process.hrtime.bigint → process
|
|
1204
|
+
if (!ts.isIdentifier(root) || root.text !== "process") return false;
|
|
1205
|
+
return !(checker.getSymbolAtLocation(root)?.declarations ?? [])
|
|
1206
|
+
.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName)));
|
|
1207
|
+
};
|
|
1208
|
+
if ((ctext === "process.hrtime" || ctext === "process.hrtime.bigint") && processIsGlobal()) geff = "Clock";
|
|
1209
|
+
else if (ctext === "process.send" && processIsGlobal()) geff = "Ipc";
|
|
1136
1210
|
else if (ts.isIdentifier(callee) && callee.text === "fetch"
|
|
1137
1211
|
&& !(checker.getSymbolAtLocation(callee)?.declarations ?? [])
|
|
1138
1212
|
.some((d) => projectFiles.has(path.resolve(d.getSourceFile().fileName))))
|
|
@@ -1161,21 +1235,28 @@ function visitCalls(node) {
|
|
|
1161
1235
|
// call), never silently pure. A resolved-but-UNSEEN accessor (external declaration) reads Unknown,
|
|
1162
1236
|
// following the same posture as an unresolvable call (SPEC §4).
|
|
1163
1237
|
if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
|
|
1164
|
-
// Is this property access
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1238
|
+
// Is this property access an assignment TARGET? A simple `x.prop = v` invokes the SETTER only. A
|
|
1239
|
+
// COMPOUND/LOGICAL assignment (`+=`,`-=`,`??=`,`||=`,`&&=`,…) reads the current value AND writes — both
|
|
1240
|
+
// the getter and the setter run (sweep [10]; pre-fix only a bare `=` was a setter site, so an effectful
|
|
1241
|
+
// setter under `+=`/`??=` read PURE). A DESTRUCTURING-assignment target (`({k: x.prop} = src)` /
|
|
1242
|
+
// `[x.prop] = arr`) is also a setter site, invisible to the simple-LHS test (sweep [32]).
|
|
1167
1243
|
const p = node.parent;
|
|
1168
|
-
const
|
|
1169
|
-
|
|
1170
|
-
const
|
|
1171
|
-
|
|
1244
|
+
const isBinAssign = p && ts.isBinaryExpression(p) && p.left === node;
|
|
1245
|
+
const simpleAssign = isBinAssign && p.operatorToken.kind === ts.SyntaxKind.EqualsToken;
|
|
1246
|
+
const compoundAssign = isBinAssign && !simpleAssign
|
|
1247
|
+
&& p.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && p.operatorToken.kind <= ts.SyntaxKind.LastAssignment;
|
|
1248
|
+
const recordKind = (kind) => {
|
|
1249
|
+
const hit = accessorAt(node, kind);
|
|
1250
|
+
if (!hit) return;
|
|
1172
1251
|
const owner = enclosing(node);
|
|
1173
|
-
if (owner)
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1252
|
+
if (!owner) return;
|
|
1253
|
+
const an = hit.decl.parent?.name?.getText?.() ?? "?";
|
|
1254
|
+
const pn = node.name?.getText?.() ?? node.argumentExpression?.getText?.() ?? "?";
|
|
1255
|
+
recordAccessorHit(owner, hit, `${an}.${pn}`);
|
|
1256
|
+
};
|
|
1257
|
+
if (simpleAssign || isDestructuringAssignTarget(node)) recordKind("set");
|
|
1258
|
+
else if (compoundAssign) { recordKind("get"); recordKind("set"); }
|
|
1259
|
+
else recordKind("get");
|
|
1179
1260
|
}
|
|
1180
1261
|
// OBJECT-DESTRUCTURING getter read (`const { prop } = obj`): each bound property is a READ that may
|
|
1181
1262
|
// resolve to a getter whose body does I/O — the binding-pattern analog of `obj.prop`, invisible to
|