@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
package/dist/cli.js
CHANGED
|
@@ -18,14 +18,8 @@ import { anthropicTriage, DEFAULT_MODEL } from "./llm/client.js";
|
|
|
18
18
|
import { renderScan, scanToJson } from "./repo-report.js";
|
|
19
19
|
import { ScanTargetError, scanRepoAt } from "./scan.js";
|
|
20
20
|
import { aggregate } from "./verdict.js";
|
|
21
|
-
export const VERSION = "0.1.
|
|
22
|
-
/**
|
|
23
|
-
* 0 allow or warn (warnings are printed, the process succeeds),
|
|
24
|
-
* 1 block, 2 usage error, 3 the scan could not complete — agents treat 3 as
|
|
25
|
-
* a block (fail-closed).
|
|
26
|
-
*/
|
|
21
|
+
export const VERSION = "0.1.1";
|
|
27
22
|
export const EXIT = { ok: 0, block: 1, usage: 2, incomplete: 3 };
|
|
28
|
-
/** Conventional exit code for invalid command-line usage. */
|
|
29
23
|
export const EXIT_USAGE = EXIT.usage;
|
|
30
24
|
const DEFAULT_DEPS = {
|
|
31
25
|
scan: (root) => scanRepoAt(root),
|
|
@@ -110,7 +104,6 @@ async function runPlan(values, rest, deps) {
|
|
|
110
104
|
if (values.installed !== undefined && values.before !== undefined)
|
|
111
105
|
return usageError("plan takes --before or --installed, not both");
|
|
112
106
|
if (values.installed !== undefined) {
|
|
113
|
-
// What is really in node_modules, verified folder by folder (D34).
|
|
114
107
|
const tryRead = (path) => {
|
|
115
108
|
try {
|
|
116
109
|
return read(path);
|
|
@@ -119,7 +112,6 @@ async function runPlan(values, rest, deps) {
|
|
|
119
112
|
return undefined;
|
|
120
113
|
}
|
|
121
114
|
};
|
|
122
|
-
// The installed tree of the manager that wrote the plan (D38).
|
|
123
115
|
before = isPnpmLockfile(after)
|
|
124
116
|
? installedPnpmLockfile(values.installed, tryRead, (dir) => {
|
|
125
117
|
try {
|
|
@@ -137,7 +129,6 @@ async function runPlan(values, rest, deps) {
|
|
|
137
129
|
return text;
|
|
138
130
|
before = text;
|
|
139
131
|
}
|
|
140
|
-
// pnpm's own rule for whose install scripts run, from the project's settings (D38).
|
|
141
132
|
const buildsDir = values["pnpm-builds"];
|
|
142
133
|
const builds = buildsDir === undefined || values["pnpm-version"] === undefined
|
|
143
134
|
? undefined
|
|
@@ -159,7 +150,6 @@ async function runPlan(values, rest, deps) {
|
|
|
159
150
|
}
|
|
160
151
|
})(),
|
|
161
152
|
}, values["pnpm-version"]);
|
|
162
|
-
// --offline: lockfile signals only, no registry requests (tests, air-gapped CI).
|
|
163
153
|
const outcome = values.offline === true
|
|
164
154
|
? planInstall(before, after)
|
|
165
155
|
: await planInstallOnline(before, after, {
|
|
@@ -173,7 +163,6 @@ async function runPlan(values, rest, deps) {
|
|
|
173
163
|
? { llm: deps.llm }
|
|
174
164
|
: {}),
|
|
175
165
|
});
|
|
176
|
-
// --llm without a key: the rules decide, and the report says the model did not run.
|
|
177
166
|
const llmMissing = values.llm === true && deps.llm === undefined && values.offline !== true
|
|
178
167
|
? [
|
|
179
168
|
{
|
|
@@ -209,24 +198,17 @@ async function runPlan(values, rest, deps) {
|
|
|
209
198
|
}
|
|
210
199
|
},
|
|
211
200
|
};
|
|
212
|
-
// A plain npm install or npm ci also runs the project's own install scripts
|
|
213
|
-
// (D35); pnpm runs the project's pnpmfile and config dependencies on every
|
|
214
|
-
// install (D39).
|
|
215
201
|
const project = values.project;
|
|
216
202
|
const extra = [
|
|
217
203
|
...llmMissing,
|
|
218
|
-
// The pnpmfile check needs only the project; the version is for the build rule.
|
|
219
204
|
...(buildsDir === undefined ? [] : pnpmfileFindings(buildsDir, projectFs)),
|
|
220
205
|
...(project === undefined || outcome.status !== "complete"
|
|
221
206
|
? []
|
|
222
207
|
: projectScriptFindings(project, projectFs, {
|
|
223
|
-
// The plan's lockfile parsed, so its members can be read (M6 review).
|
|
224
208
|
members: workspaceMembers(after),
|
|
225
209
|
pnpm: buildsDir !== undefined,
|
|
226
210
|
})),
|
|
227
211
|
];
|
|
228
|
-
// The folder the install ran in must be a member of the workspace the plan
|
|
229
|
-
// was made at; otherwise the package manager installs elsewhere (M6 review).
|
|
230
212
|
const member = values.member;
|
|
231
213
|
const notMember = member !== undefined &&
|
|
232
214
|
outcome.status === "complete" &&
|
|
@@ -248,9 +230,7 @@ async function runPlan(values, rest, deps) {
|
|
|
248
230
|
: renderPlan(judged, { emoji: deps.emoji && values.plain !== true });
|
|
249
231
|
return { code: exitCode(judged), stdout, stderr: "" };
|
|
250
232
|
}
|
|
251
|
-
/** Pure entry point: all I/O goes through deps. */
|
|
252
233
|
export async function run(argv, deps = DEFAULT_DEPS) {
|
|
253
|
-
// What the planning wrapper may hand to pnpm from an untrusted project (D38).
|
|
254
234
|
if (argv[0] === "sanitize") {
|
|
255
235
|
const [, kind, file] = argv;
|
|
256
236
|
if (file === undefined ||
|
|
@@ -273,7 +253,6 @@ export async function run(argv, deps = DEFAULT_DEPS) {
|
|
|
273
253
|
throw error;
|
|
274
254
|
}
|
|
275
255
|
}
|
|
276
|
-
// The workspace root croak-install plans at (D38): the same reading as the hook's.
|
|
277
256
|
if (argv[0] === "workspace-root") {
|
|
278
257
|
const [, manager, dir] = argv;
|
|
279
258
|
if (dir === undefined || (manager !== "npm" && manager !== "pnpm"))
|
|
@@ -288,7 +267,6 @@ export async function run(argv, deps = DEFAULT_DEPS) {
|
|
|
288
267
|
stderr: "",
|
|
289
268
|
};
|
|
290
269
|
}
|
|
291
|
-
// Suggestions for package names npm does not have (croak-install on E404).
|
|
292
270
|
if (argv[0] === "suggest") {
|
|
293
271
|
const lines = argv.slice(1).flatMap((spec) => {
|
|
294
272
|
const names = suggestNames(decodeURIComponent(spec));
|
|
@@ -300,7 +278,6 @@ export async function run(argv, deps = DEFAULT_DEPS) {
|
|
|
300
278
|
});
|
|
301
279
|
return { code: EXIT.ok, stdout: lines.join(""), stderr: "" };
|
|
302
280
|
}
|
|
303
|
-
// The agent hook's two steps (scripts/croak-hook.sh); they read files only.
|
|
304
281
|
if (argv[0] === "hook")
|
|
305
282
|
return runHook(argv.slice(1), {
|
|
306
283
|
readFile: deps.readFile ?? ((path) => readFileSync(path, "utf8")),
|
|
@@ -313,7 +290,6 @@ export async function run(argv, deps = DEFAULT_DEPS) {
|
|
|
313
290
|
parsed = parse(argv);
|
|
314
291
|
}
|
|
315
292
|
catch (error) {
|
|
316
|
-
// Usage mistakes are explained, not crashed on; anything else is a bug.
|
|
317
293
|
if (!isParseArgsError(error))
|
|
318
294
|
throw error;
|
|
319
295
|
return usageError(error.message);
|
|
@@ -345,9 +321,7 @@ export async function run(argv, deps = DEFAULT_DEPS) {
|
|
|
345
321
|
: renderScan(outcome, { emoji: deps.emoji && values.plain !== true });
|
|
346
322
|
return { code: exitCode(outcome), stdout, stderr: "" };
|
|
347
323
|
}
|
|
348
|
-
// Only touch the process when executed directly, not when imported by tests.
|
|
349
324
|
if (import.meta.main) {
|
|
350
|
-
// Built on first use: fingerprinting croak's code costs a directory read.
|
|
351
325
|
let cache;
|
|
352
326
|
const contentCache = () => {
|
|
353
327
|
cache ??= fileContentCache(join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "croak", "content"), codeFingerprint(import.meta.dirname));
|
package/dist/crypto/der.js
CHANGED
|
@@ -1,9 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Just enough DER to read the build identity Fulcio writes into a signing
|
|
3
|
-
* certificate (M7, D42). node:crypto verifies certificates but does not expose
|
|
4
|
-
* custom extensions. Certificates come from the registry, so every length is
|
|
5
|
-
* checked against the bytes that are there.
|
|
6
|
-
*/
|
|
7
1
|
export class DerError extends Error {
|
|
8
2
|
constructor(message) {
|
|
9
3
|
super(message);
|
|
@@ -41,7 +35,6 @@ export function readTlv(buf, offset) {
|
|
|
41
35
|
throw new DerError(`element at byte ${offset} runs past the end`);
|
|
42
36
|
return { tag, start, end };
|
|
43
37
|
}
|
|
44
|
-
/** The elements inside a constructed element, in order. */
|
|
45
38
|
export function children(buf, parent) {
|
|
46
39
|
const out = [];
|
|
47
40
|
for (let at = parent.start; at < parent.end;) {
|
|
@@ -63,7 +56,6 @@ function oidOf(buf, tlv) {
|
|
|
63
56
|
const first = bytes[0];
|
|
64
57
|
if (first === undefined)
|
|
65
58
|
throw new DerError("empty OID");
|
|
66
|
-
// The first byte packs the first two arcs as 40 * a + b (a is at most 2).
|
|
67
59
|
const top = Math.min(2, Math.floor(first / 40));
|
|
68
60
|
const arcs = [top, first - top * 40];
|
|
69
61
|
let value = 0;
|
|
@@ -78,7 +70,6 @@ function oidOf(buf, tlv) {
|
|
|
78
70
|
}
|
|
79
71
|
return arcs.join(".");
|
|
80
72
|
}
|
|
81
|
-
// Fulcio's v2 extensions (1.3.6.1.4.1.57264.1.8 onwards), each a DER UTF8String.
|
|
82
73
|
const FULCIO = "1.3.6.1.4.1.57264.1.";
|
|
83
74
|
const FIELDS = {
|
|
84
75
|
[`${FULCIO}8`]: "issuer",
|
|
@@ -95,10 +86,6 @@ const OCTET_STRING = 0x04;
|
|
|
95
86
|
const SEQUENCE = 0x30;
|
|
96
87
|
const OID = 0x06;
|
|
97
88
|
const EXTENSIONS = 0xa3;
|
|
98
|
-
/**
|
|
99
|
-
* The build identity in a Fulcio certificate. Reads only; whether the
|
|
100
|
-
* certificate is genuine is src/crypto/sigstore.ts's job.
|
|
101
|
-
*/
|
|
102
89
|
export function fulcioIdentity(certDer) {
|
|
103
90
|
const cert = expectTag(readTlv(certDer, 0), SEQUENCE, "a certificate");
|
|
104
91
|
const tbs = expectTag(children(certDer, cert)[0], SEQUENCE, "tbsCertificate");
|
package/dist/crypto/fields.js
CHANGED
|
@@ -1,9 +1,4 @@
|
|
|
1
1
|
import { verify, X509Certificate } from "node:crypto";
|
|
2
|
-
/**
|
|
3
|
-
* Reading Sigstore bundles, which come from the registry: every field is
|
|
4
|
-
* checked for shape before it is used (M7, D43).
|
|
5
|
-
*/
|
|
6
|
-
/** Why a bundle check stopped: it is wrong, or croak cannot tell. */
|
|
7
2
|
export class SigstoreError extends Error {
|
|
8
3
|
status;
|
|
9
4
|
constructor(status, message) {
|
|
@@ -17,7 +12,6 @@ export const unverifiable = (message) => new SigstoreError("unverifiable", messa
|
|
|
17
12
|
export function isRecord(value) {
|
|
18
13
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
19
14
|
}
|
|
20
|
-
/** The string at `path` inside `value`; anything else is an invalid bundle. */
|
|
21
15
|
export function str(value, ...path) {
|
|
22
16
|
let at = value;
|
|
23
17
|
for (const key of path) {
|
|
@@ -45,11 +39,6 @@ export function certFrom(der, what) {
|
|
|
45
39
|
throw invalid(`${what} is not a certificate`);
|
|
46
40
|
}
|
|
47
41
|
}
|
|
48
|
-
/**
|
|
49
|
-
* A SHA-256 signature check that answers false instead of throwing: keys
|
|
50
|
-
* and signatures come from the registry, and node:crypto throws on some
|
|
51
|
-
* (an Ed25519 key with a digest, malformed DER).
|
|
52
|
-
*/
|
|
53
42
|
export function verifies(data, key, signature) {
|
|
54
43
|
try {
|
|
55
44
|
return verify("sha256", data, key, signature);
|
|
@@ -4,12 +4,6 @@ const p256 = (spkiBase64) => createPublicKey({
|
|
|
4
4
|
format: "der",
|
|
5
5
|
type: "spki",
|
|
6
6
|
});
|
|
7
|
-
/**
|
|
8
|
-
* registry.npmjs.org/-/npm/v1/keys, pinned 2026-10-31 (D42). Pinned, not
|
|
9
|
-
* fetched: keys from the same server as the packument would only check the
|
|
10
|
-
* server against itself. A key croak does not know is "unverifiable", never
|
|
11
|
-
* a pass.
|
|
12
|
-
*/
|
|
13
7
|
export const NPM_REGISTRY_KEYS = [
|
|
14
8
|
{
|
|
15
9
|
keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
|
|
@@ -38,15 +32,9 @@ function verifies(message, key, sig) {
|
|
|
38
32
|
return verify("sha256", message, key, Buffer.from(sig, "base64"));
|
|
39
33
|
}
|
|
40
34
|
catch {
|
|
41
|
-
// Malformed DER is a bad signature, not a crash.
|
|
42
35
|
return false;
|
|
43
36
|
}
|
|
44
37
|
}
|
|
45
|
-
/**
|
|
46
|
-
* Like npm's own check: every signature by a known key must verify, and a
|
|
47
|
-
* key past its expiry signs nothing published after it. Signatures by keys
|
|
48
|
-
* croak does not know are skipped while a known one verifies.
|
|
49
|
-
*/
|
|
50
38
|
export function checkRegistrySignature(pkg, keys = NPM_REGISTRY_KEYS) {
|
|
51
39
|
const signatures = signaturesOf(pkg.signatures);
|
|
52
40
|
if (signatures.length === 0)
|
package/dist/crypto/rekor.js
CHANGED
|
@@ -7,20 +7,9 @@ export function rekorLog(spkiBase64) {
|
|
|
7
7
|
publicKey: createPublicKey({ key: der, format: "der", type: "spki" }),
|
|
8
8
|
};
|
|
9
9
|
}
|
|
10
|
-
/**
|
|
11
|
-
* rekor.sigstore.dev, pinned 2026-10-31 from github.com/sigstore/root-signing
|
|
12
|
-
* targets/trusted_root.json (D42). Rekor v2 logs (Ed25519, no SET) are not
|
|
13
|
-
* pinned: their entries are unverifiable (D43).
|
|
14
|
-
*/
|
|
15
10
|
export const REKOR_V1 = rekorLog("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==");
|
|
16
11
|
const SUPPORTED = ["dsse 0.0.1", "intoto 0.0.2"];
|
|
17
|
-
/** A PEM certificate as Rekor stores it (base64 of the PEM text), as DER. */
|
|
18
12
|
const pemCertDer = (base64Pem) => certFrom(b64(base64Pem), "the logged certificate").raw;
|
|
19
|
-
/**
|
|
20
|
-
* Verifies the SET over a bundle's log entry and reads what it recorded.
|
|
21
|
-
* Throws a SigstoreError: invalid when the entry is not what the log
|
|
22
|
-
* signed, unverifiable for a log, kind or shape croak does not support.
|
|
23
|
-
*/
|
|
24
13
|
export function readLogEntry(entry, log) {
|
|
25
14
|
const kind = `${str(entry, "kindVersion", "kind")} ${str(entry, "kindVersion", "version")}`;
|
|
26
15
|
if (!SUPPORTED.includes(kind))
|
|
@@ -35,7 +24,6 @@ export function readLogEntry(entry, log) {
|
|
|
35
24
|
if (!/^\d{1,15}$/.test(integratedTime) || !/^\d{1,15}$/.test(logIndex))
|
|
36
25
|
throw invalid("the log entry's time or index is not a number");
|
|
37
26
|
const body = str(entry, "canonicalizedBody");
|
|
38
|
-
// Rekor signs the canonical JSON of these four fields, keys sorted.
|
|
39
27
|
const promise = JSON.stringify({
|
|
40
28
|
body,
|
|
41
29
|
integratedTime: Number(integratedTime),
|
|
@@ -59,7 +47,6 @@ export function readLogEntry(entry, log) {
|
|
|
59
47
|
certificateDer: pemCertDer(str(logged, "spec", "signatures", 0, "verifier")),
|
|
60
48
|
payloadHash: str(logged, "spec", "payloadHash", "value"),
|
|
61
49
|
};
|
|
62
|
-
// intoto 0.0.2 stores the signature base64-encoded once more.
|
|
63
50
|
const signer = ["spec", "content", "envelope", "signatures", 0];
|
|
64
51
|
return {
|
|
65
52
|
loggedAt,
|
package/dist/crypto/sigstore.js
CHANGED
|
@@ -3,11 +3,6 @@ import { DerError, fulcioIdentity } from "./der.js";
|
|
|
3
3
|
import { b64, certFrom, invalid, isRecord, SigstoreError, str, unverifiable, verifies, } from "./fields.js";
|
|
4
4
|
import { REKOR_V1, readLogEntry } from "./rekor.js";
|
|
5
5
|
const pinned = (base64) => certFrom(Buffer.from(base64, "base64"), "a pinned Fulcio certificate");
|
|
6
|
-
/**
|
|
7
|
-
* Sigstore's public-good instance, pinned 2026-10-31 from
|
|
8
|
-
* github.com/sigstore/root-signing targets/trusted_root.json (D42): the
|
|
9
|
-
* Fulcio CA valid since 2022-04-13 (npm provenance began in 2023).
|
|
10
|
-
*/
|
|
11
6
|
export const SIGSTORE_PUBLIC_GOOD = {
|
|
12
7
|
fulcio: [
|
|
13
8
|
pinned("MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow="),
|
|
@@ -38,12 +33,10 @@ function checkCertificate(leaf, chain, at) {
|
|
|
38
33
|
if (at < leaf.validFromDate || at > leaf.validToDate)
|
|
39
34
|
throw invalid("it was logged outside the signing certificate's lifetime");
|
|
40
35
|
}
|
|
41
|
-
/** DSSE's pre-authentication encoding: what the envelope signature covers. */
|
|
42
36
|
const pae = (type, payload) => Buffer.concat([
|
|
43
37
|
Buffer.from(`DSSEv1 ${Buffer.byteLength(type)} ${type} ${payload.length} `),
|
|
44
38
|
payload,
|
|
45
39
|
]);
|
|
46
|
-
/** A package URL with its escapes undone (npm writes `pkg:npm/%40scope/name@1.0.0`). */
|
|
47
40
|
function purlName(purl) {
|
|
48
41
|
try {
|
|
49
42
|
return decodeURIComponent(purl);
|
|
@@ -89,7 +82,6 @@ function check(bundle, subject, trust) {
|
|
|
89
82
|
throw invalid(`the envelope carries ${type}, not an in-toto statement`);
|
|
90
83
|
const payload = b64(str(bundle, "dsseEnvelope", "payload"));
|
|
91
84
|
const sig = str(bundle, "dsseEnvelope", "signatures", 0, "sig");
|
|
92
|
-
// Fulcio also certifies Ed25519 keys; npm's are EC. Others are unchecked.
|
|
93
85
|
const keyType = leaf.publicKey.asymmetricKeyType;
|
|
94
86
|
if (keyType !== "ec" && keyType !== "rsa")
|
|
95
87
|
throw unverifiable(`the signing key is ${keyType ?? "of an unknown type"}`);
|
|
@@ -106,7 +98,6 @@ function check(bundle, subject, trust) {
|
|
|
106
98
|
loggedAt: logged.loggedAt,
|
|
107
99
|
};
|
|
108
100
|
}
|
|
109
|
-
/** Verifies an npm provenance bundle (the SLSA one) for this exact tarball. */
|
|
110
101
|
export function verifyProvenance(bundle, subject, trust = SIGSTORE_PUBLIC_GOOD) {
|
|
111
102
|
try {
|
|
112
103
|
return check(bundle, subject, trust);
|
|
@@ -19,7 +19,6 @@ const BOOLEAN = new Set([
|
|
|
19
19
|
]);
|
|
20
20
|
const MAX_SHOWN = 60;
|
|
21
21
|
const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
|
|
22
|
-
/** What git does with a setting, or undefined when the setting runs nothing. */
|
|
23
22
|
function classify({ section, key, value }) {
|
|
24
23
|
const name = `${section.split(".")[0] ?? ""}.${key}`;
|
|
25
24
|
if (section === "core" && key === "fsmonitor") {
|
|
@@ -41,13 +40,11 @@ function classify({ section, key, value }) {
|
|
|
41
40
|
return { when: "runs on every git diff", severity: "block" };
|
|
42
41
|
if (section.startsWith("filter.") &&
|
|
43
42
|
["clean", "smudge", "process"].includes(key)) {
|
|
44
|
-
// Git runs filter values through the shell: only the exact LFS command is benign.
|
|
45
43
|
return LFS_FILTER.test(value)
|
|
46
44
|
? { when: "is a Git LFS filter", severity: "info" }
|
|
47
45
|
: { when: "runs on checkout and add", severity: "block" };
|
|
48
46
|
}
|
|
49
47
|
if (name === "credential.helper") {
|
|
50
|
-
// Only a bare helper name (osxkeychain, store, manager) is benign.
|
|
51
48
|
return NAMED_HELPER.test(value)
|
|
52
49
|
? { when: "is a named credential helper", severity: "info" }
|
|
53
50
|
: { when: "runs when git asks for credentials", severity: "block" };
|
|
@@ -125,7 +122,6 @@ const isGitConfig = (path) => path === ".git/config" ||
|
|
|
125
122
|
path.endsWith("/.git/config") ||
|
|
126
123
|
posix.basename(path) === ".gitmodules";
|
|
127
124
|
const isActiveHook = (path) => /(?:^|\/)\.git\/hooks\/[^/]+$/.test(path) && !path.endsWith(".sample");
|
|
128
|
-
/** Git settings and hooks that make ordinary git commands run code (e.g. a zip-shipped .git). */
|
|
129
125
|
export const gitConfig = (repo) => repo.entries
|
|
130
126
|
.filter((e) => e.kind === "file" && !isInNodeModules(e.path))
|
|
131
127
|
.flatMap((e) => {
|
|
@@ -2,7 +2,6 @@ import { codeSignals } from "../signals/code.js";
|
|
|
2
2
|
import { hiddenCode } from "../signals/hidden.js";
|
|
3
3
|
import { isInNodeModules } from "./node-modules-present.js";
|
|
4
4
|
const RULE = "repo/hidden-code";
|
|
5
|
-
/** Files a toolchain, IDE, or shell may execute or load. */
|
|
6
5
|
const SCANNED = /\.(?:[cm]?[jt]sx?|json|sh|bash|zsh|py|rb|pl|ps1)$/;
|
|
7
6
|
const MAX_HITS_PER_FILE = 5;
|
|
8
7
|
const SEVERITY_RANK = {
|
|
@@ -19,7 +18,6 @@ const DESCRIBE = {
|
|
|
19
18
|
const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
|
|
20
19
|
function severityOf(hit, path) {
|
|
21
20
|
if (hit.kind === "bidi") {
|
|
22
|
-
// RTL locale files legitimately contain bidi isolates.
|
|
23
21
|
return path.endsWith(".json") ? "info" : "warn";
|
|
24
22
|
}
|
|
25
23
|
return codeSignals(hit.hidden).length > 0 ? "block" : "warn";
|
|
@@ -27,9 +25,6 @@ function severityOf(hit, path) {
|
|
|
27
25
|
function finding(hit, path, downgradeInstalled) {
|
|
28
26
|
const signals = [...new Set(codeSignals(hit.hidden).map((s) => s.signal))];
|
|
29
27
|
const raw = severityOf(hit, path);
|
|
30
|
-
// In a repo, installed and committed dependencies look the same and
|
|
31
|
-
// repo/node-modules-present already flags the directory, so content inside
|
|
32
|
-
// it is at most a warning. In a package, node_modules is bundled code npm runs.
|
|
33
28
|
const severity = downgradeInstalled && isInNodeModules(path) && raw === "block"
|
|
34
29
|
? "warn"
|
|
35
30
|
: raw;
|
|
@@ -43,13 +38,11 @@ function finding(hit, path, downgradeInstalled) {
|
|
|
43
38
|
message: `${DESCRIBE[hit.kind]} at line ${hit.line}, column ${hit.column}${detail}${using}`,
|
|
44
39
|
};
|
|
45
40
|
}
|
|
46
|
-
/** Code placed where a human reviewing the file will not see it. */
|
|
47
41
|
export function hiddenCodeWith({ downgradeInstalled, }) {
|
|
48
42
|
return (repo) => repo.entries
|
|
49
43
|
.filter((e) => e.kind === "file" && SCANNED.test(e.path))
|
|
50
44
|
.flatMap((e) => hiddenCode(repo.read(e.path) ?? "")
|
|
51
45
|
.map((hit) => finding(hit, e.path, downgradeInstalled))
|
|
52
|
-
// Most severe first, so harmless decoys cannot push the payload past the cap.
|
|
53
46
|
.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
|
|
54
47
|
.slice(0, MAX_HITS_PER_FILE));
|
|
55
48
|
}
|
|
@@ -11,14 +11,11 @@ function isRecord(value) {
|
|
|
11
11
|
}
|
|
12
12
|
const asString = (value) => typeof value === "string" ? value : undefined;
|
|
13
13
|
const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
|
|
14
|
-
/** Lines (1-based) that mention folderOpen, in order: one per autorun task. */
|
|
15
14
|
function folderOpenLines(text) {
|
|
16
15
|
return text
|
|
17
16
|
.split("\n")
|
|
18
17
|
.flatMap((line, index) => (line.includes("folderOpen") ? [index + 1] : []));
|
|
19
18
|
}
|
|
20
|
-
/** The npm script a task runs, read from the package.json next to the .vscode folder. */
|
|
21
|
-
/** The package.json next to the .vscode folder: its directory and scripts. */
|
|
22
19
|
function manifestFor(repo, tasksPath) {
|
|
23
20
|
const folder = posix.dirname(posix.dirname(tasksPath));
|
|
24
21
|
const dir = folder === "." ? "" : folder;
|
|
@@ -35,7 +32,6 @@ function manifestFor(repo, tasksPath) {
|
|
|
35
32
|
};
|
|
36
33
|
}
|
|
37
34
|
catch {
|
|
38
|
-
// An unreadable package.json is reported by repo/lifecycle-script.
|
|
39
35
|
return { dir, scripts: {} };
|
|
40
36
|
}
|
|
41
37
|
}
|
|
@@ -45,8 +41,6 @@ function taskFinding(repo, path, task, line, trigger) {
|
|
|
45
41
|
const hidden = presentation.reveal === "never" ? ", with its terminal hidden" : "";
|
|
46
42
|
const at = line === undefined ? { file: path } : { file: path, line };
|
|
47
43
|
if (task.type === "npm") {
|
|
48
|
-
// Same analysis as install scripts: the script, the files it runs, and
|
|
49
|
-
// the scripts it calls through npm run.
|
|
50
44
|
const script = asString(task.script) ?? "";
|
|
51
45
|
const { dir, scripts } = manifestFor(repo, path);
|
|
52
46
|
const command = asString(scripts[script]);
|
|
@@ -64,7 +58,6 @@ function taskFinding(repo, path, task, line, trigger) {
|
|
|
64
58
|
message: `task "${label}" runs npm script "${script}" (${shown}) ${trigger}${hidden}${reasons}`,
|
|
65
59
|
};
|
|
66
60
|
}
|
|
67
|
-
// shell, process, or no type: VS Code runs the command directly.
|
|
68
61
|
const args = Array.isArray(task.args)
|
|
69
62
|
? task.args.filter((a) => typeof a === "string")
|
|
70
63
|
: [];
|
|
@@ -111,7 +104,6 @@ function tasksFindings(repo, path) {
|
|
|
111
104
|
const autorun = tasks.filter((task) => isRecord(task.runOptions) && task.runOptions.runOn === "folderOpen");
|
|
112
105
|
const lines = folderOpenLines(text);
|
|
113
106
|
const findings = autorun.map((task, index) => taskFinding(repo, path, task, lines[index], "when the folder is opened"));
|
|
114
|
-
// Tasks an autorun task depends on run first, just as automatically.
|
|
115
107
|
const seen = new Set(autorun);
|
|
116
108
|
const queue = autorun.flatMap((task) => dependsOnLabels(task).map((dep) => ({
|
|
117
109
|
dep,
|
|
@@ -128,7 +120,6 @@ function tasksFindings(repo, path) {
|
|
|
128
120
|
}
|
|
129
121
|
return findings;
|
|
130
122
|
}
|
|
131
|
-
/** VS Code tasks that run by themselves when the folder is opened. */
|
|
132
123
|
export const ideAutorun = (repo) => repo.entries
|
|
133
124
|
.filter((e) => e.kind === "file" &&
|
|
134
125
|
(e.path === TASKS_FILE || e.path.endsWith(`/${TASKS_FILE}`)) &&
|
package/dist/detectors/index.js
CHANGED
|
@@ -5,7 +5,6 @@ import { lifecycleScript } from "./lifecycle-script.js";
|
|
|
5
5
|
import { nodeModulesPresent } from "./node-modules-present.js";
|
|
6
6
|
import { obfuscation } from "./obfuscation.js";
|
|
7
7
|
import { unscanned } from "./unscanned.js";
|
|
8
|
-
/** Registered repo detectors. `unscanned` is the safety floor and always runs. */
|
|
9
8
|
export const repoDetectors = [
|
|
10
9
|
unscanned,
|
|
11
10
|
nodeModulesPresent,
|
|
@@ -4,7 +4,6 @@ import { analyzeShell } from "../signals/shell.js";
|
|
|
4
4
|
import { stripBom } from "../signals/text.js";
|
|
5
5
|
import { isInNodeModules } from "./node-modules-present.js";
|
|
6
6
|
const RULE = "repo/lifecycle-script";
|
|
7
|
-
/** Every script npm runs during `npm install` in the project itself. */
|
|
8
7
|
export const INSTALL_SCRIPTS = [
|
|
9
8
|
"preinstall",
|
|
10
9
|
"install",
|
|
@@ -13,21 +12,14 @@ export const INSTALL_SCRIPTS = [
|
|
|
13
12
|
"preprepare",
|
|
14
13
|
"prepare",
|
|
15
14
|
"postprepare",
|
|
16
|
-
// runs after node_modules changes (npm docs)
|
|
17
15
|
"dependencies",
|
|
18
16
|
];
|
|
19
|
-
/** The only scripts npm runs for a package installed from the registry. */
|
|
20
17
|
export const DEPENDENCY_INSTALL_SCRIPTS = [
|
|
21
18
|
"preinstall",
|
|
22
19
|
"install",
|
|
23
20
|
"postinstall",
|
|
24
21
|
];
|
|
25
|
-
/** node_modules/<name>/package.json or node_modules/@scope/<name>/package.json, at any depth. */
|
|
26
22
|
const BUNDLED_MANIFEST = /(?:^|\/)node_modules\/(?:@[^/]+\/)?[^/@][^/]*\/package\.json$/;
|
|
27
|
-
/**
|
|
28
|
-
* Tools that run no project code: their install-time use can be an info note
|
|
29
|
-
* (D19). Everything they do is decided by the tool, not by files in the repo.
|
|
30
|
-
*/
|
|
31
23
|
const INERT_TOOLS = new Set([
|
|
32
24
|
"husky",
|
|
33
25
|
"simple-git-hooks",
|
|
@@ -43,11 +35,6 @@ const INERT_TOOLS = new Set([
|
|
|
43
35
|
"exit",
|
|
44
36
|
"printf",
|
|
45
37
|
]);
|
|
46
|
-
/**
|
|
47
|
-
* Recognized install-time tools that execute code from the repo — a JS/TS
|
|
48
|
-
* config, workspace tasks, a generator, or patches. Recognizing one adds a
|
|
49
|
-
* note; the script stays a warning.
|
|
50
|
-
*/
|
|
51
38
|
const EXECUTING_TOOLS = new Set([
|
|
52
39
|
"prisma",
|
|
53
40
|
"vite",
|
|
@@ -76,7 +63,6 @@ function isRecord(value) {
|
|
|
76
63
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
77
64
|
}
|
|
78
65
|
const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
|
|
79
|
-
/** 1-based line of `"name":` inside the manifest's "scripts" object, if found. */
|
|
80
66
|
function scriptLine(manifest, name) {
|
|
81
67
|
const lines = manifest.split("\n");
|
|
82
68
|
const scripts = lines.findIndex((line) => /"scripts"\s*:/.test(line));
|
|
@@ -86,21 +72,13 @@ function scriptLine(manifest, name) {
|
|
|
86
72
|
/"\s*:/.test(line.slice(line.indexOf(key))));
|
|
87
73
|
return index === -1 ? undefined : index + 1;
|
|
88
74
|
}
|
|
89
|
-
/**
|
|
90
|
-
* Decides whether install-time commands are routine (D19): every command is an
|
|
91
|
-
* inert tool or a package-manager call that runs a script which is itself
|
|
92
|
-
* routine — including npm's pre/post hooks for that script — and nothing else
|
|
93
|
-
* is present: no network, local files, inline code, code signals, environment
|
|
94
|
-
* assignments, redirects, wrappers, or workspace-wide runs. Results are
|
|
95
|
-
* memoized per script name, so a layered script graph costs linear time.
|
|
96
|
-
*/
|
|
97
75
|
function routineChecker(scripts) {
|
|
98
76
|
const memo = new Map();
|
|
99
77
|
const scriptIsRoutine = (name, depth) => {
|
|
100
78
|
const known = memo.get(name);
|
|
101
79
|
if (known !== undefined)
|
|
102
80
|
return known;
|
|
103
|
-
memo.set(name, false);
|
|
81
|
+
memo.set(name, false);
|
|
104
82
|
const body = scripts[name];
|
|
105
83
|
const result = typeof body === "string" &&
|
|
106
84
|
commandIsRoutine(body, depth + 1) &&
|
|
@@ -122,7 +100,6 @@ function routineChecker(scripts) {
|
|
|
122
100
|
facts.commands.length === 0) {
|
|
123
101
|
return false;
|
|
124
102
|
}
|
|
125
|
-
// Every package-manager call must run a script: `npm install x` does not.
|
|
126
103
|
const pmCalls = facts.commands.filter((name) => PACKAGE_MANAGERS.has(name)).length;
|
|
127
104
|
if (pmCalls !== facts.runScripts.length)
|
|
128
105
|
return false;
|
|
@@ -132,12 +109,6 @@ function routineChecker(scripts) {
|
|
|
132
109
|
};
|
|
133
110
|
return (command) => commandIsRoutine(command, 0);
|
|
134
111
|
}
|
|
135
|
-
/**
|
|
136
|
-
* What a script's command does, including the files and inline code it runs
|
|
137
|
-
* and the package.json scripts it calls through `npm run` (with npm's pre/post
|
|
138
|
-
* hooks), recursively. Signals are combined across the whole call graph, so
|
|
139
|
-
* an attack split across two scripts still blocks. Memoized per script name.
|
|
140
|
-
*/
|
|
141
112
|
export function assessor(repo, dir, scripts, suspicious = isSuspicious) {
|
|
142
113
|
const memo = new Map();
|
|
143
114
|
const empty = {
|
|
@@ -150,7 +121,7 @@ export function assessor(repo, dir, scripts, suspicious = isSuspicious) {
|
|
|
150
121
|
const known = memo.get(name);
|
|
151
122
|
if (known !== undefined)
|
|
152
123
|
return known;
|
|
153
|
-
memo.set(name, empty);
|
|
124
|
+
memo.set(name, empty);
|
|
154
125
|
const body = scripts[name];
|
|
155
126
|
const result = typeof body !== "string"
|
|
156
127
|
? {
|
|
@@ -166,8 +137,6 @@ export function assessor(repo, dir, scripts, suspicious = isSuspicious) {
|
|
|
166
137
|
return { ...empty, reasons: ["scripts are nested too deeply to follow"] };
|
|
167
138
|
const facts = analyzeShell(command);
|
|
168
139
|
if (facts.unparsable) {
|
|
169
|
-
// Fail closed: unparsable text that names a download tool could be
|
|
170
|
-
// hiding curl … | sh behind a trick the parser does not understand.
|
|
171
140
|
return facts.mentionsDownloader
|
|
172
141
|
? {
|
|
173
142
|
...empty,
|
|
@@ -254,7 +223,6 @@ function manifestFindings(repo, path, { suspicious, scripts: installScripts }) {
|
|
|
254
223
|
manifest = JSON.parse(stripBom(text));
|
|
255
224
|
}
|
|
256
225
|
catch {
|
|
257
|
-
// Not swallowed: an unreadable manifest is reported, the scan continues.
|
|
258
226
|
return [
|
|
259
227
|
{
|
|
260
228
|
ruleId: RULE,
|
|
@@ -299,7 +267,6 @@ function manifestFindings(repo, path, { suspicious, scripts: installScripts }) {
|
|
|
299
267
|
});
|
|
300
268
|
}
|
|
301
269
|
}
|
|
302
|
-
// npm runs `node-gyp rebuild` for a package with binding.gyp and no install script of its own.
|
|
303
270
|
const gyp = posix.join(dir, "binding.gyp");
|
|
304
271
|
if (!("install" in scripts) &&
|
|
305
272
|
!("preinstall" in scripts) &&
|
|
@@ -313,12 +280,6 @@ function manifestFindings(repo, path, { suspicious, scripts: installScripts }) {
|
|
|
313
280
|
}
|
|
314
281
|
return findings;
|
|
315
282
|
}
|
|
316
|
-
/**
|
|
317
|
-
* Install-time scripts in every package.json outside node_modules (workspaces
|
|
318
|
-
* run theirs too). The policy decides which code-signal combinations block and
|
|
319
|
-
* which scripts count; package contents use one that tolerates binary
|
|
320
|
-
* installers and follows npm's rules for registry dependencies.
|
|
321
|
-
*/
|
|
322
283
|
export function lifecycleScriptWith(policy) {
|
|
323
284
|
return (repo) => repo.entries
|
|
324
285
|
.filter((e) => e.kind === "file" &&
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const SEGMENT = "node_modules";
|
|
2
|
-
/** The outermost node_modules directory containing path, or undefined. */
|
|
3
2
|
function nodeModulesRoot(path) {
|
|
4
3
|
const segments = path.split("/");
|
|
5
4
|
const index = segments.indexOf(SEGMENT);
|
|
@@ -8,12 +7,6 @@ function nodeModulesRoot(path) {
|
|
|
8
7
|
export function isInNodeModules(path) {
|
|
9
8
|
return nodeModulesRoot(path) !== undefined;
|
|
10
9
|
}
|
|
11
|
-
/**
|
|
12
|
-
* croak repo checks a repo before install, so installed dependencies in it are
|
|
13
|
-
* a signal on their own: a committed node_modules is a classic hiding place.
|
|
14
|
-
* One finding per directory instead of one per file keeps installed projects
|
|
15
|
-
* readable.
|
|
16
|
-
*/
|
|
17
10
|
export const nodeModulesPresent = (repo) => {
|
|
18
11
|
const counts = new Map();
|
|
19
12
|
for (const { path } of repo.entries) {
|