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