@vola-trebla/croak 0.0.0-stage → 0.1.0

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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +283 -2
  3. package/dist/cli.js +373 -0
  4. package/dist/crypto/der.js +136 -0
  5. package/dist/crypto/fields.js +60 -0
  6. package/dist/crypto/registry-signature.js +88 -0
  7. package/dist/crypto/rekor.js +70 -0
  8. package/dist/crypto/sigstore.js +121 -0
  9. package/dist/detectors/git-config.js +137 -0
  10. package/dist/detectors/hidden-code.js +58 -0
  11. package/dist/detectors/ide-autorun.js +136 -0
  12. package/dist/detectors/index.js +20 -0
  13. package/dist/detectors/lifecycle-script.js +337 -0
  14. package/dist/detectors/node-modules-present.js +32 -0
  15. package/dist/detectors/obfuscation.js +124 -0
  16. package/dist/detectors/unscanned.js +41 -0
  17. package/dist/hook/hook.js +391 -0
  18. package/dist/hook/install-commands.js +927 -0
  19. package/dist/install/content-cache.js +94 -0
  20. package/dist/install/content.js +42 -0
  21. package/dist/install/detectors.js +79 -0
  22. package/dist/install/installed.js +99 -0
  23. package/dist/install/lockfile.js +72 -0
  24. package/dist/install/metadata.js +159 -0
  25. package/dist/install/plan.js +31 -0
  26. package/dist/install/pnpm-builds.js +111 -0
  27. package/dist/install/pnpm-lockfile.js +102 -0
  28. package/dist/install/popular.js +177 -0
  29. package/dist/install/project.js +223 -0
  30. package/dist/install/provenance.js +77 -0
  31. package/dist/install/registry.js +274 -0
  32. package/dist/install/report.js +26 -0
  33. package/dist/install/sanitize.js +88 -0
  34. package/dist/install/scan.js +302 -0
  35. package/dist/install/signature.js +62 -0
  36. package/dist/install/tar.js +178 -0
  37. package/dist/install/workspace.js +31 -0
  38. package/dist/install/yaml.js +282 -0
  39. package/dist/llm/client.js +22 -0
  40. package/dist/llm/cost.js +26 -0
  41. package/dist/llm/diff.js +114 -0
  42. package/dist/llm/escalate.js +14 -0
  43. package/dist/llm/plan-step.js +91 -0
  44. package/dist/llm/select.js +21 -0
  45. package/dist/llm/triage.js +111 -0
  46. package/dist/repo-report.js +95 -0
  47. package/dist/repo-snapshot.js +157 -0
  48. package/dist/scan.js +48 -0
  49. package/dist/signals/code.js +136 -0
  50. package/dist/signals/git-config.js +58 -0
  51. package/dist/signals/hidden.js +77 -0
  52. package/dist/signals/jsonc.js +100 -0
  53. package/dist/signals/obfuscation.js +98 -0
  54. package/dist/signals/shell.js +717 -0
  55. package/dist/signals/text.js +2 -0
  56. package/dist/ui.js +12 -0
  57. package/dist/verdict.js +20 -0
  58. package/package.json +71 -4
  59. package/scripts/croak-hook.sh +59 -0
  60. package/scripts/croak-install.sh +262 -0
@@ -0,0 +1,136 @@
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
+ export class DerError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "DerError";
11
+ }
12
+ }
13
+ export function readTlv(buf, offset) {
14
+ const tag = buf[offset];
15
+ const first = buf[offset + 1];
16
+ if (tag === undefined || first === undefined)
17
+ throw new DerError(`truncated element at byte ${offset}`);
18
+ if ((tag & 0x1f) === 0x1f)
19
+ throw new DerError(`high tag numbers are not supported (byte ${offset})`);
20
+ let length = first;
21
+ let start = offset + 2;
22
+ if (first & 0x80) {
23
+ const count = first & 0x7f;
24
+ if (count === 0)
25
+ throw new DerError("indefinite length is not DER");
26
+ if (count > 4)
27
+ throw new DerError(`length of ${count} bytes is too long`);
28
+ length = 0;
29
+ for (let i = 0; i < count; i++) {
30
+ const byte = buf[start + i];
31
+ if (byte === undefined)
32
+ throw new DerError("truncated length");
33
+ length = length * 256 + byte;
34
+ }
35
+ if (length < 0x80 || (count > 1 && length < 256 ** (count - 1)))
36
+ throw new DerError("length is not in its shortest form");
37
+ start += count;
38
+ }
39
+ const end = start + length;
40
+ if (end > buf.length)
41
+ throw new DerError(`element at byte ${offset} runs past the end`);
42
+ return { tag, start, end };
43
+ }
44
+ /** The elements inside a constructed element, in order. */
45
+ export function children(buf, parent) {
46
+ const out = [];
47
+ for (let at = parent.start; at < parent.end;) {
48
+ const child = readTlv(buf, at);
49
+ if (child.end > parent.end)
50
+ throw new DerError(`element at byte ${at} runs past its parent`);
51
+ out.push(child);
52
+ at = child.end;
53
+ }
54
+ return out;
55
+ }
56
+ function expectTag(tlv, tag, what) {
57
+ if (tlv === undefined || tlv.tag !== tag)
58
+ throw new DerError(`expected ${what}`);
59
+ return tlv;
60
+ }
61
+ function oidOf(buf, tlv) {
62
+ const bytes = buf.subarray(tlv.start, tlv.end);
63
+ const first = bytes[0];
64
+ if (first === undefined)
65
+ throw new DerError("empty OID");
66
+ // The first byte packs the first two arcs as 40 * a + b (a is at most 2).
67
+ const top = Math.min(2, Math.floor(first / 40));
68
+ const arcs = [top, first - top * 40];
69
+ let value = 0;
70
+ for (const byte of bytes.subarray(1)) {
71
+ value = value * 128 + (byte & 0x7f);
72
+ if (value > Number.MAX_SAFE_INTEGER)
73
+ throw new DerError("OID arc too large");
74
+ if (!(byte & 0x80)) {
75
+ arcs.push(value);
76
+ value = 0;
77
+ }
78
+ }
79
+ return arcs.join(".");
80
+ }
81
+ // Fulcio's v2 extensions (1.3.6.1.4.1.57264.1.8 onwards), each a DER UTF8String.
82
+ const FULCIO = "1.3.6.1.4.1.57264.1.";
83
+ const FIELDS = {
84
+ [`${FULCIO}8`]: "issuer",
85
+ [`${FULCIO}9`]: "buildSignerUri",
86
+ [`${FULCIO}11`]: "runnerEnvironment",
87
+ [`${FULCIO}12`]: "sourceRepositoryUri",
88
+ [`${FULCIO}13`]: "sourceRepositoryDigest",
89
+ [`${FULCIO}14`]: "sourceRepositoryRef",
90
+ [`${FULCIO}15`]: "sourceRepositoryId",
91
+ [`${FULCIO}18`]: "buildConfigUri",
92
+ };
93
+ const UTF8_STRING = 0x0c;
94
+ const OCTET_STRING = 0x04;
95
+ const SEQUENCE = 0x30;
96
+ const OID = 0x06;
97
+ 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
+ export function fulcioIdentity(certDer) {
103
+ const cert = expectTag(readTlv(certDer, 0), SEQUENCE, "a certificate");
104
+ const tbs = expectTag(children(certDer, cert)[0], SEQUENCE, "tbsCertificate");
105
+ const wrapper = children(certDer, tbs).find((t) => t.tag === EXTENSIONS);
106
+ const identity = {};
107
+ if (wrapper !== undefined) {
108
+ const list = expectTag(children(certDer, wrapper)[0], SEQUENCE, "extensions");
109
+ const seen = new Set();
110
+ for (const ext of children(certDer, list)) {
111
+ const parts = children(certDer, expectTag(ext, SEQUENCE, "an extension"));
112
+ const oid = oidOf(certDer, expectTag(parts[0], OID, "an extension OID"));
113
+ if (seen.has(oid))
114
+ throw new DerError(`extension ${oid} appears twice`);
115
+ seen.add(oid);
116
+ const field = FIELDS[oid];
117
+ if (field === undefined)
118
+ continue;
119
+ const octets = expectTag(parts.at(-1), OCTET_STRING, "an extension value");
120
+ const text = expectTag(readTlv(certDer, octets.start), UTF8_STRING, `a string in ${oid}`);
121
+ if (text.end !== octets.end)
122
+ throw new DerError(`trailing bytes in ${oid}`);
123
+ identity[field] = certDer.subarray(text.start, text.end).toString("utf8");
124
+ }
125
+ }
126
+ return {
127
+ issuer: identity.issuer,
128
+ buildSignerUri: identity.buildSignerUri,
129
+ runnerEnvironment: identity.runnerEnvironment,
130
+ sourceRepositoryUri: identity.sourceRepositoryUri,
131
+ sourceRepositoryDigest: identity.sourceRepositoryDigest,
132
+ sourceRepositoryRef: identity.sourceRepositoryRef,
133
+ sourceRepositoryId: identity.sourceRepositoryId,
134
+ buildConfigUri: identity.buildConfigUri,
135
+ };
136
+ }
@@ -0,0 +1,60 @@
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
+ export class SigstoreError extends Error {
8
+ status;
9
+ constructor(status, message) {
10
+ super(message);
11
+ this.name = "SigstoreError";
12
+ this.status = status;
13
+ }
14
+ }
15
+ export const invalid = (message) => new SigstoreError("invalid", message);
16
+ export const unverifiable = (message) => new SigstoreError("unverifiable", message);
17
+ export function isRecord(value) {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+ /** The string at `path` inside `value`; anything else is an invalid bundle. */
21
+ export function str(value, ...path) {
22
+ let at = value;
23
+ for (const key of path) {
24
+ at =
25
+ Array.isArray(at) && typeof key === "number"
26
+ ? at[key]
27
+ : isRecord(at)
28
+ ? at[key]
29
+ : undefined;
30
+ }
31
+ if (typeof at !== "string")
32
+ throw invalid(`the bundle has no ${path.join(".")}`);
33
+ return at;
34
+ }
35
+ export function b64(text) {
36
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(text))
37
+ throw invalid("a bundle field is not base64");
38
+ return Buffer.from(text, "base64");
39
+ }
40
+ export function certFrom(der, what) {
41
+ try {
42
+ return new X509Certificate(der);
43
+ }
44
+ catch {
45
+ throw invalid(`${what} is not a certificate`);
46
+ }
47
+ }
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
+ export function verifies(data, key, signature) {
54
+ try {
55
+ return verify("sha256", data, key, signature);
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
@@ -0,0 +1,88 @@
1
+ import { createPublicKey, verify } from "node:crypto";
2
+ const p256 = (spkiBase64) => createPublicKey({
3
+ key: Buffer.from(spkiBase64, "base64"),
4
+ format: "der",
5
+ type: "spki",
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
+ export const NPM_REGISTRY_KEYS = [
14
+ {
15
+ keyid: "SHA256:jl3bwswu80PjjokCgh0o2w5c2U4LhQAE57gj9cz1kzA",
16
+ publicKey: p256("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE1Olb3zMAFFxXKHiIkQO5cJ3Yhl5i6UPp+IhuteBJbuHcA5UogKo0EWtlWwW6KSaKoTNEYL7JlCQiVnkhBktUgg=="),
17
+ expires: new Date("2025-01-29T00:00:00.000Z"),
18
+ },
19
+ {
20
+ keyid: "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U",
21
+ publicKey: p256("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEY6Ya7W++7aUPzvMTrezH6Ycx3c+HOKYCcNGybJZSCJq/fd7Qa8uuAKtdIkUQtQiEKERhAmE5lMMJhP8OkDOa2g=="),
22
+ expires: undefined,
23
+ },
24
+ ];
25
+ function signaturesOf(value) {
26
+ if (!Array.isArray(value))
27
+ return [];
28
+ return value.filter((s) => typeof s === "object" &&
29
+ s !== null &&
30
+ typeof s.keyid === "string" &&
31
+ typeof s.sig === "string");
32
+ }
33
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
34
+ function verifies(message, key, sig) {
35
+ if (sig === "" || !BASE64.test(sig))
36
+ return false;
37
+ try {
38
+ return verify("sha256", message, key, Buffer.from(sig, "base64"));
39
+ }
40
+ catch {
41
+ // Malformed DER is a bad signature, not a crash.
42
+ return false;
43
+ }
44
+ }
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
+ export function checkRegistrySignature(pkg, keys = NPM_REGISTRY_KEYS) {
51
+ const signatures = signaturesOf(pkg.signatures);
52
+ if (signatures.length === 0)
53
+ return { status: "unsigned" };
54
+ const message = Buffer.from(`${pkg.name}@${pkg.version}:${pkg.integrity}`);
55
+ const unknown = [];
56
+ let valid;
57
+ for (const { keyid, sig } of signatures) {
58
+ const key = keys.find((k) => k.keyid === keyid);
59
+ if (key === undefined) {
60
+ unknown.push(keyid);
61
+ continue;
62
+ }
63
+ if (key.expires !== undefined) {
64
+ if (pkg.published === undefined)
65
+ return {
66
+ status: "unverifiable",
67
+ reason: `key ${keyid} expired on ${key.expires.toISOString()} and the registry gives no publish time`,
68
+ };
69
+ if (pkg.published.getTime() > key.expires.getTime())
70
+ return {
71
+ status: "invalid",
72
+ reason: `signed with key ${keyid}, which expired before this version was published`,
73
+ };
74
+ }
75
+ if (!verifies(message, key.publicKey, sig))
76
+ return {
77
+ status: "invalid",
78
+ reason: `the signature by key ${keyid} does not match ${pkg.name}@${pkg.version} and its integrity`,
79
+ };
80
+ valid ??= keyid;
81
+ }
82
+ return valid !== undefined
83
+ ? { status: "valid", keyid: valid }
84
+ : {
85
+ status: "unverifiable",
86
+ reason: `signed only with keys croak does not know (${unknown.join(", ")}); a newer croak may know them`,
87
+ };
88
+ }
@@ -0,0 +1,70 @@
1
+ import { createHash, createPublicKey } from "node:crypto";
2
+ import { b64, certFrom, invalid, isRecord, str, unverifiable, verifies, } from "./fields.js";
3
+ export function rekorLog(spkiBase64) {
4
+ const der = Buffer.from(spkiBase64, "base64");
5
+ return {
6
+ logId: createHash("sha256").update(der).digest("base64"),
7
+ publicKey: createPublicKey({ key: der, format: "der", type: "spki" }),
8
+ };
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
+ export const REKOR_V1 = rekorLog("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==");
16
+ 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
+ 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
+ export function readLogEntry(entry, log) {
25
+ const kind = `${str(entry, "kindVersion", "kind")} ${str(entry, "kindVersion", "version")}`;
26
+ if (!SUPPORTED.includes(kind))
27
+ throw unverifiable(`a transparency log entry of kind ${kind}`);
28
+ if (str(entry, "logId", "keyId") !== log.logId)
29
+ throw unverifiable("logged in a transparency log croak does not know (a newer croak may)");
30
+ if (!isRecord(entry) || !isRecord(entry.inclusionPromise))
31
+ throw unverifiable("the log entry has no signed entry timestamp");
32
+ const set = str(entry, "inclusionPromise", "signedEntryTimestamp");
33
+ const integratedTime = str(entry, "integratedTime");
34
+ const logIndex = str(entry, "logIndex");
35
+ if (!/^\d{1,15}$/.test(integratedTime) || !/^\d{1,15}$/.test(logIndex))
36
+ throw invalid("the log entry's time or index is not a number");
37
+ const body = str(entry, "canonicalizedBody");
38
+ // Rekor signs the canonical JSON of these four fields, keys sorted.
39
+ const promise = JSON.stringify({
40
+ body,
41
+ integratedTime: Number(integratedTime),
42
+ logID: b64(log.logId).toString("hex"),
43
+ logIndex: Number(logIndex),
44
+ });
45
+ if (!verifies(Buffer.from(promise), log.publicKey, b64(set)))
46
+ throw invalid("the transparency log's signed timestamp does not verify");
47
+ let logged;
48
+ try {
49
+ logged = JSON.parse(b64(body).toString("utf8"));
50
+ }
51
+ catch {
52
+ throw invalid("the log entry's body is not JSON");
53
+ }
54
+ const loggedAt = new Date(Number(integratedTime) * 1000);
55
+ if (kind === "dsse 0.0.1")
56
+ return {
57
+ loggedAt,
58
+ signature: str(logged, "spec", "signatures", 0, "signature"),
59
+ certificateDer: pemCertDer(str(logged, "spec", "signatures", 0, "verifier")),
60
+ payloadHash: str(logged, "spec", "payloadHash", "value"),
61
+ };
62
+ // intoto 0.0.2 stores the signature base64-encoded once more.
63
+ const signer = ["spec", "content", "envelope", "signatures", 0];
64
+ return {
65
+ loggedAt,
66
+ signature: b64(str(logged, ...signer, "sig")).toString("utf8"),
67
+ certificateDer: pemCertDer(str(logged, ...signer, "publicKey")),
68
+ payloadHash: str(logged, "spec", "content", "payloadHash", "value"),
69
+ };
70
+ }
@@ -0,0 +1,121 @@
1
+ import { createHash } from "node:crypto";
2
+ import { DerError, fulcioIdentity } from "./der.js";
3
+ import { b64, certFrom, invalid, isRecord, SigstoreError, str, unverifiable, verifies, } from "./fields.js";
4
+ import { REKOR_V1, readLogEntry } from "./rekor.js";
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
+ export const SIGSTORE_PUBLIC_GOOD = {
12
+ fulcio: [
13
+ pinned("MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow="),
14
+ pinned("MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ"),
15
+ ],
16
+ rekor: REKOR_V1,
17
+ };
18
+ const CODE_SIGNING = "1.3.6.1.5.5.7.3.3";
19
+ function leafOf(material) {
20
+ const raw = isRecord(material) && isRecord(material.certificate)
21
+ ? str(material, "certificate", "rawBytes")
22
+ : str(material, "x509CertificateChain", "certificates", 0, "rawBytes");
23
+ return certFrom(b64(raw), "the signing certificate");
24
+ }
25
+ function checkCertificate(leaf, chain, at) {
26
+ let child = leaf;
27
+ for (const issuer of chain) {
28
+ if (!child.checkIssued(issuer) || !child.verify(issuer.publicKey))
29
+ throw invalid("the signing certificate does not chain to Fulcio");
30
+ if (at < issuer.validFromDate || at > issuer.validToDate)
31
+ throw invalid("Fulcio's certificate was not valid when this was logged");
32
+ child = issuer;
33
+ }
34
+ if (leaf.ca)
35
+ throw invalid("the signing certificate is a CA");
36
+ if (!(leaf.keyUsage ?? []).includes(CODE_SIGNING))
37
+ throw invalid("the signing certificate is not for code signing");
38
+ if (at < leaf.validFromDate || at > leaf.validToDate)
39
+ throw invalid("it was logged outside the signing certificate's lifetime");
40
+ }
41
+ /** DSSE's pre-authentication encoding: what the envelope signature covers. */
42
+ const pae = (type, payload) => Buffer.concat([
43
+ Buffer.from(`DSSEv1 ${Buffer.byteLength(type)} ${type} ${payload.length} `),
44
+ payload,
45
+ ]);
46
+ /** A package URL with its escapes undone (npm writes `pkg:npm/%40scope/name@1.0.0`). */
47
+ function purlName(purl) {
48
+ try {
49
+ return decodeURIComponent(purl);
50
+ }
51
+ catch {
52
+ throw invalid("the attestation's subject is not a valid package URL");
53
+ }
54
+ }
55
+ function checkSubject(payload, subject) {
56
+ let statement;
57
+ try {
58
+ statement = JSON.parse(payload.toString("utf8"));
59
+ }
60
+ catch {
61
+ throw invalid("the attested statement is not JSON");
62
+ }
63
+ const sha512 = subject.integrity
64
+ .trim()
65
+ .split(/\s+/)
66
+ .find((hash) => hash.startsWith("sha512-"));
67
+ if (sha512 === undefined)
68
+ throw unverifiable("the registry gives no sha512 to compare");
69
+ const digest = b64(sha512.slice("sha512-".length)).toString("hex");
70
+ const want = `pkg:npm/${subject.name}@${subject.version}`;
71
+ const subjects = isRecord(statement) && Array.isArray(statement.subject)
72
+ ? statement.subject
73
+ : [];
74
+ const match = subjects.some((_s, i) => purlName(str(statement, "subject", i, "name")) === want &&
75
+ str(statement, "subject", i, "digest", "sha512") === digest);
76
+ if (!match)
77
+ throw invalid(`the attestation is not about ${subject.name}@${subject.version} with this tarball`);
78
+ }
79
+ function check(bundle, subject, trust) {
80
+ const material = isRecord(bundle) ? bundle.verificationMaterial : undefined;
81
+ const entries = isRecord(material) && Array.isArray(material.tlogEntries)
82
+ ? material.tlogEntries
83
+ : [];
84
+ const logged = readLogEntry(entries[0], trust.rekor);
85
+ const leaf = leafOf(material);
86
+ checkCertificate(leaf, trust.fulcio, logged.loggedAt);
87
+ const type = str(bundle, "dsseEnvelope", "payloadType");
88
+ if (type !== "application/vnd.in-toto+json")
89
+ throw invalid(`the envelope carries ${type}, not an in-toto statement`);
90
+ const payload = b64(str(bundle, "dsseEnvelope", "payload"));
91
+ const sig = str(bundle, "dsseEnvelope", "signatures", 0, "sig");
92
+ // Fulcio also certifies Ed25519 keys; npm's are EC. Others are unchecked.
93
+ const keyType = leaf.publicKey.asymmetricKeyType;
94
+ if (keyType !== "ec" && keyType !== "rsa")
95
+ throw unverifiable(`the signing key is ${keyType ?? "of an unknown type"}`);
96
+ if (!verifies(pae(type, payload), leaf.publicKey, b64(sig)))
97
+ throw invalid("the envelope's signature does not verify");
98
+ if (logged.signature !== sig ||
99
+ !logged.certificateDer.equals(leaf.raw) ||
100
+ logged.payloadHash !== createHash("sha256").update(payload).digest("hex"))
101
+ throw invalid("the transparency log entry is for a different signature");
102
+ checkSubject(payload, subject);
103
+ return {
104
+ status: "verified",
105
+ identity: fulcioIdentity(leaf.raw),
106
+ loggedAt: logged.loggedAt,
107
+ };
108
+ }
109
+ /** Verifies an npm provenance bundle (the SLSA one) for this exact tarball. */
110
+ export function verifyProvenance(bundle, subject, trust = SIGSTORE_PUBLIC_GOOD) {
111
+ try {
112
+ return check(bundle, subject, trust);
113
+ }
114
+ catch (error) {
115
+ if (error instanceof SigstoreError)
116
+ return { status: error.status, reason: error.message };
117
+ if (error instanceof DerError)
118
+ return { status: "invalid", reason: error.message };
119
+ throw error;
120
+ }
121
+ }
@@ -0,0 +1,137 @@
1
+ import { posix } from "node:path";
2
+ import { codeSignals, isSuspicious } from "../signals/code.js";
3
+ import { parseGitConfig } from "../signals/git-config.js";
4
+ import { analyzeShell } from "../signals/shell.js";
5
+ import { isInNodeModules } from "./node-modules-present.js";
6
+ const RULE = "repo/git-config";
7
+ const LFS_FILTER = /^git-lfs (?:clean|smudge|filter-process)(?: -- %f)?$/;
8
+ const NAMED_HELPER = /^[A-Za-z0-9-]+$/;
9
+ const BOOLEAN = new Set([
10
+ "true",
11
+ "false",
12
+ "yes",
13
+ "no",
14
+ "on",
15
+ "off",
16
+ "1",
17
+ "0",
18
+ "",
19
+ ]);
20
+ const MAX_SHOWN = 60;
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
+ function classify({ section, key, value }) {
24
+ const name = `${section.split(".")[0] ?? ""}.${key}`;
25
+ if (section === "core" && key === "fsmonitor") {
26
+ return BOOLEAN.has(value.toLowerCase())
27
+ ? undefined
28
+ : { when: "runs on every git status", severity: "block" };
29
+ }
30
+ if (section === "core" && key === "sshcommand")
31
+ return { when: "runs on every fetch and push", severity: "block" };
32
+ if (section === "core" && ["pager", "editor", "askpass"].includes(key)) {
33
+ return { when: `runs as git's ${key}`, severity: "block" };
34
+ }
35
+ if (name === "sequence.editor" ||
36
+ name === "gpg.program" ||
37
+ (section.startsWith("gpg.") && key === "program")) {
38
+ return { when: "runs during rebase or signing", severity: "block" };
39
+ }
40
+ if (name === "diff.external")
41
+ return { when: "runs on every git diff", severity: "block" };
42
+ if (section.startsWith("filter.") &&
43
+ ["clean", "smudge", "process"].includes(key)) {
44
+ // Git runs filter values through the shell: only the exact LFS command is benign.
45
+ return LFS_FILTER.test(value)
46
+ ? { when: "is a Git LFS filter", severity: "info" }
47
+ : { when: "runs on checkout and add", severity: "block" };
48
+ }
49
+ if (name === "credential.helper") {
50
+ // Only a bare helper name (osxkeychain, store, manager) is benign.
51
+ return NAMED_HELPER.test(value)
52
+ ? { when: "is a named credential helper", severity: "info" }
53
+ : { when: "runs when git asks for credentials", severity: "block" };
54
+ }
55
+ if (section.startsWith("submodule.") &&
56
+ key === "update" &&
57
+ value.startsWith("!")) {
58
+ return { when: "runs on git submodule update", severity: "block" };
59
+ }
60
+ if (section.startsWith("diff.") &&
61
+ (key === "textconv" || key === "command")) {
62
+ return {
63
+ when: "runs on git diff and git log -p for files mapped in .gitattributes",
64
+ severity: "block",
65
+ };
66
+ }
67
+ if (section.startsWith("merge.") && key === "driver")
68
+ return { when: "runs on merges", severity: "block" };
69
+ if (section === "pager")
70
+ return { when: `runs as the pager for git ${key}`, severity: "block" };
71
+ if (section === "core" && key === "gitproxy")
72
+ return { when: "runs on git:// fetches", severity: "block" };
73
+ if (section.startsWith("remote.") &&
74
+ (key === "uploadpack" || key === "receivepack")) {
75
+ return { when: "runs on fetch or push", severity: "block" };
76
+ }
77
+ if ((section.startsWith("difftool.") || section.startsWith("mergetool.")) &&
78
+ key === "cmd") {
79
+ return {
80
+ when: "runs when the diff or merge tool is used",
81
+ severity: "block",
82
+ };
83
+ }
84
+ if (section === "core" && key === "hookspath")
85
+ return { when: "moves git hooks to another directory", severity: "warn" };
86
+ if ((section === "include" || section.startsWith("includeif.")) &&
87
+ key === "path") {
88
+ return { when: "pulls in another config file", severity: "warn" };
89
+ }
90
+ if (section === "alias" && value.startsWith("!"))
91
+ return {
92
+ when: "runs a shell command when the alias is used",
93
+ severity: "warn",
94
+ };
95
+ return undefined;
96
+ }
97
+ function configFindings(repo, path) {
98
+ return parseGitConfig(repo.read(path) ?? "").flatMap((entry) => {
99
+ const rule = classify(entry);
100
+ if (rule === undefined)
101
+ return [];
102
+ return [
103
+ {
104
+ ruleId: RULE,
105
+ severity: rule.severity,
106
+ file: path,
107
+ line: entry.line,
108
+ message: `${entry.section}.${entry.key} = \`${truncate(entry.value)}\` ${rule.when}`,
109
+ },
110
+ ];
111
+ });
112
+ }
113
+ function hookFinding(repo, path) {
114
+ const text = repo.read(path) ?? "";
115
+ const suspicious = analyzeShell(text).downloadExecute ||
116
+ isSuspicious(new Set(codeSignals(text).map((s) => s.signal)));
117
+ return {
118
+ ruleId: RULE,
119
+ severity: suspicious ? "block" : "warn",
120
+ file: path,
121
+ message: `active git hook ${posix.basename(path)} runs automatically on git operations${suspicious ? " and downloads, executes, or exfiltrates" : ""}`,
122
+ };
123
+ }
124
+ const isGitConfig = (path) => path === ".git/config" ||
125
+ path.endsWith("/.git/config") ||
126
+ posix.basename(path) === ".gitmodules";
127
+ 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
+ export const gitConfig = (repo) => repo.entries
130
+ .filter((e) => e.kind === "file" && !isInNodeModules(e.path))
131
+ .flatMap((e) => {
132
+ if (isGitConfig(e.path))
133
+ return configFindings(repo, e.path);
134
+ if (isActiveHook(e.path))
135
+ return [hookFinding(repo, e.path)];
136
+ return [];
137
+ });
@@ -0,0 +1,58 @@
1
+ import { codeSignals } from "../signals/code.js";
2
+ import { hiddenCode } from "../signals/hidden.js";
3
+ import { isInNodeModules } from "./node-modules-present.js";
4
+ const RULE = "repo/hidden-code";
5
+ /** Files a toolchain, IDE, or shell may execute or load. */
6
+ const SCANNED = /\.(?:[cm]?[jt]sx?|json|sh|bash|zsh|py|rb|pl|ps1)$/;
7
+ const MAX_HITS_PER_FILE = 5;
8
+ const SEVERITY_RANK = {
9
+ info: 0,
10
+ warn: 1,
11
+ block: 2,
12
+ };
13
+ const MAX_SHOWN = 60;
14
+ const DESCRIBE = {
15
+ offscreen: "code hidden offscreen after a long run of whitespace",
16
+ vertical: "code hidden below hundreds of blank lines",
17
+ bidi: "Unicode bidi control characters that change how the code reads (Trojan Source)",
18
+ };
19
+ const truncate = (text) => text.length > MAX_SHOWN ? `${text.slice(0, MAX_SHOWN)}…` : text;
20
+ function severityOf(hit, path) {
21
+ if (hit.kind === "bidi") {
22
+ // RTL locale files legitimately contain bidi isolates.
23
+ return path.endsWith(".json") ? "info" : "warn";
24
+ }
25
+ return codeSignals(hit.hidden).length > 0 ? "block" : "warn";
26
+ }
27
+ function finding(hit, path, downgradeInstalled) {
28
+ const signals = [...new Set(codeSignals(hit.hidden).map((s) => s.signal))];
29
+ 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
+ const severity = downgradeInstalled && isInNodeModules(path) && raw === "block"
34
+ ? "warn"
35
+ : raw;
36
+ const detail = hit.kind === "bidi" ? "" : `: \`${truncate(hit.hidden)}\``;
37
+ const using = signals.length > 0 ? ` (uses ${signals.join(", ")})` : "";
38
+ return {
39
+ ruleId: RULE,
40
+ severity,
41
+ file: path,
42
+ line: hit.line,
43
+ message: `${DESCRIBE[hit.kind]} at line ${hit.line}, column ${hit.column}${detail}${using}`,
44
+ };
45
+ }
46
+ /** Code placed where a human reviewing the file will not see it. */
47
+ export function hiddenCodeWith({ downgradeInstalled, }) {
48
+ return (repo) => repo.entries
49
+ .filter((e) => e.kind === "file" && SCANNED.test(e.path))
50
+ .flatMap((e) => hiddenCode(repo.read(e.path) ?? "")
51
+ .map((hit) => finding(hit, e.path, downgradeInstalled))
52
+ // Most severe first, so harmless decoys cannot push the payload past the cap.
53
+ .sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity])
54
+ .slice(0, MAX_HITS_PER_FILE));
55
+ }
56
+ export const hiddenCodeDetector = hiddenCodeWith({
57
+ downgradeInstalled: true,
58
+ });