@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.
Files changed (126) hide show
  1. package/LICENSE +1 -201
  2. package/NOTICE +1 -4
  3. package/README.md +293 -0
  4. package/dist/api/analyze.js +210 -0
  5. package/dist/audit/deep.js +180 -0
  6. package/dist/audit/detectors.js +247 -0
  7. package/dist/audit/events.js +41 -0
  8. package/dist/audit/rules.js +426 -0
  9. package/dist/audit-ui/AuditApp.js +39 -0
  10. package/dist/audit-ui/components/AuditHeader.js +24 -0
  11. package/dist/audit-ui/components/AuditResultsView.js +307 -0
  12. package/dist/audit-ui/components/DeepStatusRow.js +11 -0
  13. package/dist/audit-ui/export.js +85 -0
  14. package/dist/audit-ui/format.js +34 -0
  15. package/dist/audit-ui/launch.js +34 -0
  16. package/dist/auth/device-login.js +271 -0
  17. package/dist/auth/env-token.js +6 -0
  18. package/dist/auth/login-app.js +156 -0
  19. package/dist/auth/store.js +147 -0
  20. package/dist/bin/dg.js +71 -0
  21. package/dist/commands/audit.js +362 -0
  22. package/dist/commands/completion.js +116 -0
  23. package/dist/commands/config.js +99 -0
  24. package/dist/commands/doctor.js +39 -0
  25. package/dist/commands/explain.js +100 -0
  26. package/dist/commands/guard-commit.js +158 -0
  27. package/dist/commands/help.js +74 -0
  28. package/dist/commands/licenses.js +435 -0
  29. package/dist/commands/login.js +81 -0
  30. package/dist/commands/logout.js +37 -0
  31. package/dist/commands/router.js +98 -0
  32. package/dist/commands/scan.js +18 -0
  33. package/dist/commands/service.js +475 -0
  34. package/dist/commands/setup.js +302 -0
  35. package/dist/commands/status.js +115 -0
  36. package/dist/commands/suggest.js +35 -0
  37. package/dist/commands/types.js +4 -0
  38. package/dist/commands/unavailable.js +11 -0
  39. package/dist/commands/uninstall.js +111 -0
  40. package/dist/commands/update.js +210 -0
  41. package/dist/commands/verify.js +151 -0
  42. package/dist/commands/version.js +22 -0
  43. package/dist/commands/wrap.js +55 -0
  44. package/dist/config/settings.js +302 -0
  45. package/dist/install-ui/LiveInstall.js +24 -0
  46. package/dist/install-ui/block-render.js +85 -0
  47. package/dist/install-ui/live-install-app.js +48 -0
  48. package/dist/install-ui/prompt.js +24 -0
  49. package/dist/launcher/classify.js +116 -0
  50. package/dist/launcher/env.js +53 -0
  51. package/dist/launcher/live-install.js +50 -0
  52. package/dist/launcher/output-redaction.js +77 -0
  53. package/dist/launcher/preflight-prompt.js +139 -0
  54. package/dist/launcher/resolve-real-binary.js +73 -0
  55. package/dist/launcher/run.js +417 -0
  56. package/dist/policy/evaluate.js +128 -0
  57. package/dist/presentation/mode.js +52 -0
  58. package/dist/presentation/theme.js +29 -0
  59. package/dist/proxy/buffer-budget.js +64 -0
  60. package/dist/proxy/ca.js +126 -0
  61. package/dist/proxy/classify-host.js +26 -0
  62. package/dist/proxy/enforcement.js +102 -0
  63. package/dist/proxy/metadata-map.js +336 -0
  64. package/dist/proxy/server.js +919 -0
  65. package/dist/proxy/upstream-proxy.js +102 -0
  66. package/dist/proxy/worker.js +39 -0
  67. package/dist/publish-set/collect.js +51 -0
  68. package/dist/publish-set/no-exec-shell.js +19 -0
  69. package/dist/publish-set/npm.js +109 -0
  70. package/dist/publish-set/pack.js +36 -0
  71. package/dist/publish-set/pypi.js +59 -0
  72. package/dist/runtime/cli.js +17 -0
  73. package/dist/runtime/first-run.js +60 -0
  74. package/dist/runtime/node-version.js +58 -0
  75. package/dist/runtime/nudges.js +105 -0
  76. package/dist/scan/analyze-worker.js +21 -0
  77. package/dist/scan/collect.js +153 -0
  78. package/dist/scan/command.js +159 -0
  79. package/dist/scan/discovery.js +209 -0
  80. package/dist/scan/render.js +240 -0
  81. package/dist/scan/scanner-report.js +82 -0
  82. package/dist/scan/staged.js +173 -0
  83. package/dist/scan/types.js +1 -0
  84. package/dist/scan-ui/LegacyApp.js +156 -0
  85. package/dist/scan-ui/alt-screen.js +84 -0
  86. package/dist/scan-ui/api-aliases.js +1 -0
  87. package/dist/scan-ui/components/ErrorView.js +23 -0
  88. package/dist/scan-ui/components/InteractiveResultsView.js +1179 -0
  89. package/dist/scan-ui/components/ProgressBar.js +89 -0
  90. package/dist/scan-ui/components/ProjectSelector.js +62 -0
  91. package/dist/scan-ui/components/ScoreHeader.js +20 -0
  92. package/dist/scan-ui/components/SetupBanner.js +13 -0
  93. package/dist/scan-ui/components/Spinner.js +4 -0
  94. package/dist/scan-ui/format-helpers.js +40 -0
  95. package/dist/scan-ui/hooks/useExpandAnimation.js +40 -0
  96. package/dist/scan-ui/hooks/useScan.js +113 -0
  97. package/dist/scan-ui/hooks/useTerminalSize.js +24 -0
  98. package/dist/scan-ui/launch.js +27 -0
  99. package/dist/scan-ui/logo.js +91 -0
  100. package/dist/scan-ui/shims.js +30 -0
  101. package/dist/security/sanitize.js +28 -0
  102. package/dist/service/state.js +837 -0
  103. package/dist/service/trust-store.js +234 -0
  104. package/dist/service/worker.js +88 -0
  105. package/dist/setup/git-hook.js +244 -0
  106. package/dist/setup/optional-support.js +58 -0
  107. package/dist/setup/plan.js +935 -0
  108. package/dist/state/cleanup-registry.js +60 -0
  109. package/dist/state/index.js +5 -0
  110. package/dist/state/locks.js +161 -0
  111. package/dist/state/paths.js +24 -0
  112. package/dist/state/sessions.js +170 -0
  113. package/dist/state/store.js +50 -0
  114. package/dist/telemetry/events.js +40 -0
  115. package/dist/util/git.js +20 -0
  116. package/dist/util/tty-prompt.js +43 -0
  117. package/dist/verify/local.js +400 -0
  118. package/dist/verify/package-check.js +240 -0
  119. package/dist/verify/preflight.js +698 -0
  120. package/dist/verify/render.js +184 -0
  121. package/dist/verify/types.js +1 -0
  122. package/package.json +33 -50
  123. package/dist/index.mjs +0 -54116
  124. package/dist/postinstall.mjs +0 -731
  125. package/dist/python-hook/dg_pip_hook.pth +0 -1
  126. package/dist/python-hook/dg_pip_hook.py +0 -130
@@ -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
+ }