@vola-trebla/croak 0.1.0 → 0.1.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/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 +0 -6
- package/dist/install/installed.js +3 -19
- package/dist/install/lockfile.js +0 -7
- package/dist/install/metadata.js +1 -31
- 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
|
@@ -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;
|
package/dist/signals/hidden.js
CHANGED
|
@@ -1,25 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Facts about code placed where a human reading the file will not see it:
|
|
3
|
-
*
|
|
4
|
-
* - offscreen — after a long run of spaces or tabs on the same line, far past
|
|
5
|
-
* the right edge of the editor (e.g. a payload after `module.exports = {};`)
|
|
6
|
-
* - vertical — after hundreds of blank lines, far below the visible code
|
|
7
|
-
* - bidi — Unicode bidirectional controls that make the displayed text
|
|
8
|
-
* differ from what runs (Trojan Source, CVE-2021-42574)
|
|
9
|
-
*
|
|
10
|
-
* No verdicts here; detectors weigh these facts with code signals. Character
|
|
11
|
-
* scanning only, no backtracking regexes.
|
|
12
|
-
*/
|
|
13
|
-
/** Whitespace run, in characters, that pushes code offscreen. */
|
|
14
1
|
export const MIN_GAP = 100;
|
|
15
|
-
/** Consecutive blank lines that push code below the fold. */
|
|
16
2
|
export const MIN_BLANK_LINES = 150;
|
|
17
|
-
// Any Unicode whitespace hides code, not only spaces and tabs (NBSP, U+3000, …).
|
|
18
3
|
const isSpace = (ch) => /\s/.test(ch);
|
|
19
4
|
const BLANK = /^\s*$/;
|
|
20
|
-
/** JS and editors break lines at \r\n, \n, a lone \r, U+2028, and U+2029. */
|
|
21
5
|
const LINE_BREAK = /\r\n|[\n\r\u2028\u2029]/;
|
|
22
|
-
/** U+202A–U+202E (embeddings/overrides) and U+2066–U+2069 (isolates). */
|
|
23
6
|
function isBidiControl(code) {
|
|
24
7
|
return ((code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069));
|
|
25
8
|
}
|
package/dist/signals/jsonc.js
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* JSON with comments and trailing commas, as VS Code reads `.vscode/*.json`.
|
|
3
|
-
* One linear pass turns JSONC into plain JSON (comments become spaces,
|
|
4
|
-
* trailing commas are dropped), then JSON.parse does the rest. Nesting depth
|
|
5
|
-
* is checked during the pass so hostile input cannot recurse deeply.
|
|
6
|
-
*/
|
|
7
1
|
export const MAX_JSONC_DEPTH = 64;
|
|
8
2
|
export class JsoncError extends Error {
|
|
9
3
|
constructor(message) {
|
|
@@ -11,7 +5,6 @@ export class JsoncError extends Error {
|
|
|
11
5
|
this.name = "JsoncError";
|
|
12
6
|
}
|
|
13
7
|
}
|
|
14
|
-
/** Index of the next character that is not whitespace, or text.length. */
|
|
15
8
|
function nextSignificant(text, from) {
|
|
16
9
|
let i = from;
|
|
17
10
|
while (i < text.length && /\s/.test(text[i] ?? ""))
|
|
@@ -56,8 +49,6 @@ function toJson(text) {
|
|
|
56
49
|
out += ch;
|
|
57
50
|
}
|
|
58
51
|
else if (ch === ",") {
|
|
59
|
-
// Drop a comma whose next significant token (comments already being
|
|
60
|
-
// skipped) closes the container.
|
|
61
52
|
out += ",";
|
|
62
53
|
const rest = toJsonPeek(text, i + 1);
|
|
63
54
|
if (rest === "}" || rest === "]")
|
|
@@ -69,7 +60,6 @@ function toJson(text) {
|
|
|
69
60
|
}
|
|
70
61
|
return out;
|
|
71
62
|
}
|
|
72
|
-
/** The next significant character after `from`, skipping whitespace and comments. */
|
|
73
63
|
function toJsonPeek(text, from) {
|
|
74
64
|
let i = nextSignificant(text, from);
|
|
75
65
|
while (i < text.length &&
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Facts that code was deliberately made unreadable — not merely minified.
|
|
3
|
-
* Minifiers shorten names and drop whitespace but keep strings and property
|
|
4
|
-
* names readable; obfuscators encode strings, rename to `_0x…`, rotate string
|
|
5
|
-
* arrays, and reach `eval` through computed names. No verdicts here.
|
|
6
|
-
* Patterns are linear (checked by `pnpm redos`).
|
|
7
|
-
*/
|
|
8
1
|
export const OBFUSCATION_PATTERNS = [
|
|
9
2
|
{
|
|
10
3
|
kind: "escapes",
|
|
@@ -30,9 +23,6 @@ export const OBFUSCATION_PATTERNS = [
|
|
|
30
23
|
{
|
|
31
24
|
kind: "dynamic-call",
|
|
32
25
|
label: "computed global call",
|
|
33
|
-
// globalThis[expr](…) with a non-literal key (one level of nested [] allowed),
|
|
34
|
-
// but not a well-known symbol: self[Symbol.asyncIterator]() is how
|
|
35
|
-
// iterators are called (the Anthropic SDK's streaming code)…
|
|
36
26
|
pattern: /\b(?:globalThis|window|global|self)\s*\[\s*(?!Symbol\s*\.)[^\]"'`\s][^\]]{0,100}\]{1,2}\s*\(/,
|
|
37
27
|
threshold: 1,
|
|
38
28
|
scope: "line",
|
|
@@ -40,7 +30,6 @@ export const OBFUSCATION_PATTERNS = [
|
|
|
40
30
|
{
|
|
41
31
|
kind: "dynamic-call",
|
|
42
32
|
label: "string-keyed eval",
|
|
43
|
-
// …or a literal key that names eval or Function.
|
|
44
33
|
pattern: /\b(?:globalThis|window|global|self)\s*\[\s*["'`](?:eval|Function)["'`]\s*\]/,
|
|
45
34
|
threshold: 1,
|
|
46
35
|
scope: "line",
|