@westbayberry/dg 1.3.3 → 2.0.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 +1 -201
- package/NOTICE +1 -4
- package/README.md +293 -0
- package/dist/api/analyze.js +210 -0
- package/dist/audit/deep.js +180 -0
- package/dist/audit/detectors.js +247 -0
- package/dist/audit/events.js +41 -0
- package/dist/audit/rules.js +426 -0
- package/dist/audit-ui/AuditApp.js +39 -0
- package/dist/audit-ui/components/AuditHeader.js +24 -0
- package/dist/audit-ui/components/AuditResultsView.js +307 -0
- package/dist/audit-ui/components/DeepStatusRow.js +11 -0
- package/dist/audit-ui/export.js +85 -0
- package/dist/audit-ui/format.js +34 -0
- package/dist/audit-ui/launch.js +34 -0
- package/dist/auth/device-login.js +271 -0
- package/dist/auth/env-token.js +6 -0
- package/dist/auth/login-app.js +156 -0
- package/dist/auth/store.js +147 -0
- package/dist/bin/dg.js +71 -0
- package/dist/commands/audit.js +362 -0
- package/dist/commands/completion.js +116 -0
- package/dist/commands/config.js +99 -0
- package/dist/commands/doctor.js +39 -0
- package/dist/commands/explain.js +100 -0
- package/dist/commands/guard-commit.js +158 -0
- package/dist/commands/help.js +74 -0
- package/dist/commands/licenses.js +435 -0
- package/dist/commands/login.js +81 -0
- package/dist/commands/logout.js +37 -0
- package/dist/commands/router.js +98 -0
- package/dist/commands/scan.js +18 -0
- package/dist/commands/service.js +475 -0
- package/dist/commands/setup.js +302 -0
- package/dist/commands/status.js +115 -0
- package/dist/commands/suggest.js +35 -0
- package/dist/commands/types.js +4 -0
- package/dist/commands/unavailable.js +11 -0
- package/dist/commands/uninstall.js +111 -0
- package/dist/commands/update.js +210 -0
- package/dist/commands/verify.js +151 -0
- package/dist/commands/version.js +22 -0
- package/dist/commands/wrap.js +55 -0
- package/dist/config/settings.js +302 -0
- package/dist/install-ui/LiveInstall.js +24 -0
- package/dist/install-ui/block-render.js +85 -0
- package/dist/install-ui/live-install-app.js +48 -0
- package/dist/install-ui/prompt.js +24 -0
- package/dist/launcher/classify.js +116 -0
- package/dist/launcher/env.js +53 -0
- package/dist/launcher/live-install.js +50 -0
- package/dist/launcher/output-redaction.js +77 -0
- package/dist/launcher/preflight-prompt.js +139 -0
- package/dist/launcher/resolve-real-binary.js +73 -0
- package/dist/launcher/run.js +417 -0
- package/dist/policy/evaluate.js +128 -0
- package/dist/presentation/mode.js +52 -0
- package/dist/presentation/theme.js +29 -0
- package/dist/proxy/buffer-budget.js +64 -0
- package/dist/proxy/ca.js +126 -0
- package/dist/proxy/classify-host.js +26 -0
- package/dist/proxy/enforcement.js +102 -0
- package/dist/proxy/metadata-map.js +336 -0
- package/dist/proxy/server.js +919 -0
- package/dist/proxy/upstream-proxy.js +102 -0
- package/dist/proxy/worker.js +39 -0
- package/dist/publish-set/collect.js +51 -0
- package/dist/publish-set/no-exec-shell.js +19 -0
- package/dist/publish-set/npm.js +109 -0
- package/dist/publish-set/pack.js +36 -0
- package/dist/publish-set/pypi.js +59 -0
- package/dist/runtime/cli.js +17 -0
- package/dist/runtime/first-run.js +60 -0
- package/dist/runtime/node-version.js +58 -0
- package/dist/runtime/nudges.js +105 -0
- package/dist/scan/analyze-worker.js +21 -0
- package/dist/scan/collect.js +153 -0
- package/dist/scan/command.js +159 -0
- package/dist/scan/discovery.js +209 -0
- package/dist/scan/render.js +240 -0
- package/dist/scan/scanner-report.js +82 -0
- package/dist/scan/staged.js +173 -0
- package/dist/scan/types.js +1 -0
- package/dist/scan-ui/LegacyApp.js +156 -0
- package/dist/scan-ui/alt-screen.js +84 -0
- package/dist/scan-ui/api-aliases.js +1 -0
- package/dist/scan-ui/components/ErrorView.js +23 -0
- package/dist/scan-ui/components/InteractiveResultsView.js +1179 -0
- package/dist/scan-ui/components/ProgressBar.js +89 -0
- package/dist/scan-ui/components/ProjectSelector.js +62 -0
- package/dist/scan-ui/components/ScoreHeader.js +20 -0
- package/dist/scan-ui/components/SetupBanner.js +13 -0
- package/dist/scan-ui/components/Spinner.js +4 -0
- package/dist/scan-ui/format-helpers.js +40 -0
- package/dist/scan-ui/hooks/useExpandAnimation.js +40 -0
- package/dist/scan-ui/hooks/useScan.js +113 -0
- package/dist/scan-ui/hooks/useTerminalSize.js +24 -0
- package/dist/scan-ui/launch.js +27 -0
- package/dist/scan-ui/logo.js +91 -0
- package/dist/scan-ui/shims.js +30 -0
- package/dist/security/sanitize.js +28 -0
- package/dist/service/state.js +837 -0
- package/dist/service/trust-store.js +234 -0
- package/dist/service/worker.js +88 -0
- package/dist/setup/git-hook.js +244 -0
- package/dist/setup/optional-support.js +58 -0
- package/dist/setup/plan.js +935 -0
- package/dist/state/cleanup-registry.js +60 -0
- package/dist/state/index.js +5 -0
- package/dist/state/locks.js +161 -0
- package/dist/state/paths.js +24 -0
- package/dist/state/sessions.js +170 -0
- package/dist/state/store.js +50 -0
- package/dist/telemetry/events.js +40 -0
- package/dist/util/git.js +20 -0
- package/dist/util/tty-prompt.js +43 -0
- package/dist/verify/local.js +400 -0
- package/dist/verify/package-check.js +240 -0
- package/dist/verify/preflight.js +698 -0
- package/dist/verify/render.js +184 -0
- package/dist/verify/types.js +1 -0
- package/package.json +33 -50
- package/dist/index.mjs +0 -54116
- package/dist/postinstall.mjs +0 -731
- package/dist/python-hook/dg_pip_hook.pth +0 -1
- package/dist/python-hook/dg_pip_hook.py +0 -130
package/dist/proxy/ca.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
import forge from "node-forge";
|
|
6
|
+
export function createEphemeralCertificateAuthority(caPath) {
|
|
7
|
+
const keys = forge.pki.rsa.generateKeyPair({
|
|
8
|
+
bits: 2048,
|
|
9
|
+
workers: -1
|
|
10
|
+
});
|
|
11
|
+
const cert = forge.pki.createCertificate();
|
|
12
|
+
cert.publicKey = keys.publicKey;
|
|
13
|
+
cert.serialNumber = serialNumber();
|
|
14
|
+
cert.validity.notBefore = new Date(Date.now() - 60_000);
|
|
15
|
+
cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1_000);
|
|
16
|
+
const attrs = [{
|
|
17
|
+
name: "commonName",
|
|
18
|
+
value: "Dependency Guardian per-session proxy CA"
|
|
19
|
+
}];
|
|
20
|
+
cert.setSubject(attrs);
|
|
21
|
+
cert.setIssuer(attrs);
|
|
22
|
+
cert.setExtensions([
|
|
23
|
+
{
|
|
24
|
+
name: "basicConstraints",
|
|
25
|
+
cA: true,
|
|
26
|
+
critical: true
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: "keyUsage",
|
|
30
|
+
keyCertSign: true,
|
|
31
|
+
cRLSign: true,
|
|
32
|
+
critical: true
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "subjectKeyIdentifier"
|
|
36
|
+
}
|
|
37
|
+
]);
|
|
38
|
+
cert.sign(keys.privateKey, forge.md.sha256.create());
|
|
39
|
+
const caCertPem = forge.pki.certificateToPem(cert);
|
|
40
|
+
mkdirSync(dirname(caPath), {
|
|
41
|
+
recursive: true,
|
|
42
|
+
mode: 0o700
|
|
43
|
+
});
|
|
44
|
+
writeFileSync(caPath, caCertPem, {
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
mode: 0o600
|
|
47
|
+
});
|
|
48
|
+
const leafs = new Map();
|
|
49
|
+
return {
|
|
50
|
+
caCertPem,
|
|
51
|
+
caPath,
|
|
52
|
+
leafForHost(host) {
|
|
53
|
+
const normalized = normalizeHost(host);
|
|
54
|
+
const existing = leafs.get(normalized);
|
|
55
|
+
if (existing) {
|
|
56
|
+
return existing;
|
|
57
|
+
}
|
|
58
|
+
const leaf = createLeafCertificate(normalized, cert, keys.privateKey);
|
|
59
|
+
leafs.set(normalized, leaf);
|
|
60
|
+
return leaf;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function createLeafCertificate(host, issuerCert, issuerKey) {
|
|
65
|
+
const keys = forge.pki.rsa.generateKeyPair({
|
|
66
|
+
bits: 2048,
|
|
67
|
+
workers: -1
|
|
68
|
+
});
|
|
69
|
+
const cert = forge.pki.createCertificate();
|
|
70
|
+
cert.publicKey = keys.publicKey;
|
|
71
|
+
cert.serialNumber = serialNumber();
|
|
72
|
+
cert.validity.notBefore = new Date(Date.now() - 60_000);
|
|
73
|
+
cert.validity.notAfter = new Date(Date.now() + 24 * 60 * 60 * 1_000);
|
|
74
|
+
cert.setSubject([{
|
|
75
|
+
name: "commonName",
|
|
76
|
+
value: host
|
|
77
|
+
}]);
|
|
78
|
+
cert.setIssuer(issuerCert.subject.attributes);
|
|
79
|
+
cert.setExtensions([
|
|
80
|
+
{
|
|
81
|
+
name: "basicConstraints",
|
|
82
|
+
cA: false,
|
|
83
|
+
critical: true
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "keyUsage",
|
|
87
|
+
digitalSignature: true,
|
|
88
|
+
keyEncipherment: true,
|
|
89
|
+
critical: true
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "extKeyUsage",
|
|
93
|
+
serverAuth: true
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "subjectAltName",
|
|
97
|
+
altNames: subjectAlternativeNames(host)
|
|
98
|
+
}
|
|
99
|
+
]);
|
|
100
|
+
cert.sign(issuerKey, forge.md.sha256.create());
|
|
101
|
+
return {
|
|
102
|
+
certPem: forge.pki.certificateToPem(cert),
|
|
103
|
+
keyPem: forge.pki.privateKeyToPem(keys.privateKey)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function subjectAlternativeNames(host) {
|
|
107
|
+
const ipVersion = isIP(host);
|
|
108
|
+
if (ipVersion !== 0) {
|
|
109
|
+
return [{
|
|
110
|
+
type: 7,
|
|
111
|
+
ip: host
|
|
112
|
+
}];
|
|
113
|
+
}
|
|
114
|
+
return [{
|
|
115
|
+
type: 2,
|
|
116
|
+
value: host
|
|
117
|
+
}];
|
|
118
|
+
}
|
|
119
|
+
function normalizeHost(host) {
|
|
120
|
+
return host.replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "").toLowerCase();
|
|
121
|
+
}
|
|
122
|
+
function serialNumber() {
|
|
123
|
+
const bytes = randomBytes(16);
|
|
124
|
+
bytes[0] = (bytes[0] ?? 0) & 0x7f;
|
|
125
|
+
return bytes.toString("hex");
|
|
126
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const DEFAULT_MITM_PATTERNS = [
|
|
2
|
+
"registry.npmjs.org",
|
|
3
|
+
"*.npmjs.org",
|
|
4
|
+
"registry.yarnpkg.com",
|
|
5
|
+
"pypi.org",
|
|
6
|
+
"files.pythonhosted.org",
|
|
7
|
+
"crates.io",
|
|
8
|
+
"static.crates.io",
|
|
9
|
+
"index.crates.io"
|
|
10
|
+
];
|
|
11
|
+
export function shouldMitmHost(host, env) {
|
|
12
|
+
const normalized = normalizeHost(host);
|
|
13
|
+
const configured = (env.DG_PROXY_MITM_HOSTS ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
14
|
+
return [...DEFAULT_MITM_PATTERNS, ...configured].some((pattern) => hostMatchesPattern(normalized, pattern));
|
|
15
|
+
}
|
|
16
|
+
function hostMatchesPattern(host, pattern) {
|
|
17
|
+
const normalized = normalizeHost(pattern);
|
|
18
|
+
if (normalized.startsWith("*.")) {
|
|
19
|
+
const suffix = normalized.slice(1);
|
|
20
|
+
return host.endsWith(suffix) && host.length > suffix.length;
|
|
21
|
+
}
|
|
22
|
+
return host === normalized;
|
|
23
|
+
}
|
|
24
|
+
function normalizeHost(host) {
|
|
25
|
+
return host.replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, "").toLowerCase();
|
|
26
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { recordAuditEvent } from "../audit/events.js";
|
|
2
|
+
import { loadUserConfig } from "../config/settings.js";
|
|
3
|
+
import { applyForceOverride, evaluatePackagePolicy, resolveEffectivePolicy } from "../policy/evaluate.js";
|
|
4
|
+
export function parseForceOverrideRequest(raw) {
|
|
5
|
+
if (!raw) {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(raw);
|
|
10
|
+
if (parsed.force !== true) {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
return { force: true };
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
export function enforceProtectedInstall(request) {
|
|
20
|
+
const userConfig = request.userConfig ?? loadUserConfig(request.env);
|
|
21
|
+
const policy = resolveEffectivePolicy({ userConfig });
|
|
22
|
+
const proxyVerdict = request.proxyVerdict ?? failClosedVerdict(request.classification);
|
|
23
|
+
const packageName = proxyVerdict.packageName ?? derivePackageName(request.classification);
|
|
24
|
+
const evaluation = evaluatePackagePolicy({
|
|
25
|
+
verdict: proxyVerdict.verdict,
|
|
26
|
+
packageName,
|
|
27
|
+
policy
|
|
28
|
+
});
|
|
29
|
+
const baseDecision = withOptionalDecisionFields({
|
|
30
|
+
action: evaluation.action,
|
|
31
|
+
cause: proxyVerdict.cause ?? causeFromVerdict(proxyVerdict.verdict, evaluation.action),
|
|
32
|
+
packageName,
|
|
33
|
+
policyMode: policy.mode,
|
|
34
|
+
reason: proxyVerdict.reason ?? evaluation.reason
|
|
35
|
+
}, proxyVerdict);
|
|
36
|
+
if (baseDecision.action !== "block") {
|
|
37
|
+
return baseDecision;
|
|
38
|
+
}
|
|
39
|
+
const force = applyForceOverride({
|
|
40
|
+
packageName,
|
|
41
|
+
currentAction: "block",
|
|
42
|
+
force: request.forceOverride?.force ?? false,
|
|
43
|
+
policy,
|
|
44
|
+
...(request.now ? { now: request.now } : {})
|
|
45
|
+
}, request.env);
|
|
46
|
+
if (force.allowed) {
|
|
47
|
+
return {
|
|
48
|
+
...baseDecision,
|
|
49
|
+
action: "warn",
|
|
50
|
+
reason: `force override allowed: ${force.reason}`,
|
|
51
|
+
forceOverride: {
|
|
52
|
+
allowed: true,
|
|
53
|
+
reason: force.reason
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
recordAuditEvent({
|
|
58
|
+
type: "install.blocked",
|
|
59
|
+
packageName,
|
|
60
|
+
reason: baseDecision.reason,
|
|
61
|
+
policyMode: policy.mode,
|
|
62
|
+
createdAt: (request.now ?? new Date()).toISOString()
|
|
63
|
+
}, request.env);
|
|
64
|
+
if (!request.forceOverride?.force) {
|
|
65
|
+
return baseDecision;
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...baseDecision,
|
|
69
|
+
forceOverride: {
|
|
70
|
+
allowed: false,
|
|
71
|
+
reason: force.reason
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function failClosedVerdict(classification) {
|
|
76
|
+
return {
|
|
77
|
+
verdict: "block",
|
|
78
|
+
packageName: derivePackageName(classification),
|
|
79
|
+
cause: "proxy-setup-failure",
|
|
80
|
+
reason: "per-invocation proxy enforcement is not available, so protected installs fail closed"
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function derivePackageName(classification) {
|
|
84
|
+
const spec = classification.args.find((arg) => !arg.startsWith("-") && arg !== classification.action);
|
|
85
|
+
return spec ?? `${classification.manager}:${classification.action || "install"}`;
|
|
86
|
+
}
|
|
87
|
+
function causeFromVerdict(verdict, action) {
|
|
88
|
+
if (verdict === "pass" && action === "pass") {
|
|
89
|
+
return "pass";
|
|
90
|
+
}
|
|
91
|
+
if (action === "warn") {
|
|
92
|
+
return "warn";
|
|
93
|
+
}
|
|
94
|
+
return "policy";
|
|
95
|
+
}
|
|
96
|
+
function withOptionalDecisionFields(decision, verdict) {
|
|
97
|
+
return {
|
|
98
|
+
...decision,
|
|
99
|
+
...(verdict.dashboardUrl ? { dashboardUrl: verdict.dashboardUrl } : {}),
|
|
100
|
+
...(verdict.unauthenticated ? { unauthenticated: true } : {})
|
|
101
|
+
};
|
|
102
|
+
}
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function extractRegistryMetadataIdentities(metadataUrl, response) {
|
|
3
|
+
// PyPI Simple index (PEP 503 HTML or PEP 691 JSON) — the pip/uv/pipx flow.
|
|
4
|
+
if (isPypiSimpleIndexUrl(metadataUrl)) {
|
|
5
|
+
return extractPypiSimpleIdentities(metadataUrl, response);
|
|
6
|
+
}
|
|
7
|
+
if (!looksLikeJson(response.headers["content-type"])) {
|
|
8
|
+
return [];
|
|
9
|
+
}
|
|
10
|
+
const parsed = parseJson(response.body);
|
|
11
|
+
if (!isRecord(parsed)) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
return [
|
|
15
|
+
...extractNpmIdentities(metadataUrl, parsed),
|
|
16
|
+
...extractPypiIdentities(metadataUrl, parsed)
|
|
17
|
+
];
|
|
18
|
+
}
|
|
19
|
+
// A registry INDEX/metadata request (not an artifact download). These must be
|
|
20
|
+
// passed through untouched — never verified as a package — so e.g. pip's
|
|
21
|
+
// `pypi.org/simple/<pkg>/` index is not mistaken for a package named "simple".
|
|
22
|
+
// PEP 658 `.metadata` sidecars are dist-info metadata pip fetches for dependency
|
|
23
|
+
// resolution; the actual wheel/sdist download is what gets verified. npm reserves
|
|
24
|
+
// the `/-/` path prefix for registry API endpoints (security/advisories, audits,
|
|
25
|
+
// `-/v1/search`) which npm hits during install — those are never package tarballs
|
|
26
|
+
// (tarballs live at `/<pkg>/-/<file>.tgz`, where `/-/` is not at the path start).
|
|
27
|
+
export function isRegistryIndexRequest(url) {
|
|
28
|
+
return (isPypiSimpleIndexUrl(url) ||
|
|
29
|
+
/\/pypi\/[^/]+\/json\/?$/i.test(url.pathname) ||
|
|
30
|
+
/\.metadata$/i.test(url.pathname) ||
|
|
31
|
+
/^\/-\//.test(url.pathname));
|
|
32
|
+
}
|
|
33
|
+
function isPypiSimpleIndexUrl(url) {
|
|
34
|
+
return /^\/simple(\/|$)/i.test(url.pathname);
|
|
35
|
+
}
|
|
36
|
+
function extractPypiSimpleIdentities(metadataUrl, response) {
|
|
37
|
+
const entries = [];
|
|
38
|
+
if (looksLikeJson(response.headers["content-type"])) {
|
|
39
|
+
const parsed = parseJson(response.body);
|
|
40
|
+
const files = isRecord(parsed) && Array.isArray(parsed.files) ? parsed.files : [];
|
|
41
|
+
for (const file of files) {
|
|
42
|
+
if (isRecord(file) && typeof file.url === "string" && file.url.length > 0) {
|
|
43
|
+
entries.push(typeof file.filename === "string" && file.filename.length > 0
|
|
44
|
+
? { href: file.url, filename: file.filename }
|
|
45
|
+
: { href: file.url });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const html = response.body.toString("utf8");
|
|
51
|
+
const linkRe = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
|
|
52
|
+
let match;
|
|
53
|
+
while ((match = linkRe.exec(html)) !== null) {
|
|
54
|
+
if (match[1]) {
|
|
55
|
+
entries.push({ href: match[1] });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const identities = [];
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
let absolute;
|
|
62
|
+
try {
|
|
63
|
+
absolute = new URL(entry.href, metadataUrl);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const file = entry.filename ?? decodeURIComponent(absolute.pathname.split("/").filter(Boolean).at(-1) ?? "");
|
|
69
|
+
const parsed = parsePypiArtifactFilename(file);
|
|
70
|
+
if (!parsed) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
identities.push({
|
|
74
|
+
ecosystem: "pypi",
|
|
75
|
+
name: parsed.name,
|
|
76
|
+
version: parsed.version,
|
|
77
|
+
registryHost: absolute.hostname,
|
|
78
|
+
tarballUrl: artifactUrlKey(absolute),
|
|
79
|
+
sourceKind: "registry-metadata"
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return identities;
|
|
83
|
+
}
|
|
84
|
+
// Parse a PyPI wheel (PEP 427) or sdist filename into {name, version}. Wheel and
|
|
85
|
+
// sdist names/versions never contain "-" (it is escaped to "_"), so a wheel is a
|
|
86
|
+
// clean dash-split and an sdist is name + a digit-leading version.
|
|
87
|
+
function parsePypiArtifactFilename(file) {
|
|
88
|
+
if (/\.whl$/i.test(file)) {
|
|
89
|
+
const parts = file.replace(/\.whl$/i, "").split("-");
|
|
90
|
+
if (parts.length >= 5 && parts[0] && parts[1]) {
|
|
91
|
+
return { name: normalizePypiName(parts[0]), version: parts[1] };
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const sdistExt = /\.(?:tar\.gz|tgz|zip|tar\.bz2|tar\.xz)$/i.exec(file);
|
|
96
|
+
if (sdistExt) {
|
|
97
|
+
const stem = file.slice(0, file.length - sdistExt[0].length);
|
|
98
|
+
const match = /^(.+)-(\d[A-Za-z0-9._!+]*)$/.exec(stem);
|
|
99
|
+
if (match && match[1] && match[2]) {
|
|
100
|
+
return { name: normalizePypiName(match[1]), version: match[2] };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
function normalizePypiName(name) {
|
|
106
|
+
return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
107
|
+
}
|
|
108
|
+
export function resolveArtifactIdentity(artifactUrl, identities, classification) {
|
|
109
|
+
const key = artifactUrlKey(artifactUrl);
|
|
110
|
+
const matches = identities.filter((identity) => artifactUrlKey(identity.tarballUrl) === key);
|
|
111
|
+
const unique = dedupeIdentities(matches);
|
|
112
|
+
if (unique.length === 1) {
|
|
113
|
+
const identity = unique[0];
|
|
114
|
+
if (!identity) {
|
|
115
|
+
throw new Error("artifact identity resolution was empty");
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
kind: "resolved",
|
|
119
|
+
identity
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (unique.length > 1) {
|
|
123
|
+
const first = unique[0];
|
|
124
|
+
if (!first) {
|
|
125
|
+
throw new Error("artifact identity ambiguity was empty");
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
kind: "ambiguous",
|
|
129
|
+
packageName: artifactDisplayName(first),
|
|
130
|
+
reason: `ambiguous artifact identity for ${redactedUrl(artifactUrl)} from registry metadata`
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return {
|
|
134
|
+
kind: "resolved",
|
|
135
|
+
identity: fallbackIdentity(artifactUrl, classification)
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
export function artifactDisplayName(identity) {
|
|
139
|
+
return `${identity.ecosystem}:${identity.name}@${identity.version}`;
|
|
140
|
+
}
|
|
141
|
+
export function artifactUrlHash(url) {
|
|
142
|
+
return createHash("sha256").update(artifactUrlKey(url)).digest("hex");
|
|
143
|
+
}
|
|
144
|
+
export function artifactUrlKey(value) {
|
|
145
|
+
const url = typeof value === "string" ? new URL(value) : new URL(value.toString());
|
|
146
|
+
url.username = "";
|
|
147
|
+
url.password = "";
|
|
148
|
+
url.hash = "";
|
|
149
|
+
return url.toString();
|
|
150
|
+
}
|
|
151
|
+
function extractNpmIdentities(metadataUrl, parsed) {
|
|
152
|
+
const packageName = typeof parsed.name === "string" ? parsed.name : packageNameFromNpmMetadataPath(metadataUrl);
|
|
153
|
+
const versions = isRecord(parsed.versions) ? parsed.versions : {};
|
|
154
|
+
const identities = [];
|
|
155
|
+
for (const [version, rawVersion] of Object.entries(versions)) {
|
|
156
|
+
if (!isRecord(rawVersion) || typeof version !== "string") {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const dist = isRecord(rawVersion.dist) ? rawVersion.dist : {};
|
|
160
|
+
if (typeof dist.tarball !== "string" || dist.tarball.length === 0) {
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
identities.push({
|
|
164
|
+
ecosystem: "npm",
|
|
165
|
+
name: packageName,
|
|
166
|
+
version,
|
|
167
|
+
registryHost: metadataUrl.hostname,
|
|
168
|
+
tarballUrl: artifactUrlKey(new URL(dist.tarball, metadataUrl)),
|
|
169
|
+
sourceKind: "registry-metadata"
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
return identities;
|
|
173
|
+
}
|
|
174
|
+
function extractPypiIdentities(metadataUrl, parsed) {
|
|
175
|
+
const info = isRecord(parsed.info) ? parsed.info : {};
|
|
176
|
+
const packageName = typeof info.name === "string" && info.name.length > 0
|
|
177
|
+
? info.name
|
|
178
|
+
: packageNameFromPypiMetadataPath(metadataUrl);
|
|
179
|
+
const releases = isRecord(parsed.releases) ? parsed.releases : {};
|
|
180
|
+
const identities = [];
|
|
181
|
+
for (const [version, rawFiles] of Object.entries(releases)) {
|
|
182
|
+
if (!Array.isArray(rawFiles)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
for (const rawFile of rawFiles) {
|
|
186
|
+
if (!isRecord(rawFile) || typeof rawFile.url !== "string" || rawFile.url.length === 0) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
identities.push({
|
|
190
|
+
ecosystem: "pypi",
|
|
191
|
+
name: packageName,
|
|
192
|
+
version,
|
|
193
|
+
registryHost: metadataUrl.hostname,
|
|
194
|
+
tarballUrl: artifactUrlKey(new URL(rawFile.url, metadataUrl)),
|
|
195
|
+
sourceKind: "registry-metadata"
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return identities;
|
|
200
|
+
}
|
|
201
|
+
function fallbackIdentity(artifactUrl, classification) {
|
|
202
|
+
const ecosystem = ecosystemForManager(classification.manager);
|
|
203
|
+
const parsed = ecosystem === "pypi"
|
|
204
|
+
? parsePypiArtifactFilename(decodeURIComponent(artifactUrl.pathname.split("/").filter(Boolean).at(-1) ?? "")) ?? parsePackageVersionFromUrl(artifactUrl)
|
|
205
|
+
: parsePackageVersionFromUrl(artifactUrl);
|
|
206
|
+
const requested = requestedIdentityFromArgs(classification, parsed.name);
|
|
207
|
+
return {
|
|
208
|
+
ecosystem,
|
|
209
|
+
name: requested?.name ?? parsed.name,
|
|
210
|
+
version: requested?.version ?? parsed.version,
|
|
211
|
+
registryHost: artifactUrl.hostname,
|
|
212
|
+
tarballUrl: artifactUrlKey(artifactUrl),
|
|
213
|
+
sourceKind: "url-fallback"
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function parsePackageVersionFromUrl(artifactUrl) {
|
|
217
|
+
const parts = artifactUrl.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
|
|
218
|
+
const file = parts.at(-1) ?? artifactUrl.hostname;
|
|
219
|
+
const npmPackage = npmPackageFromTarballPath(parts);
|
|
220
|
+
const packageName = npmPackage ?? parts.at(-2) ?? file.replace(/\.(?:tgz|tar\.gz|zip|whl)$/i, "");
|
|
221
|
+
const filePackageName = packageName.split("/").at(-1) ?? packageName;
|
|
222
|
+
const escaped = filePackageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
223
|
+
const versionMatch = new RegExp(`${escaped}[-_]v?([^/]+?)\\.(?:tgz|tar\\.gz|zip|whl)$`, "i").exec(file);
|
|
224
|
+
return {
|
|
225
|
+
name: packageName || artifactUrl.hostname,
|
|
226
|
+
version: versionMatch?.[1] ?? "unknown"
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function npmPackageFromTarballPath(parts) {
|
|
230
|
+
const marker = parts.indexOf("-");
|
|
231
|
+
if (marker <= 0) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
const beforeMarker = parts.slice(0, marker);
|
|
235
|
+
const name = beforeMarker.at(-1);
|
|
236
|
+
const scope = beforeMarker.at(-2);
|
|
237
|
+
if (!name) {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
if (scope?.startsWith("@")) {
|
|
241
|
+
return `${scope}/${name}`;
|
|
242
|
+
}
|
|
243
|
+
return name;
|
|
244
|
+
}
|
|
245
|
+
function requestedIdentityFromArgs(classification, parsedName) {
|
|
246
|
+
if (classification.ecosystem !== "javascript") {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
const specs = classification.args
|
|
250
|
+
.filter((arg) => !arg.startsWith("-") && arg !== classification.action)
|
|
251
|
+
.map(parseNpmPackageSpec)
|
|
252
|
+
.filter((spec) => spec !== undefined);
|
|
253
|
+
if (specs.length !== 1) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
const [spec] = specs;
|
|
257
|
+
if (!spec || spec.name !== parsedName) {
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
return spec;
|
|
261
|
+
}
|
|
262
|
+
function parseNpmPackageSpec(spec) {
|
|
263
|
+
if (spec.length === 0 || spec === "." || spec.startsWith("file:") || /^https?:\/\//.test(spec)) {
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
|
266
|
+
const withoutAlias = spec.includes("@npm:") ? spec.slice(spec.indexOf("@npm:") + 5) : spec;
|
|
267
|
+
const versionSeparator = withoutAlias.lastIndexOf("@");
|
|
268
|
+
if (versionSeparator <= 0) {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
const name = withoutAlias.slice(0, versionSeparator);
|
|
272
|
+
const version = withoutAlias.slice(versionSeparator + 1);
|
|
273
|
+
if (!name || !version || name === version || /[*xX]|\|\||[<>=~^]/.test(version)) {
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
return { name, version };
|
|
277
|
+
}
|
|
278
|
+
function packageNameFromNpmMetadataPath(metadataUrl) {
|
|
279
|
+
const parts = metadataUrl.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
|
|
280
|
+
if (parts[0]?.startsWith("@") && parts[1]) {
|
|
281
|
+
return `${parts[0]}/${parts[1]}`;
|
|
282
|
+
}
|
|
283
|
+
return parts[0] ?? metadataUrl.hostname;
|
|
284
|
+
}
|
|
285
|
+
function packageNameFromPypiMetadataPath(metadataUrl) {
|
|
286
|
+
const parts = metadataUrl.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
|
|
287
|
+
const projectIndex = parts.findIndex((part) => part.toLowerCase() === "pypi");
|
|
288
|
+
return parts[projectIndex + 1] ?? parts[0] ?? metadataUrl.hostname;
|
|
289
|
+
}
|
|
290
|
+
function ecosystemForManager(manager) {
|
|
291
|
+
if (manager === "npm" || manager === "npx" || manager === "pnpm" || manager === "pnpx" || manager === "yarn") {
|
|
292
|
+
return "npm";
|
|
293
|
+
}
|
|
294
|
+
if (manager === "pip" || manager === "pipx" || manager === "uv" || manager === "uvx") {
|
|
295
|
+
return "pypi";
|
|
296
|
+
}
|
|
297
|
+
if (manager === "cargo") {
|
|
298
|
+
return "cargo";
|
|
299
|
+
}
|
|
300
|
+
return "unknown";
|
|
301
|
+
}
|
|
302
|
+
function dedupeIdentities(identities) {
|
|
303
|
+
const seen = new Set();
|
|
304
|
+
const unique = [];
|
|
305
|
+
for (const identity of identities) {
|
|
306
|
+
const key = `${identity.ecosystem}\0${identity.name}\0${identity.version}\0${identity.registryHost}`;
|
|
307
|
+
if (!seen.has(key)) {
|
|
308
|
+
seen.add(key);
|
|
309
|
+
unique.push(identity);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return unique;
|
|
313
|
+
}
|
|
314
|
+
function redactedUrl(url) {
|
|
315
|
+
const copy = new URL(url.toString());
|
|
316
|
+
if (copy.username || copy.password) {
|
|
317
|
+
copy.username = "<redacted>";
|
|
318
|
+
copy.password = "";
|
|
319
|
+
}
|
|
320
|
+
return copy.toString();
|
|
321
|
+
}
|
|
322
|
+
function looksLikeJson(value) {
|
|
323
|
+
const header = Array.isArray(value) ? value.join(",") : String(value ?? "");
|
|
324
|
+
return /\bjson\b/i.test(header);
|
|
325
|
+
}
|
|
326
|
+
function parseJson(body) {
|
|
327
|
+
try {
|
|
328
|
+
return JSON.parse(body.toString("utf8"));
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
function isRecord(value) {
|
|
335
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
336
|
+
}
|