deeplink-parity 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -3
- package/dist/cli.js +1 -1
- package/dist/discover/expo.js +44 -0
- package/dist/fetch/pool.js +21 -0
- package/dist/run.js +9 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -120,17 +120,40 @@ npx deeplink-parity . --well-known ./staging/well-known
|
|
|
120
120
|
|
|
121
121
|
<br>
|
|
122
122
|
|
|
123
|
+
## Native, Flutter and React Native
|
|
124
|
+
|
|
125
|
+
Whatever the app is written in, deep links are declared in the same two native files and
|
|
126
|
+
verified against the same two hosted files. The scan looks for those, so a Flutter or
|
|
127
|
+
React Native checkout works with no extra setup:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
npx deeplink-parity . # ios/ and android/ live in one repo
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**Expo is the exception.** `expo prebuild` generates `ios/` and `android/` at build time
|
|
134
|
+
and they are normally gitignored, so there is nothing to scan. Run the check after a
|
|
135
|
+
prebuild, or on CI after the prebuild step. Pointed at an un-prebuilt Expo project the
|
|
136
|
+
scan says so rather than reporting a clean run — a static `app.json` also lists the
|
|
137
|
+
domains it found, though a JavaScript config is never evaluated.
|
|
138
|
+
|
|
139
|
+
<br>
|
|
140
|
+
|
|
123
141
|
## Hardened against real projects
|
|
124
142
|
|
|
125
143
|
Synthetic fixtures agree with whatever the author assumed. Real apps do not — so this was
|
|
126
|
-
run against
|
|
127
|
-
DuckDuckGo
|
|
144
|
+
run against open-source apps across all four stacks (Wikipedia, Mastodon, Bitwarden,
|
|
145
|
+
DuckDuckGo, Bluesky, Open Food Facts). Every one of these came from that exercise, and
|
|
146
|
+
none would have surfaced against fixtures alone:
|
|
128
147
|
|
|
129
148
|
- `applinks:*.example.com` is a valid wildcard declaration, and there is no such host to fetch
|
|
130
149
|
- `myapp://callback` carries a host too, but a custom scheme is not an App Link
|
|
131
150
|
- an entitlements file belongs to one target, so the app must not inherit the widget's bundle id
|
|
151
|
+
- a dev domain's assetlinks names the `applicationIdSuffix` variant, which is still our app
|
|
152
|
+
- an app can declare a domain per country, and firing every request at once caused the very
|
|
153
|
+
timeouts it then reported — requests are now pooled
|
|
154
|
+
- an Expo checkout has no native project to scan, which read as a clean run
|
|
132
155
|
|
|
133
|
-
Each is
|
|
156
|
+
Each is a regression test. A browser's `http`/`https` filters with no host are correctly
|
|
134
157
|
read as browser registration rather than deep links.
|
|
135
158
|
|
|
136
159
|
<br>
|
package/dist/cli.js
CHANGED
|
@@ -56,7 +56,7 @@ async function main() {
|
|
|
56
56
|
}
|
|
57
57
|
const source = wellKnown ? localSource(resolve(wellKnown)) : networkSource();
|
|
58
58
|
const result = await run({ roots, source, sha256 });
|
|
59
|
-
if (result.iosApps.length === 0 && result.androidApps.length === 0) {
|
|
59
|
+
if (result.iosApps.length === 0 && result.androidApps.length === 0 && result.findings.length === 0) {
|
|
60
60
|
console.error(`No app configuration declaring deep links was found in ${roots.join(', ')}`);
|
|
61
61
|
console.error('Expected a .entitlements file with applinks:, or an AndroidManifest.xml with intent-filters.');
|
|
62
62
|
process.exit(2);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { displayPath, walk } from './walk.js';
|
|
3
|
+
const CONFIG_FILES = new Set(['app.json', 'app.config.js', 'app.config.ts', 'app.config.json']);
|
|
4
|
+
/**
|
|
5
|
+
* Expo projects declare deep links in the Expo config rather than in the native files,
|
|
6
|
+
* and `expo prebuild` generates `ios/` and `android/` at build time — they are usually
|
|
7
|
+
* gitignored. Scanning such a checkout finds nothing, which would read as "all clear".
|
|
8
|
+
*
|
|
9
|
+
* Static JSON config is parsed directly. A JavaScript config is not evaluated: doing so
|
|
10
|
+
* means executing arbitrary code from the repository being inspected, which a checker
|
|
11
|
+
* should not do. Those projects are pointed at `expo prebuild` instead.
|
|
12
|
+
*/
|
|
13
|
+
export async function detectExpo(root, foundNative) {
|
|
14
|
+
if (foundNative)
|
|
15
|
+
return [];
|
|
16
|
+
const configs = await walk(root, (n) => CONFIG_FILES.has(n));
|
|
17
|
+
if (configs.length === 0)
|
|
18
|
+
return [];
|
|
19
|
+
const findings = [];
|
|
20
|
+
for (const path of configs) {
|
|
21
|
+
const source = await readFile(path, 'utf8');
|
|
22
|
+
if (!/associatedDomains|intentFilters/.test(source))
|
|
23
|
+
continue;
|
|
24
|
+
const isStatic = path.endsWith('.json');
|
|
25
|
+
const domains = isStatic ? staticDomains(source) : [];
|
|
26
|
+
findings.push({
|
|
27
|
+
severity: 'warn',
|
|
28
|
+
rule: 'expo-config-only',
|
|
29
|
+
message: 'Deep links are declared in the Expo config and no native project was found',
|
|
30
|
+
detail: domains.length
|
|
31
|
+
? `Declared: ${domains.join(', ')}. Run \`npx expo prebuild\` and scan again to verify them.`
|
|
32
|
+
: 'Run `npx expo prebuild` to generate ios/ and android/, then scan again. A JavaScript config is not evaluated.',
|
|
33
|
+
source: displayPath(path),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return findings;
|
|
37
|
+
}
|
|
38
|
+
/** Pull `applinks:` entries out of a static Expo config without executing it. */
|
|
39
|
+
function staticDomains(source) {
|
|
40
|
+
const domains = new Set();
|
|
41
|
+
for (const m of source.matchAll(/"applinks:([^"]+)"/g))
|
|
42
|
+
domains.add(m[1].split('?')[0]);
|
|
43
|
+
return [...domains];
|
|
44
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run tasks with a bounded number in flight.
|
|
3
|
+
*
|
|
4
|
+
* Some apps declare a domain per country, and firing every request at once looks
|
|
5
|
+
* like an attack from the receiving end — it also causes the timeouts it then
|
|
6
|
+
* reports as findings. Results keep input order.
|
|
7
|
+
*/
|
|
8
|
+
export async function mapLimit(items, limit, task) {
|
|
9
|
+
const results = new Array(items.length);
|
|
10
|
+
let next = 0;
|
|
11
|
+
const worker = async () => {
|
|
12
|
+
while (next < items.length) {
|
|
13
|
+
const index = next++;
|
|
14
|
+
results[index] = await task(items[index], index);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
/** Concurrent requests per run. Low enough to stay polite on a shared host. */
|
|
21
|
+
export const FETCH_CONCURRENCY = 6;
|
package/dist/run.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { discoverAndroid } from './discover/android.js';
|
|
2
|
+
import { detectExpo } from './discover/expo.js';
|
|
2
3
|
import { discoverIos } from './discover/ios.js';
|
|
4
|
+
import { FETCH_CONCURRENCY, mapLimit } from './fetch/pool.js';
|
|
3
5
|
import { checkAndroidHost, checkAndroidUnresolved } from './rules/android.js';
|
|
4
6
|
import { checkCrossPlatform } from './rules/cross.js';
|
|
5
7
|
import { aasaPaths, checkIosDomain } from './rules/ios.js';
|
|
@@ -12,6 +14,11 @@ export async function run({ roots, source, sha256 }) {
|
|
|
12
14
|
const iosApps = discovered.flatMap(([ios]) => ios);
|
|
13
15
|
const androidApps = discovered.flatMap(([, android]) => android);
|
|
14
16
|
const findings = [];
|
|
17
|
+
// an Expo checkout has no native project committed; say so rather than report nothing
|
|
18
|
+
if (iosApps.length === 0 && androidApps.length === 0) {
|
|
19
|
+
for (const root of roots)
|
|
20
|
+
findings.push(...(await detectExpo(root, false)));
|
|
21
|
+
}
|
|
15
22
|
const ios = emptyView();
|
|
16
23
|
const android = emptyView();
|
|
17
24
|
for (const app of iosApps) {
|
|
@@ -21,7 +28,7 @@ export async function run({ roots, source, sha256 }) {
|
|
|
21
28
|
const wildcards = fresh.filter(isWildcardDomain);
|
|
22
29
|
wildcards.forEach((d) => findings.push(wildcardFinding(d, app.entitlementsPath)));
|
|
23
30
|
const pending = fresh.filter((d) => !isWildcardDomain(d));
|
|
24
|
-
const results = await
|
|
31
|
+
const results = await mapLimit(pending, FETCH_CONCURRENCY, (d) => source.aasa(d));
|
|
25
32
|
pending.forEach((domain, i) => {
|
|
26
33
|
const { findings: domainFindings, aasa } = checkIosDomain(app, domain, results[i]);
|
|
27
34
|
findings.push(...domainFindings);
|
|
@@ -41,9 +48,8 @@ export async function run({ roots, source, sha256 }) {
|
|
|
41
48
|
.filter((h) => isWildcardDomain(h.host))
|
|
42
49
|
.forEach((h) => findings.push(wildcardFinding(h.host, app.manifestPath)));
|
|
43
50
|
const pending = fresh.filter((h) => !isWildcardDomain(h.host));
|
|
44
|
-
const results = await Promise.all(
|
|
45
51
|
// an unverified host is never checked by the system, so there is nothing to fetch
|
|
46
|
-
pending
|
|
52
|
+
const results = await mapLimit(pending, FETCH_CONCURRENCY, (h) => h.autoVerify ? source.assetlinks(h.host) : Promise.resolve(null));
|
|
47
53
|
pending.forEach((entry, i) => {
|
|
48
54
|
const res = results[i] ?? { url: '', ok: false, redirected: false };
|
|
49
55
|
findings.push(...checkAndroidHost(app, entry, res, { sha256 }));
|
package/package.json
CHANGED