@vola-trebla/croak 0.1.0 → 0.1.3
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/dist/cli.js +1 -27
- package/dist/crypto/der.js +0 -13
- package/dist/crypto/fields.js +0 -11
- package/dist/crypto/registry-signature.js +0 -12
- package/dist/crypto/rekor.js +0 -13
- package/dist/crypto/sigstore.js +0 -9
- package/dist/detectors/git-config.js +0 -4
- package/dist/detectors/hidden-code.js +0 -7
- package/dist/detectors/ide-autorun.js +0 -9
- package/dist/detectors/index.js +0 -1
- package/dist/detectors/lifecycle-script.js +2 -41
- package/dist/detectors/node-modules-present.js +0 -7
- package/dist/detectors/obfuscation.js +0 -26
- package/dist/detectors/unscanned.js +0 -8
- package/dist/hook/hook.js +2 -45
- package/dist/hook/install-commands.js +1 -36
- package/dist/install/content-cache.js +0 -8
- package/dist/install/content.js +0 -10
- package/dist/install/detectors.js +30 -29
- package/dist/install/installed.js +3 -19
- package/dist/install/lockfile.js +0 -7
- package/dist/install/metadata.js +5 -32
- package/dist/install/plan.js +0 -10
- package/dist/install/pnpm-builds.js +0 -13
- package/dist/install/pnpm-lockfile.js +0 -14
- package/dist/install/popular.js +0 -5
- package/dist/install/project.js +0 -20
- package/dist/install/provenance.js +0 -21
- package/dist/install/registry.js +0 -33
- package/dist/install/sanitize.js +0 -14
- package/dist/install/scan.js +0 -31
- package/dist/install/signature.js +0 -7
- package/dist/install/tar.js +1 -8
- package/dist/install/workspace.js +0 -1
- package/dist/install/yaml.js +0 -26
- package/dist/llm/client.js +0 -8
- package/dist/llm/cost.js +0 -2
- package/dist/llm/diff.js +0 -14
- package/dist/llm/plan-step.js +0 -5
- package/dist/llm/select.js +0 -6
- package/dist/llm/triage.js +0 -10
- package/dist/repo-report.js +0 -11
- package/dist/repo-snapshot.js +0 -17
- package/dist/scan.js +0 -5
- package/dist/signals/code.js +0 -29
- package/dist/signals/git-config.js +0 -7
- package/dist/signals/hidden.js +0 -17
- package/dist/signals/jsonc.js +0 -10
- package/dist/signals/obfuscation.js +0 -11
- package/dist/signals/shell.js +0 -40
- package/dist/signals/text.js +0 -1
- package/dist/ui.js +0 -1
- package/dist/verdict.js +0 -1
- package/package.json +1 -1
- package/scripts/croak-hook.sh +4 -2
- package/scripts/croak-install.sh +14 -6
package/dist/install/scan.js
CHANGED
|
@@ -13,15 +13,9 @@ import { provenanceBaseline, provenanceFindings, } from "./provenance.js";
|
|
|
13
13
|
import { fetchPackument, fetchProvenanceBundle, fetchTarball, IntegrityError, RegistryError, registryTarball, versionFacts, } from "./registry.js";
|
|
14
14
|
import { signatureFindings } from "./signature.js";
|
|
15
15
|
import { snapshotTarball, TarballError } from "./tar.js";
|
|
16
|
-
/** An npm package-lock.json or a pnpm-lock.yaml, told apart by their first lines. */
|
|
17
16
|
export function parseAnyLockfile(text) {
|
|
18
17
|
return isPnpmLockfile(text) ? parsePnpmLockfile(text) : parseLockfile(text);
|
|
19
18
|
}
|
|
20
|
-
/**
|
|
21
|
-
* Judges what an install would bring in, from the project's lockfile before
|
|
22
|
-
* and the lockfile npm planned (`--package-lock-only --ignore-scripts`).
|
|
23
|
-
* croak itself runs no package manager; it only reads the two files.
|
|
24
|
-
*/
|
|
25
19
|
export function planInstall(beforeText, afterText, detectors = installDetectors) {
|
|
26
20
|
const started = performance.now();
|
|
27
21
|
const parse = (text, which) => {
|
|
@@ -58,15 +52,9 @@ export function planInstall(beforeText, afterText, detectors = installDetectors)
|
|
|
58
52
|
}
|
|
59
53
|
}
|
|
60
54
|
const DEFAULT_MAX_DOWNLOAD_BYTES = 300 * 1024 * 1024;
|
|
61
|
-
/** The plan is too large to inspect within the download budget. */
|
|
62
55
|
class BudgetError extends Error {
|
|
63
56
|
}
|
|
64
57
|
const keyOf = (s) => `${s.name}@${s.version}`;
|
|
65
|
-
/**
|
|
66
|
-
* Provenance findings (D42, D43) for planned versions the registry lists
|
|
67
|
-
* with provenance: each one's bundle, and its previous version's, fetched
|
|
68
|
-
* and verified, then compared.
|
|
69
|
-
*/
|
|
70
58
|
async function provenanceOf(known, options, fetchOptions) {
|
|
71
59
|
const plans = known.flatMap(({ change, facts }) => {
|
|
72
60
|
if (facts === undefined || facts === "not-found" || !facts.hasProvenance)
|
|
@@ -81,8 +69,6 @@ async function provenanceOf(known, options, fetchOptions) {
|
|
|
81
69
|
const previous = before === undefined
|
|
82
70
|
? undefined
|
|
83
71
|
: subject(before.version, before.integrity);
|
|
84
|
-
// Both bundles are immutable for these tarballs, and so are both
|
|
85
|
-
// versions' install scripts: the findings can be remembered.
|
|
86
72
|
const label = `provenance ${keyOf(current)} after ${previous === undefined ? "none" : `${keyOf(previous)} ${previous.integrity}`}`;
|
|
87
73
|
const cached = options.cache?.get(current.integrity, label);
|
|
88
74
|
return [{ change, facts, before, current, previous, label, cached }];
|
|
@@ -116,13 +102,11 @@ async function provenanceOf(known, options, fetchOptions) {
|
|
|
116
102
|
provenance: then,
|
|
117
103
|
installCommands: before.installCommands,
|
|
118
104
|
}, facts.installCommands);
|
|
119
|
-
// A bundle the registry did not serve may be served next time.
|
|
120
105
|
if (now !== "missing" && then !== "missing")
|
|
121
106
|
options.cache?.set(current.integrity, plan.label, findings);
|
|
122
107
|
return findings;
|
|
123
108
|
});
|
|
124
109
|
}
|
|
125
|
-
/** Runs `work` over `items` with at most `limit` in flight. */
|
|
126
110
|
async function pool(items, limit, work) {
|
|
127
111
|
const results = new Array(items.length);
|
|
128
112
|
let next = 0;
|
|
@@ -135,11 +119,6 @@ async function pool(items, limit, work) {
|
|
|
135
119
|
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
|
|
136
120
|
return results;
|
|
137
121
|
}
|
|
138
|
-
/**
|
|
139
|
-
* planInstall plus npm registry signals (D26) for every new or changed version
|
|
140
|
-
* that comes from the public registry. Any registry failure makes the whole
|
|
141
|
-
* check incomplete: an agent must not proceed on a partial answer.
|
|
142
|
-
*/
|
|
143
122
|
export async function planInstallOnline(beforeText, afterText, options = {}) {
|
|
144
123
|
const started = performance.now();
|
|
145
124
|
const local = planInstall(beforeText, afterText);
|
|
@@ -147,7 +126,6 @@ export async function planInstallOnline(beforeText, afterText, options = {}) {
|
|
|
147
126
|
return local;
|
|
148
127
|
const before = beforeText === undefined ? undefined : parseAnyLockfile(beforeText);
|
|
149
128
|
const fromRegistry = diffLockfiles(before, parseAnyLockfile(afterText)).filter((c) => !c.entry.link && registryTarball(c.entry.resolved) !== undefined);
|
|
150
|
-
// npm skips platform packages for other systems, so they are not checked.
|
|
151
129
|
const platform = options.platform ?? {
|
|
152
130
|
os: process.platform,
|
|
153
131
|
cpu: process.arch,
|
|
@@ -193,7 +171,6 @@ export async function planInstallOnline(beforeText, afterText, options = {}) {
|
|
|
193
171
|
: signatureFindings(c, facts, options.registryKeys);
|
|
194
172
|
return [...metadataFindings(c, facts, now), ...signature, ...deps];
|
|
195
173
|
});
|
|
196
|
-
// Both only read the registry; neither waits for the other.
|
|
197
174
|
const [provenance, content] = await Promise.all([
|
|
198
175
|
provenanceOf(known, options, fetchOptions),
|
|
199
176
|
options.content === false ? [] : contentOf(changes, options),
|
|
@@ -205,7 +182,6 @@ export async function planInstallOnline(beforeText, afterText, options = {}) {
|
|
|
205
182
|
...provenance,
|
|
206
183
|
...content,
|
|
207
184
|
];
|
|
208
|
-
// Before the LLM step: a script pnpm will not run needs no second opinion.
|
|
209
185
|
const ruled = options.builds === undefined
|
|
210
186
|
? all
|
|
211
187
|
: applyBuildPolicy(all, options.builds);
|
|
@@ -247,7 +223,6 @@ export async function planInstallOnline(beforeText, afterText, options = {}) {
|
|
|
247
223
|
throw error;
|
|
248
224
|
}
|
|
249
225
|
}
|
|
250
|
-
/** Any listed version, to read package-level facts such as the creation date. */
|
|
251
226
|
function firstVersion(doc) {
|
|
252
227
|
const versions = typeof doc === "object" && doc !== null && "versions" in doc
|
|
253
228
|
? doc.versions
|
|
@@ -256,11 +231,6 @@ function firstVersion(doc) {
|
|
|
256
231
|
? (Object.keys(versions)[0] ?? "")
|
|
257
232
|
: "";
|
|
258
233
|
}
|
|
259
|
-
/**
|
|
260
|
-
* Downloads each registry tarball (verified against the lockfile's integrity),
|
|
261
|
-
* reads it in memory, and runs the content detectors. A tampered tarball is a
|
|
262
|
-
* block; a plan over the download budget is incomplete, never partially clean.
|
|
263
|
-
*/
|
|
264
234
|
async function contentOf(changes, options) {
|
|
265
235
|
const budget = options.maxDownloadBytes ?? DEFAULT_MAX_DOWNLOAD_BYTES;
|
|
266
236
|
let downloaded = 0;
|
|
@@ -293,7 +263,6 @@ async function contentOf(changes, options) {
|
|
|
293
263
|
if (downloaded > budget)
|
|
294
264
|
throw new BudgetError(`the plan downloads more than ${budget} bytes of tarballs`);
|
|
295
265
|
const findings = contentFindings(label, snapshotTarball(data));
|
|
296
|
-
// Only after the download verified against this hash and was read whole.
|
|
297
266
|
if (entry.integrity !== undefined)
|
|
298
267
|
options.cache?.set(entry.integrity, label, findings);
|
|
299
268
|
return findings;
|
|
@@ -3,12 +3,6 @@ const sha512Of = (integrity) => integrity
|
|
|
3
3
|
?.trim()
|
|
4
4
|
.split(/\s+/)
|
|
5
5
|
.find((hash) => hash.startsWith("sha512-"));
|
|
6
|
-
/**
|
|
7
|
-
* The registry's signature over this version (M7, D42). Broken blocks: a
|
|
8
|
-
* known key's signature that does not verify, or a lockfile hash that is not
|
|
9
|
-
* the one the registry signed. Missing or unverifiable warns: npm signs every
|
|
10
|
-
* version, but a key rotation croak missed must not block every install.
|
|
11
|
-
*/
|
|
12
6
|
export function signatureFindings(change, facts, keys = NPM_REGISTRY_KEYS) {
|
|
13
7
|
const { entry } = change;
|
|
14
8
|
const version = entry.version ?? "";
|
|
@@ -46,7 +40,6 @@ export function signatureFindings(change, facts, keys = NPM_REGISTRY_KEYS) {
|
|
|
46
40
|
const locked = sha512Of(entry.integrity);
|
|
47
41
|
const signed = sha512Of(facts.integrity);
|
|
48
42
|
if (locked === undefined)
|
|
49
|
-
// No hash at all is install/integrity's finding.
|
|
50
43
|
return entry.integrity === undefined
|
|
51
44
|
? []
|
|
52
45
|
: [
|
package/dist/install/tar.js
CHANGED
|
@@ -3,7 +3,6 @@ import { gunzipSync } from "node:zlib";
|
|
|
3
3
|
export const DEFAULT_TAR_LIMITS = {
|
|
4
4
|
maxUnpackedBytes: 256 * 1024 * 1024,
|
|
5
5
|
maxEntries: 20_000,
|
|
6
|
-
// Bundles of a few MB are common in packages; reading them is cheap in memory.
|
|
7
6
|
maxFileBytes: 8 * 1024 * 1024,
|
|
8
7
|
};
|
|
9
8
|
export class TarballError extends Error {
|
|
@@ -32,7 +31,6 @@ function verifyChecksum(block) {
|
|
|
32
31
|
if (sum !== expected)
|
|
33
32
|
throw new TarballError("header checksum mismatch: corrupted or crafted archive");
|
|
34
33
|
}
|
|
35
|
-
/** pax records: "<len> key=value\n" … */
|
|
36
34
|
function paxRecords(data) {
|
|
37
35
|
const records = new Map();
|
|
38
36
|
let offset = 0;
|
|
@@ -55,9 +53,7 @@ function paxRecords(data) {
|
|
|
55
53
|
}
|
|
56
54
|
return records;
|
|
57
55
|
}
|
|
58
|
-
/** POSIX ustar ("ustar\0" + "00"): only then does node-tar read the name prefix. */
|
|
59
56
|
const isUstar = (block) => block.toString("latin1", 257, 265) === "ustar\u000000";
|
|
60
|
-
/** Drop the archive's top-level directory (`package/` in npm tarballs), as npm does. */
|
|
61
57
|
function relative(path) {
|
|
62
58
|
const slash = path.indexOf("/");
|
|
63
59
|
const rest = slash === -1 ? "" : path.slice(slash + 1);
|
|
@@ -72,7 +68,6 @@ function relative(path) {
|
|
|
72
68
|
}
|
|
73
69
|
const FILE_TYPES = new Set(["0", "\0", "7"]);
|
|
74
70
|
const LINK_TYPES = new Set(["1", "2"]);
|
|
75
|
-
/** Devices, FIFOs and directories carry no code. */
|
|
76
71
|
const IGNORED_TYPES = new Set(["3", "4", "5", "6"]);
|
|
77
72
|
export function snapshotTarball(gzipped, limits = {}) {
|
|
78
73
|
const { maxUnpackedBytes, maxEntries, maxFileBytes } = {
|
|
@@ -98,8 +93,6 @@ export function snapshotTarball(gzipped, limits = {}) {
|
|
|
98
93
|
while (offset + BLOCK <= tar.length) {
|
|
99
94
|
const block = tar.subarray(offset, offset + BLOCK);
|
|
100
95
|
if (block.every((byte) => byte === 0)) {
|
|
101
|
-
// node-tar ends the archive only at two zero blocks in a row; a single
|
|
102
|
-
// one followed by more entries would hide them from croak alone.
|
|
103
96
|
const next = tar.subarray(offset + BLOCK, offset + 2 * BLOCK);
|
|
104
97
|
if (next.length === BLOCK && !next.every((byte) => byte === 0))
|
|
105
98
|
throw new TarballError("a zero block in the middle of the archive: entries after it would be hidden");
|
|
@@ -132,7 +125,7 @@ export function snapshotTarball(gzipped, limits = {}) {
|
|
|
132
125
|
continue;
|
|
133
126
|
}
|
|
134
127
|
if (type === "K")
|
|
135
|
-
continue;
|
|
128
|
+
continue;
|
|
136
129
|
if (type === "g") {
|
|
137
130
|
const records = paxRecords(data);
|
|
138
131
|
if (records.has("path") || records.has("size"))
|
package/dist/install/yaml.js
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A strict reader for the YAML that pnpm writes in pnpm-lock.yaml (M6, D38):
|
|
3
|
-
* block mappings by indentation, plain and quoted scalars, flow mappings
|
|
4
|
-
* `{a: b}` and flow sequences `[a, b]`, several documents split by `---`.
|
|
5
|
-
* The lockfile comes from an untrusted repo, so anything outside that subset
|
|
6
|
-
* (anchors, tags, block sequences, multi-line scalars) is a YamlError — the
|
|
7
|
-
* check is then incomplete, never guessed at. Linear scanning; size, depth
|
|
8
|
-
* and key counts are capped.
|
|
9
|
-
*/
|
|
10
1
|
export class YamlError extends Error {
|
|
11
2
|
constructor(message) {
|
|
12
3
|
super(message);
|
|
@@ -18,7 +9,6 @@ export const DEFAULT_YAML_LIMITS = {
|
|
|
18
9
|
maxDepth: 32,
|
|
19
10
|
maxKeys: 2_000_000,
|
|
20
11
|
};
|
|
21
|
-
/** A scalar: 'single', "double" (simple escapes only) or plain. */
|
|
22
12
|
function scalar(raw, where) {
|
|
23
13
|
const text = raw.trim();
|
|
24
14
|
if (text.startsWith("'")) {
|
|
@@ -41,7 +31,6 @@ function scalar(raw, where) {
|
|
|
41
31
|
throw new YamlError(`${where}: unsupported YAML (${text.slice(0, 1)})`);
|
|
42
32
|
return text;
|
|
43
33
|
}
|
|
44
|
-
/** Splits a flow collection's body at top-level commas, respecting quotes and nesting. */
|
|
45
34
|
function splitFlow(body, where) {
|
|
46
35
|
const parts = [];
|
|
47
36
|
let depth = 0;
|
|
@@ -73,7 +62,6 @@ function splitFlow(body, where) {
|
|
|
73
62
|
parts.push(last);
|
|
74
63
|
return parts.map((p) => p.trim()).filter((p) => p !== "");
|
|
75
64
|
}
|
|
76
|
-
/** The index of the `: ` (or a final `:`) that ends a key, outside quotes and brackets. */
|
|
77
65
|
function keyEnd(text) {
|
|
78
66
|
let quote;
|
|
79
67
|
let depth = 0;
|
|
@@ -121,7 +109,6 @@ function flowValue(raw, where, depth, limits) {
|
|
|
121
109
|
}
|
|
122
110
|
function parseDocument(lines, limits) {
|
|
123
111
|
const root = {};
|
|
124
|
-
// Open mappings by indentation: the deepest last.
|
|
125
112
|
const stack = [
|
|
126
113
|
{ indent: -1, map: root },
|
|
127
114
|
];
|
|
@@ -134,8 +121,6 @@ function parseDocument(lines, limits) {
|
|
|
134
121
|
throw new YamlError(`${where}: tab character`);
|
|
135
122
|
const indent = text.length - text.trimStart().length;
|
|
136
123
|
const body = text.trim();
|
|
137
|
-
// A block sequence of scalars under a key (pnpm writes
|
|
138
|
-
// transitivePeerDependencies this way); nothing nested.
|
|
139
124
|
if (body.startsWith("- ") || body === "-") {
|
|
140
125
|
if (pending !== undefined && indent >= pending.indent) {
|
|
141
126
|
sequence = { indent, items: [] };
|
|
@@ -151,8 +136,6 @@ function parseDocument(lines, limits) {
|
|
|
151
136
|
continue;
|
|
152
137
|
}
|
|
153
138
|
sequence = undefined;
|
|
154
|
-
// A flow collection on the line after its key (as Biome and Prettier
|
|
155
|
-
// write it) is that key's value; anywhere else it is not YAML pnpm writes.
|
|
156
139
|
if (body.startsWith("{") || body.startsWith("[")) {
|
|
157
140
|
if (pending === undefined || indent <= pending.indent)
|
|
158
141
|
throw new YamlError(`${where}: a flow collection without a key`);
|
|
@@ -162,7 +145,6 @@ function parseDocument(lines, limits) {
|
|
|
162
145
|
}
|
|
163
146
|
let opened = false;
|
|
164
147
|
if (pending !== undefined) {
|
|
165
|
-
// The previous key opened a nested mapping (or is empty if this line is not deeper).
|
|
166
148
|
if (indent > pending.indent) {
|
|
167
149
|
const child = {};
|
|
168
150
|
pending.parent[pending.key] = child;
|
|
@@ -203,10 +185,6 @@ function parseDocument(lines, limits) {
|
|
|
203
185
|
pending.parent[pending.key] = {};
|
|
204
186
|
return root;
|
|
205
187
|
}
|
|
206
|
-
/**
|
|
207
|
-
* A line without its comment: a `#` outside quotes that starts the line or
|
|
208
|
-
* follows whitespace (YAML's rule; `repo#commit` keeps its `#`).
|
|
209
|
-
*/
|
|
210
188
|
function stripComment(line) {
|
|
211
189
|
let quote;
|
|
212
190
|
for (let i = 0; i < line.length; i++) {
|
|
@@ -223,7 +201,6 @@ function stripComment(line) {
|
|
|
223
201
|
}
|
|
224
202
|
return line;
|
|
225
203
|
}
|
|
226
|
-
/** How many flow brackets a line leaves open, outside quotes. */
|
|
227
204
|
function openBrackets(line, start) {
|
|
228
205
|
let depth = start;
|
|
229
206
|
let quote;
|
|
@@ -241,14 +218,11 @@ function openBrackets(line, start) {
|
|
|
241
218
|
}
|
|
242
219
|
return depth;
|
|
243
220
|
}
|
|
244
|
-
/** Every document in the text (split at `---` lines), as plain objects. */
|
|
245
221
|
export function parseYamlDocuments(text, limits = {}) {
|
|
246
222
|
const all = { ...DEFAULT_YAML_LIMITS, ...limits };
|
|
247
223
|
if (text.length > all.maxBytes)
|
|
248
224
|
throw new YamlError(`larger than ${all.maxBytes} bytes`);
|
|
249
225
|
const docs = [[]];
|
|
250
|
-
// A flow collection a formatter spread over several lines (Biome, Prettier)
|
|
251
|
-
// is joined back into one logical line, as YAML reads it.
|
|
252
226
|
let open;
|
|
253
227
|
const lines = text.split("\n");
|
|
254
228
|
lines.forEach((raw, index) => {
|
package/dist/llm/client.js
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
1
|
import { DEFAULT_MODEL, triage } from "./triage.js";
|
|
2
|
-
/**
|
|
3
|
-
* The model for `croak plan --llm`, loaded on first use (M5 review, D36). The
|
|
4
|
-
* SDK is the one runtime dependency; importing it statically would load it,
|
|
5
|
-
* and its own dependencies, for every croak command — the hook's steps run
|
|
6
|
-
* on every agent command, outside the sandbox. This literal dynamic import is
|
|
7
|
-
* the only one the invariant tripwire allows.
|
|
8
|
-
*/
|
|
9
2
|
export function anthropicTriage(apiKey) {
|
|
10
3
|
let ready;
|
|
11
4
|
const load = async () => {
|
|
12
5
|
const { default: Anthropic } = await import("@anthropic-ai/sdk");
|
|
13
|
-
// Well inside the hook's timeout; one retry for an overloaded API.
|
|
14
6
|
const client = new Anthropic({ apiKey, timeout: 30_000, maxRetries: 1 });
|
|
15
7
|
return (input) => triage(input, client);
|
|
16
8
|
};
|
package/dist/llm/cost.js
CHANGED
|
@@ -7,7 +7,6 @@ export const PRICES = {
|
|
|
7
7
|
longOutput: 2.5,
|
|
8
8
|
},
|
|
9
9
|
};
|
|
10
|
-
/** Cost in USD, or undefined for a model without a known price. */
|
|
11
10
|
export function costOf(model, usage) {
|
|
12
11
|
const price = PRICES[model];
|
|
13
12
|
if (price === undefined)
|
|
@@ -18,7 +17,6 @@ export function costOf(model, usage) {
|
|
|
18
17
|
const long = price.longAbove !== undefined && prompt > price.longAbove;
|
|
19
18
|
const input = long ? (price.longInput ?? price.input) : price.input;
|
|
20
19
|
const output = long ? (price.longOutput ?? price.output) : price.output;
|
|
21
|
-
// Cache writes cost 1.25x and reads 0.1x the input price.
|
|
22
20
|
const promptCost = usage.input_tokens * input +
|
|
23
21
|
(usage.cache_creation_input_tokens ?? 0) * input * 1.25 +
|
|
24
22
|
(usage.cache_read_input_tokens ?? 0) * input * 0.1;
|
package/dist/llm/diff.js
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import { posix } from "node:path";
|
|
2
2
|
import { analyzeShell } from "../signals/shell.js";
|
|
3
3
|
import { stripBom } from "../signals/text.js";
|
|
4
|
-
/**
|
|
5
|
-
* What the model sees (M5, D36): the added and changed lines between a
|
|
6
|
-
* package's previous version and this one, most telling files first —
|
|
7
|
-
* package.json, then the files its install scripts run, then other code —
|
|
8
|
-
* within a character budget. Attacks add code, so removed lines are only
|
|
9
|
-
* counted. A new package (no previous version) is shown as all added.
|
|
10
|
-
*/
|
|
11
|
-
/** Room for the most telling files; triage cuts anything longer. */
|
|
12
4
|
export const DIFF_BUDGET = 50_000;
|
|
13
|
-
/** Minified bundles are one huge line; each shown line is cut to this. */
|
|
14
5
|
const MAX_LINE = 400;
|
|
15
6
|
const CODE = /\.(?:[cm]?[jt]sx?|json|sh|bash|py|gyp)$/;
|
|
16
7
|
const SKIPPED = /\.(?:d\.[cm]?ts|map)$|(?:^|\/)(?:package-lock|npm-shrinkwrap)\.json$/;
|
|
@@ -18,7 +9,6 @@ const INSTALL_SCRIPTS = ["preinstall", "install", "postinstall"];
|
|
|
18
9
|
function isRecord(value) {
|
|
19
10
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
11
|
}
|
|
21
|
-
/** Files the install scripts run, from the new version's package.json. */
|
|
22
12
|
function installFiles(after) {
|
|
23
13
|
const files = new Set();
|
|
24
14
|
let manifest;
|
|
@@ -40,7 +30,6 @@ function installFiles(after) {
|
|
|
40
30
|
const cut = (line) => line.length > MAX_LINE
|
|
41
31
|
? `${line.slice(0, MAX_LINE)}… (${line.length} characters)`
|
|
42
32
|
: line;
|
|
43
|
-
/** Lines of `after` that `before` does not have, with their line numbers. */
|
|
44
33
|
function addedLines(before, after) {
|
|
45
34
|
const old = new Map();
|
|
46
35
|
for (const line of (before ?? "").split("\n"))
|
|
@@ -76,8 +65,6 @@ export function versionDiff(before, after, budget = DIFF_BUDGET) {
|
|
|
76
65
|
continue;
|
|
77
66
|
}
|
|
78
67
|
const text = after.read(path) ?? "";
|
|
79
|
-
// A file the install scripts run is shown whole, changed or not: a payload
|
|
80
|
-
// can sit unchanged until a new version starts running it (M5 review).
|
|
81
68
|
const runsAtInstall = install.has(path);
|
|
82
69
|
const old = runsAtInstall ? undefined : before?.read(path);
|
|
83
70
|
if (!runsAtInstall && old === text)
|
|
@@ -92,7 +79,6 @@ export function versionDiff(before, after, budget = DIFF_BUDGET) {
|
|
|
92
79
|
: `=== ${path} (changed: ${lines.length} line(s) added, ${removed} removed)`;
|
|
93
80
|
const section = [head, ...lines].join("\n");
|
|
94
81
|
if (used + section.length > budget) {
|
|
95
|
-
// A file that does not fit still gets its header, so the model knows it exists.
|
|
96
82
|
notes.push(`${head.slice(4)}: not shown, over the size budget`);
|
|
97
83
|
continue;
|
|
98
84
|
}
|
package/dist/llm/plan-step.js
CHANGED
|
@@ -32,7 +32,6 @@ export async function llmStep(findings, changes, docs, llm, fetchOptions) {
|
|
|
32
32
|
const left = maxBytes - downloaded;
|
|
33
33
|
if (left <= 0)
|
|
34
34
|
throw new RegistryError(`over the LLM step's ${maxBytes}-byte download budget`);
|
|
35
|
-
// fetchTarball stops reading at the cap, so the budget holds mid-download too.
|
|
36
35
|
const data = await fetchTarball(url, integrity, {
|
|
37
36
|
...fetchOptions,
|
|
38
37
|
maxBytes: Math.min(left, 100 * 1024 * 1024),
|
|
@@ -40,8 +39,6 @@ export async function llmStep(findings, changes, docs, llm, fetchOptions) {
|
|
|
40
39
|
downloaded += data.length;
|
|
41
40
|
return data;
|
|
42
41
|
};
|
|
43
|
-
// Every copy with a warning: D35 keeps same name@version from different
|
|
44
|
-
// sources apart, and the warned one may not be the first (M5 review).
|
|
45
42
|
for (const change of changes.filter((c) => warned.has(`${c.entry.name}@${c.entry.version ?? "?"}`))) {
|
|
46
43
|
const { entry } = change;
|
|
47
44
|
const label = `${entry.name}@${entry.version ?? "?"}`;
|
|
@@ -57,8 +54,6 @@ export async function llmStep(findings, changes, docs, llm, fetchOptions) {
|
|
|
57
54
|
asked += 1;
|
|
58
55
|
try {
|
|
59
56
|
const after = snapshotTarball(await download(entry.resolved ?? "", entry.integrity));
|
|
60
|
-
// The baseline is what the project had: a new dependency is shown whole,
|
|
61
|
-
// not as a delta from the registry's previous release (M5 review).
|
|
62
57
|
const previous = change.kind === "changed" ? change.previousVersion : undefined;
|
|
63
58
|
const prevTarball = previous === undefined ? undefined : tarballOf(doc, previous);
|
|
64
59
|
const before = prevTarball === undefined
|
package/dist/llm/select.js
CHANGED
|
@@ -1,12 +1,6 @@
|
|
|
1
1
|
import { verdictRank } from "../verdict.js";
|
|
2
|
-
/** name@version at the start of a finding's file: "x@1.0.0/setup.js" → "x@1.0.0". */
|
|
3
2
|
const LABEL = /^((?:@[^/@]+\/)?[^/@][^/@]*@[^/]+)/;
|
|
4
3
|
export const labelOf = (file) => LABEL.exec(file)?.[1];
|
|
5
|
-
/**
|
|
6
|
-
* Packages the rules warned about and did not block: the only ones the model
|
|
7
|
-
* is asked about (D36). A block needs no second opinion; an allow is not
|
|
8
|
-
* escalated, to keep the cost to the packages a human would look at anyway.
|
|
9
|
-
*/
|
|
10
4
|
export function warnedPackages(findings) {
|
|
11
5
|
const worst = new Map();
|
|
12
6
|
for (const f of findings) {
|
package/dist/llm/triage.js
CHANGED
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { costOf } from "./cost.js";
|
|
3
|
-
/**
|
|
4
|
-
* One cheap, focused LLM call per package (M5, D36): Claude Haiku 5.5 reads
|
|
5
|
-
* what changed between a package's previous version and this one, and says
|
|
6
|
-
* whether the change looks malicious. Its answer can only escalate (see
|
|
7
|
-
* escalate.ts). Package contents are untrusted input: they arrive inside a
|
|
8
|
-
* per-call random boundary, the instructions say so, and the answer is held
|
|
9
|
-
* to a JSON schema. Any failure is an LlmError, and croak falls back to its
|
|
10
|
-
* deterministic verdict (invariant 3).
|
|
11
|
-
*/
|
|
12
3
|
export const DEFAULT_MODEL = "claude-haiku-5-5";
|
|
13
|
-
/** Diffs above this many characters are cut, and the model is told so. */
|
|
14
4
|
export const MAX_DIFF_CHARS = 60_000;
|
|
15
5
|
export class LlmError extends Error {
|
|
16
6
|
constructor(message) {
|
package/dist/repo-report.js
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { INCOMPLETE_ICON, SEVERITY_ICON, VERDICT_ICON } from "./ui.js";
|
|
2
|
-
/** Bump when the JSON shape changes; agents and hooks depend on it. */
|
|
3
2
|
export const REPORT_SCHEMA = 1;
|
|
4
|
-
/**
|
|
5
|
-
* What each verdict means, said out loud: neither green nor yellow is a
|
|
6
|
-
* promise that the repo is safe to run.
|
|
7
|
-
*/
|
|
8
3
|
const MEANING = {
|
|
9
4
|
allow: "Nothing suspicious found. Not a guarantee: croak only knows what it checks.",
|
|
10
5
|
warn: "Needs your attention: croak found behavior worth reviewing before you install or run anything. This is not a safe verdict.",
|
|
@@ -19,11 +14,6 @@ const MESSAGE_INDENT = " ".repeat(11);
|
|
|
19
14
|
function sortFindings(findings) {
|
|
20
15
|
return [...findings].sort((a, b) => ORDER[a.severity] - ORDER[b.severity] || a.file.localeCompare(b.file));
|
|
21
16
|
}
|
|
22
|
-
/**
|
|
23
|
-
* Text from the scanned repo (commands, file names, hidden code) is hostile.
|
|
24
|
-
* Escape C0/C1 control characters and bidi controls so it cannot move the
|
|
25
|
-
* cursor, erase the 🔴 lines, or reorder the report itself (Trojan Source).
|
|
26
|
-
*/
|
|
27
17
|
function isUnsafe(code) {
|
|
28
18
|
return (code <= 0x1f ||
|
|
29
19
|
(code >= 0x7f && code <= 0x9f) ||
|
|
@@ -71,7 +61,6 @@ export function renderScan(outcome, options) {
|
|
|
71
61
|
footer: `${outcome.filesScanned} files scanned · ${Math.round(outcome.durationMs)} ms`,
|
|
72
62
|
}, options);
|
|
73
63
|
}
|
|
74
|
-
/** The shared verdict layout for `croak repo` and `croak plan`. */
|
|
75
64
|
export function renderVerdict(report, options) {
|
|
76
65
|
const { emoji } = options;
|
|
77
66
|
const status = emoji
|
package/dist/repo-snapshot.js
CHANGED
|
@@ -5,33 +5,21 @@ export const DEFAULT_LIMITS = {
|
|
|
5
5
|
maxEntries: 100_000,
|
|
6
6
|
maxDepth: 64,
|
|
7
7
|
};
|
|
8
|
-
/** The repo exceeds a safety limit. Callers fail closed: an incomplete scan is not a clean scan. */
|
|
9
8
|
export class SnapshotLimitError extends Error {
|
|
10
9
|
constructor(message) {
|
|
11
10
|
super(message);
|
|
12
11
|
this.name = "SnapshotLimitError";
|
|
13
12
|
}
|
|
14
13
|
}
|
|
15
|
-
/** A file changed between listing and reading. Callers fail closed. */
|
|
16
14
|
export class SnapshotIntegrityError extends Error {
|
|
17
15
|
constructor(message) {
|
|
18
16
|
super(message);
|
|
19
17
|
this.name = "SnapshotIntegrityError";
|
|
20
18
|
}
|
|
21
19
|
}
|
|
22
|
-
/** What git itself executes from inside a root .git directory. */
|
|
23
20
|
const GIT_EXECUTED_FILES = ["config"];
|
|
24
21
|
const GIT_HOOKS_DIR = "hooks";
|
|
25
|
-
/**
|
|
26
|
-
* Lists a repository without following symlinks. Symlinks and oversized files
|
|
27
|
-
* are recorded (detectors may flag them) but never read. node_modules and
|
|
28
|
-
* nested .git directories are walked on purpose: both are hiding places. The
|
|
29
|
-
* root .git contributes only what git executes (config, hooks), not its
|
|
30
|
-
* object store.
|
|
31
|
-
*/
|
|
32
22
|
export function snapshotRepo(root, limits = {}) {
|
|
33
|
-
// O_NOFOLLOW is the read-time symlink guard; without it (Windows) we refuse
|
|
34
|
-
// rather than silently lose the protection.
|
|
35
23
|
if (typeof constants.O_NOFOLLOW !== "number") {
|
|
36
24
|
throw new Error("croak requires a POSIX platform (O_NOFOLLOW unavailable)");
|
|
37
25
|
}
|
|
@@ -58,7 +46,6 @@ export function snapshotRepo(root, limits = {}) {
|
|
|
58
46
|
identities.set(path, { dev: stats.dev, ino: stats.ino });
|
|
59
47
|
}
|
|
60
48
|
};
|
|
61
|
-
/** Streams one directory's entries instead of loading them all at once. */
|
|
62
49
|
const eachEntry = (dir, visit) => {
|
|
63
50
|
const handle = opendirSync(dir);
|
|
64
51
|
try {
|
|
@@ -127,8 +114,6 @@ export function snapshotRepo(root, limits = {}) {
|
|
|
127
114
|
const expected = identities.get(path);
|
|
128
115
|
if (expected === undefined)
|
|
129
116
|
return undefined;
|
|
130
|
-
// O_NOFOLLOW refuses a final-component symlink; the inode check below
|
|
131
|
-
// catches any other swap, including an intermediate directory.
|
|
132
117
|
const fd = openSync(join(root, path), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
133
118
|
try {
|
|
134
119
|
const stats = fstatSync(fd, { bigint: true });
|
|
@@ -137,8 +122,6 @@ export function snapshotRepo(root, limits = {}) {
|
|
|
137
122
|
stats.ino !== expected.ino) {
|
|
138
123
|
throw new SnapshotIntegrityError(`${path} changed after it was listed`);
|
|
139
124
|
}
|
|
140
|
-
// Read at most one byte past the cap, so growth after listing is detected
|
|
141
|
-
// instead of silently read.
|
|
142
125
|
const buffer = Buffer.alloc(MAX_FILE_BYTES + 1);
|
|
143
126
|
let length = 0;
|
|
144
127
|
for (let n = readSync(fd, buffer, 0, buffer.length, 0); n > 0 && length < buffer.length; n = readSync(fd, buffer, length, buffer.length - length, length)) {
|
package/dist/scan.js
CHANGED
|
@@ -3,17 +3,12 @@ import { performance } from "node:perf_hooks";
|
|
|
3
3
|
import { repoDetectors, scanRepo, } from "./detectors/index.js";
|
|
4
4
|
import { SnapshotIntegrityError, SnapshotLimitError, snapshotRepo, } from "./repo-snapshot.js";
|
|
5
5
|
import { aggregate } from "./verdict.js";
|
|
6
|
-
/** The scan target is missing or not a directory: a usage error, not a verdict. */
|
|
7
6
|
export class ScanTargetError extends Error {
|
|
8
7
|
constructor(message) {
|
|
9
8
|
super(message);
|
|
10
9
|
this.name = "ScanTargetError";
|
|
11
10
|
}
|
|
12
11
|
}
|
|
13
|
-
/**
|
|
14
|
-
* Scans a repository directory. The root itself is chosen by the user and may
|
|
15
|
-
* itself be a symbolic link, like /tmp on macOS; nothing inside it is followed.
|
|
16
|
-
*/
|
|
17
12
|
export function scanRepoAt(root, options = {}) {
|
|
18
13
|
if (statSync(root, { throwIfNoEntry: false })?.isDirectory() !== true) {
|
|
19
14
|
throw new ScanTargetError(`${root} is not a directory`);
|
package/dist/signals/code.js
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
// croak-invariants: pattern-data
|
|
2
|
-
// Detector patterns and test inputs below are data, not code (D16).
|
|
3
|
-
/**
|
|
4
|
-
* Signals in JavaScript/TypeScript source that, in combination, indicate an
|
|
5
|
-
* attack. A single signal is context, not evidence: install scripts fetch
|
|
6
|
-
* binaries, build tools spawn processes. Patterns are deliberately narrow and
|
|
7
|
-
* linear (checked by `pnpm redos`): they run on attacker-controlled text.
|
|
8
|
-
*/
|
|
9
1
|
const LOAD = String.raw `(?:require\s*\(\s*|from\s+|import\s*\(\s*)["']`;
|
|
10
2
|
const load = (modules) => new RegExp(`${LOAD}(?:node:)?(?:${modules})["']`);
|
|
11
3
|
export const CODE_PATTERNS = [
|
|
@@ -31,9 +23,7 @@ export const CODE_PATTERNS = [
|
|
|
31
23
|
label: "process library",
|
|
32
24
|
pattern: load("execa|shelljs|cross-spawn"),
|
|
33
25
|
},
|
|
34
|
-
// Not `$eval(` (Puppeteer and Playwright's page.$eval API).
|
|
35
26
|
{ signal: "eval", label: "eval", pattern: /(?<![\w$])eval\s*\)?\s*\(/ },
|
|
36
|
-
// Not `Function("return this")`, the old idiom for the global object.
|
|
37
27
|
{
|
|
38
28
|
signal: "eval",
|
|
39
29
|
label: "Function constructor",
|
|
@@ -61,14 +51,9 @@ export const CODE_PATTERNS = [
|
|
|
61
51
|
label: "private key",
|
|
62
52
|
pattern: /\bid_(?:rsa|ed25519|ecdsa|dsa)\b/,
|
|
63
53
|
},
|
|
64
|
-
// The whole environment, not process.env.PORT, a destructured
|
|
65
|
-
// `{ PORT } = process.env`, or `{ env: process.env }` handed to a child.
|
|
66
54
|
{
|
|
67
55
|
signal: "secrets",
|
|
68
56
|
label: "whole process.env",
|
|
69
|
-
// passed whole to a call as its first or last argument (JSON.stringify,
|
|
70
|
-
// Object.entries, post(url, process.env)), spread into JSON.stringify, or
|
|
71
|
-
// walked with for…in. A spread for a child's env stays quiet.
|
|
72
57
|
pattern: /[(,]\s*process\s*\.\s*env\s*\)|\(\s*process\s*\.\s*env\s*,|\bstringify\s*\(\s*\{\s*\.\.\.\s*process\s*\.\s*env\b|\bin\s+process\s*\.\s*env\b/,
|
|
73
58
|
},
|
|
74
59
|
{ signal: "secrets", label: "home directory", pattern: /\bhomedir\s*\(/ },
|
|
@@ -82,14 +67,12 @@ export const CODE_PATTERNS = [
|
|
|
82
67
|
label: "browser credentials",
|
|
83
68
|
pattern: /\b(?:Login Data|Local State)\b|\bKeychains?\b/,
|
|
84
69
|
},
|
|
85
|
-
// MetaMask's extension id: wallet stealers look for its storage directory.
|
|
86
70
|
{
|
|
87
71
|
signal: "secrets",
|
|
88
72
|
label: "wallet",
|
|
89
73
|
pattern: /\bnkbihfbeogaeaoehlefnkodbefgpgknn\b/,
|
|
90
74
|
},
|
|
91
75
|
];
|
|
92
|
-
/** Every pattern match, line by line. */
|
|
93
76
|
export function codeSignals(text) {
|
|
94
77
|
const hits = [];
|
|
95
78
|
text.split("\n").forEach((lineText, index) => {
|
|
@@ -100,10 +83,6 @@ export function codeSignals(text) {
|
|
|
100
83
|
});
|
|
101
84
|
return hits;
|
|
102
85
|
}
|
|
103
|
-
/**
|
|
104
|
-
* Network plus anything that reaches into the machine or hides code, or
|
|
105
|
-
* eval of decoded data. Network alone is a warning, not an attack.
|
|
106
|
-
*/
|
|
107
86
|
export function isSuspicious(signals) {
|
|
108
87
|
const reach = signals.has("secrets") ||
|
|
109
88
|
signals.has("process") ||
|
|
@@ -112,19 +91,11 @@ export function isSuspicious(signals) {
|
|
|
112
91
|
return ((signals.has("network") && reach) ||
|
|
113
92
|
(signals.has("eval") && signals.has("decode")));
|
|
114
93
|
}
|
|
115
|
-
/**
|
|
116
|
-
* For dependency contents: native-binary installers (esbuild, swc, sharp)
|
|
117
|
-
* legitimately download a binary, unpack it (String.fromCharCode on a tar
|
|
118
|
-
* header), and spawn it at install time, so network plus process or decoding
|
|
119
|
-
* alone only warns there. Network plus secrets or eval still blocks, as does
|
|
120
|
-
* eval of decoded data.
|
|
121
|
-
*/
|
|
122
94
|
export function isSuspiciousInDependency(signals) {
|
|
123
95
|
const reach = signals.has("secrets") || signals.has("eval");
|
|
124
96
|
return ((signals.has("network") && reach) ||
|
|
125
97
|
(signals.has("eval") && signals.has("decode")));
|
|
126
98
|
}
|
|
127
|
-
/** "network (x.js:1), secrets (x.js:3)" — each signal once, at its first hit. */
|
|
128
99
|
export function describeSignals(hits, file) {
|
|
129
100
|
const first = new Map();
|
|
130
101
|
for (const hit of hits)
|
|
@@ -1,12 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A minimal git-config reader for .git/config and .gitmodules: sections,
|
|
3
|
-
* `[name "subsection"]` and legacy `[name.sub]` headers, quoted values,
|
|
4
|
-
* comments. Enough to see which settings make git run a command. Linear,
|
|
5
|
-
* line by line; continuation lines are not supported.
|
|
6
|
-
*/
|
|
7
1
|
const HEADER = /^\[\s*([A-Za-z0-9.-]+)(?:\s+"((?:[^"\\]|\\.)*)")?\s*\]\s*(.*)$/;
|
|
8
2
|
const KEY = /^([A-Za-z][A-Za-z0-9-]*)\s*(?:=\s*(.*))?$/;
|
|
9
|
-
/** Removes a trailing comment outside quotes, then quotes and escapes. */
|
|
10
3
|
function parseValue(raw) {
|
|
11
4
|
let out = "";
|
|
12
5
|
let quoted = false;
|