candor-ts 0.5.12 → 0.5.13
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/AGENTS.md +4 -1
- package/package.json +1 -1
- package/policy.mjs +6 -2
- package/scan.mjs +30 -5
package/AGENTS.md
CHANGED
|
@@ -144,7 +144,10 @@ curated-κ caveat cuts the other way:** a call into an npm package κ doesn't kn
|
|
|
144
144
|
NOTHING — invisible, not `Unknown`. The scan's receipt now DISCLOSES these by name (`κ doesn't
|
|
145
145
|
know N packages…`), so the blind spots are per-scan evidence, not a doc footnote: never conclude
|
|
146
146
|
"no effect" through a package that line names (the documented weaker edge of the
|
|
147
|
-
never-silently-pure promise, same as every candor engine's curated classifier).
|
|
147
|
+
never-silently-pure promise, same as every candor engine's curated classifier). Each function ALSO
|
|
148
|
+
carries an `invisible` list — the κ-unknown packages it (transitively) reaches — so `inferred` is
|
|
149
|
+
never an unqualified claim PER FUNCTION: `inferred: []` with a non-empty `invisible` means "pure as
|
|
150
|
+
far as candor could see, but it could not see through these" (a LOWER bound), not "pure". An uncurated
|
|
148
151
|
dependency can opt out of that blind spot by declaring `"candorEffects": ["Net", …]` in its
|
|
149
152
|
`package.json` (the §5.1 effect manifest, read declared-not-verified) — its calls then classify to
|
|
150
153
|
the declared set instead of contributing nothing.
|
package/package.json
CHANGED
package/policy.mjs
CHANGED
|
@@ -103,7 +103,7 @@ export function literalAllowed(effect, reached, values) {
|
|
|
103
103
|
* transitive inferred; AS-EFF-008 allowlists over the transitive literal surfaces, the no-visible-
|
|
104
104
|
* literal case flagged as uncertifiable; AS-EFF-009 forbid by reachability). One line per violation.
|
|
105
105
|
*/
|
|
106
|
-
export function evaluatePolicy(pol, functions, callgraph) {
|
|
106
|
+
export function evaluatePolicy(pol, functions, callgraph, incomplete = new Map()) {
|
|
107
107
|
const out = [];
|
|
108
108
|
const surfaces = { Net: "hosts", Exec: "cmds", Fs: "paths", Db: "tables" };
|
|
109
109
|
for (const f of functions) {
|
|
@@ -116,7 +116,11 @@ export function evaluatePolicy(pol, functions, callgraph) {
|
|
|
116
116
|
if (r.scope && !scopeMatches(f.fn, r.scope)) continue;
|
|
117
117
|
if (!f.inferred.includes(r.effect)) continue;
|
|
118
118
|
const reached = f[surfaces[r.effect]] ?? [];
|
|
119
|
-
|
|
119
|
+
// An INCOMPLETE surface (a structurally-invisible reach — a host-establishing call with a runtime/
|
|
120
|
+
// invisible host) can't be certified even with visible hosts, else a benign literal masks the
|
|
121
|
+
// invisible forbidden endpoint (the masking evasion). Matches candor-java 0.5.29 / candor-rust.
|
|
122
|
+
const surfaceIncomplete = incomplete.get(f.fn)?.has(r.effect);
|
|
123
|
+
if (reached.length === 0 || surfaceIncomplete) {
|
|
120
124
|
out.push(`[AS-EFF-008] \`${f.fn}\` performs ${r.effect} with no visible literal — the surface cannot be certified: \`${r.raw}\``);
|
|
121
125
|
} else {
|
|
122
126
|
const bad = reached.filter((v) => !literalAllowed(r.effect, v, r.values));
|
package/scan.mjs
CHANGED
|
@@ -464,7 +464,7 @@ for (const sf of sources) {
|
|
|
464
464
|
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
|
|
465
465
|
fns.set(ctorQual, { local: `${node.name.text}.constructor`, direct: new Set(), edges: new Set(),
|
|
466
466
|
hosts: new Set(), tables: new Set(), cmds: new Set(), paths: new Set(),
|
|
467
|
-
why: new Set(), entry: false,
|
|
467
|
+
blind: new Set(), incomplete: new Set(), why: new Set(), entry: false,
|
|
468
468
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
|
|
469
469
|
}
|
|
470
470
|
nodeName.set(node, ctorQual);
|
|
@@ -475,7 +475,7 @@ for (const sf of sources) {
|
|
|
475
475
|
const qual = `${mod}.${n}`;
|
|
476
476
|
const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart());
|
|
477
477
|
fns.set(qual, { local: n, direct: new Set(), edges: new Set(), hosts: new Set(), tables: new Set(),
|
|
478
|
-
cmds: new Set(), paths: new Set(), why: new Set(), entry: false, isCjsExport,
|
|
478
|
+
cmds: new Set(), paths: new Set(), blind: new Set(), incomplete: new Set(), why: new Set(), entry: false, isCjsExport,
|
|
479
479
|
loc: `${path.relative(rootDir, sf.fileName)}:${line + 1}:${character + 1}` });
|
|
480
480
|
nodeName.set(node, qual);
|
|
481
481
|
if ((ts.isVariableDeclaration(node) || ts.isPropertyDeclaration(node)) && node.initializer)
|
|
@@ -980,6 +980,9 @@ function visitCalls(node) {
|
|
|
980
980
|
// needs no entry here. Inert ctors (Agent/Server/Socket/TLSSocket/Http2Server*/message shells)
|
|
981
981
|
// still synthesize "new" and stay pure.
|
|
982
982
|
const CONNECTING_CTORS = new Set(["ClientRequest"]);
|
|
983
|
+
// 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
|
+
const NET_ESTABLISHING = new Set(["request", "get", "connect", "createConnection", "fetch"]);
|
|
983
986
|
const ctorClassName = ts.isNewExpression(node)
|
|
984
987
|
? (ts.isConstructorDeclaration(decl) ? decl.parent?.name?.getText?.()
|
|
985
988
|
: (decl.name ? decl.name.getText() : ""))
|
|
@@ -1005,6 +1008,14 @@ function visitCalls(node) {
|
|
|
1005
1008
|
const lit = firstStringLiteral(node);
|
|
1006
1009
|
const h = lit && hostLiteral(lit);
|
|
1007
1010
|
if (h) rec.hosts.add(h);
|
|
1011
|
+
// MASKING fix: a host-ESTABLISHING Net call whose host is NOT a captured literal (runtime URL, or
|
|
1012
|
+
// 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 (request/get/connect/
|
|
1014
|
+
// createConnection/fetch + the connecting ctor) — NEVER use-calls (write/end/send), which would
|
|
1015
|
+
// false-positive on `socket.connect("h").write(data)` (the host is captured at connect). Under-
|
|
1016
|
+
// catches an unlisted establishing verb (safe direction); never over-flags a use-call.
|
|
1017
|
+
else if (NET_ESTABLISHING.has(member) || CONNECTING_CTORS.has(ctorClassName))
|
|
1018
|
+
rec.incomplete.add("Net");
|
|
1008
1019
|
}
|
|
1009
1020
|
if (eff === "Db") {
|
|
1010
1021
|
const lit = firstStringLiteral(node);
|
|
@@ -1079,6 +1090,11 @@ function visitCalls(node) {
|
|
|
1079
1090
|
} else if (!kappaKnows(pkg) && !depCoveredPkgs.has(pkg)
|
|
1080
1091
|
&& /node_modules\//.test(file) && !/node_modules\/(@types\/node|typescript)\//.test(file)) {
|
|
1081
1092
|
unlistedSeen.set(pkg, (unlistedSeen.get(pkg) ?? 0) + 1);
|
|
1093
|
+
// Per-fn HONESTY: this fn calls into a genuinely-blind package (κ-unknown, not dep-covered).
|
|
1094
|
+
// Recorded per fn, propagated transitively, emitted as `invisible` — so `inferred` is never an
|
|
1095
|
+
// unqualified completeness claim. This branch already IS the global-blind condition, so no
|
|
1096
|
+
// post-filter is needed (κ either knows a package or it doesn't).
|
|
1097
|
+
rec.blind.add(pkg);
|
|
1082
1098
|
}
|
|
1083
1099
|
}
|
|
1084
1100
|
}
|
|
@@ -1271,7 +1287,7 @@ while (changed) {
|
|
|
1271
1287
|
if (!mine.has(e)) { mine.add(e); changed = true; }
|
|
1272
1288
|
}
|
|
1273
1289
|
}
|
|
1274
|
-
for (const m of ["hosts", "tables", "cmds", "paths"]) {
|
|
1290
|
+
for (const m of ["hosts", "tables", "cmds", "paths", "blind", "incomplete"]) {
|
|
1275
1291
|
let moved = true;
|
|
1276
1292
|
while (moved) {
|
|
1277
1293
|
moved = false;
|
|
@@ -1286,7 +1302,9 @@ for (const m of ["hosts", "tables", "cmds", "paths"]) {
|
|
|
1286
1302
|
const functions = [];
|
|
1287
1303
|
for (const [name, rec] of fns) {
|
|
1288
1304
|
const inf = [...inferred.get(name)].sort();
|
|
1289
|
-
|
|
1305
|
+
// entry points stay visible even when pure; a BLIND fn stays too, so the honesty disclosure survives
|
|
1306
|
+
// on exactly the `inferred: []` fns that need it.
|
|
1307
|
+
if (inf.length === 0 && !rec.entry && rec.blind.size === 0) continue;
|
|
1290
1308
|
const entry = {
|
|
1291
1309
|
fn: name,
|
|
1292
1310
|
loc: rec.loc,
|
|
@@ -1303,6 +1321,9 @@ for (const [name, rec] of fns) {
|
|
|
1303
1321
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
1304
1322
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
1305
1323
|
if (rec.direct.has("Unknown") && rec.why.size) entry.unknownWhy = [...rec.why].sort();
|
|
1324
|
+
// HONESTY: the npm packages this fn transitively reaches that κ couldn't see through — effects through
|
|
1325
|
+
// them are NOT in `inferred`, so it is a LOWER BOUND when this is non-empty. Omitted when none.
|
|
1326
|
+
if (rec.blind.size) entry.invisible = [...rec.blind].sort();
|
|
1306
1327
|
if (rec.entry) entry.entryPoint = true;
|
|
1307
1328
|
if (rec.isCjsExport) entry.unitKind = "export"; // spec 0.5 draft, informative — per-unit, not by name
|
|
1308
1329
|
functions.push(entry);
|
|
@@ -1339,7 +1360,11 @@ if (policyPath) {
|
|
|
1339
1360
|
console.error(`candor-ts: policy ${policyPath} could not be read; gate NOT enforced`);
|
|
1340
1361
|
process.exit(2);
|
|
1341
1362
|
}
|
|
1342
|
-
|
|
1363
|
+
// The masking-incompleteness map (fn -> effects whose surface is incomplete), kept INTERNAL like the
|
|
1364
|
+
// java/rust engines (not a report field) — passed to the gate so an incomplete surface fails closed.
|
|
1365
|
+
const incompleteMap = new Map();
|
|
1366
|
+
for (const [name, rec] of fns) if (rec.incomplete.size) incompleteMap.set(name, rec.incomplete);
|
|
1367
|
+
const v = evaluatePolicy(parsePolicy(text), functions, cg, incompleteMap);
|
|
1343
1368
|
for (const line of v) console.log(line);
|
|
1344
1369
|
if (v.length) {
|
|
1345
1370
|
console.error(`candor-ts: ${v.length} policy violation(s)`);
|