1import { Actor } from 'apify';
2import dns from 'node:dns/promises';
3import net from 'node:net';
4import { fileURLToPath } from 'node:url';
5
6const USER_AGENT = 'AppLinksAuditor/0.1 (+https://apify.com)';
7const DEFAULT_TIMEOUT_SECONDS = 10;
8const DEFAULT_MAX_BYTES = 131072;
9const MAX_BYTES = 524288;
10
11
12const AASA_WELL_KNOWN_PATH = '/.well-known/apple-app-site-association';
13const AASA_ROOT_PATH = '/apple-app-site-association';
14
15const ASSETLINKS_PATH = '/.well-known/assetlinks.json';
16
17
18const SHA256_HEX_LENGTH = 64;
19const SHA1_HEX_LENGTH = 40;
20const SHA256_BASE64_LENGTH = 44;
21
22
23
24
25
26function isPrivateIPv4(ip) {
27 const parts = ip.split('.').map(Number);
28 if (parts.length !== 4 || parts.some((n) => Number.isNaN(n))) return false;
29 const [a, b] = parts;
30 return a === 10
31 || (a === 172 && b >= 16 && b <= 31)
32 || (a === 192 && b === 168)
33 || a === 127
34 || a === 0
35 || (a === 169 && b === 254);
36}
37
38function isPrivateIPv6(ip) {
39 const normalized = ip.toLowerCase();
40 return normalized === '::1'
41 || normalized.startsWith('fc')
42 || normalized.startsWith('fd')
43 || normalized.startsWith('fe80:');
44}
45
46export async function normalizeAndValidateUrl(rawUrl) {
47 if (!rawUrl || typeof rawUrl !== 'string') throw new Error('startUrl is required');
48 if (/^[a-z][a-z0-9+.-]*:/i.test(rawUrl) && !/^https?:\/\//i.test(rawUrl)) {
49 throw new Error('Only HTTP and HTTPS URLs are supported');
50 }
51
52 const withScheme = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}`;
53 const url = new URL(withScheme);
54 if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Only HTTP and HTTPS URLs are supported');
55
56 if (!url.hostname || url.username || url.password) throw new Error('URL must be public and must not include credentials');
57
58 const literalType = net.isIP(url.hostname);
59 if (literalType === 4 && isPrivateIPv4(url.hostname)) throw new Error('Private IPv4 targets are blocked');
60 if (literalType === 6 && isPrivateIPv6(url.hostname)) throw new Error('Private IPv6 targets are blocked');
61
62 const records = literalType ? [{ address: url.hostname, family: literalType }] : await dns.lookup(url.hostname, { all: true });
63 for (const record of records) {
64 if (record.family === 4 && isPrivateIPv4(record.address)) throw new Error('DNS resolves to a private IPv4 address; blocked for SSRF safety');
65 if (record.family === 6 && isPrivateIPv6(record.address)) throw new Error('DNS resolves to a private IPv6 address; blocked for SSRF safety');
66 }
67 return url;
68}
69
70
71
72
73
74
75async function fetchPath(originUrl, path, timeoutSeconds, maxBytes, redirectsRemaining = 3) {
76 const requestUrl = new URL(path, originUrl.href);
77 await normalizeAndValidateUrl(requestUrl.href);
78 const controller = new AbortController();
79 const timeout = setTimeout(() => controller.abort(), timeoutSeconds * 1000);
80 try {
81 const response = await fetch(requestUrl, {
82 redirect: 'manual',
83 signal: controller.signal,
84 headers: {
85 'user-agent': USER_AGENT,
86 accept: 'application/json, */*;q=0.1',
87 },
88 });
89
90 if ([301, 302, 303, 307, 308].includes(response.status)) {
91 if (redirectsRemaining <= 0) throw new Error('Too many redirects');
92 const location = response.headers.get('location');
93 if (!location) throw new Error('Redirect without Location header');
94 const nextUrl = new URL(location, requestUrl.href);
95 await normalizeAndValidateUrl(nextUrl.href);
96 const followed = await fetchPath(nextUrl, nextUrl.pathname + nextUrl.search, timeoutSeconds, maxBytes, redirectsRemaining - 1);
97 return { ...followed, followedRedirect: true };
98 }
99
100 const reader = response.body.getReader();
101 const chunks = [];
102 let total = 0;
103 let truncated = false;
104 while (true) {
105 const { done, value } = await reader.read();
106 if (done) break;
107 if (total + value.length > maxBytes) {
108 chunks.push(value.slice(0, Math.max(0, maxBytes - total)));
109 truncated = true;
110 break;
111 }
112 chunks.push(value);
113 total += value.length;
114 }
115 try { await reader.cancel(); } catch { }
116
117 const body = new TextDecoder('utf-8', { fatal: false }).decode(Buffer.concat(chunks));
118 const headerMap = {};
119 response.headers.forEach((value, key) => { headerMap[key.toLowerCase()] = value; });
120
121 return {
122 ok: response.ok,
123 status: response.status,
124 finalUrl: response.url || requestUrl.href,
125 https: (response.url || requestUrl.href).startsWith('https://'),
126 body,
127 headers: headerMap,
128 truncated,
129 followedRedirect: false,
130 error: null,
131 };
132 } catch (error) {
133 return {
134 ok: false,
135 status: null,
136 finalUrl: requestUrl.href,
137 https: requestUrl.protocol === 'https:',
138 body: '',
139 headers: {},
140 truncated: false,
141 followedRedirect: false,
142 error: error.message,
143 };
144 } finally {
145 clearTimeout(timeout);
146 }
147}
148
149
150
151
152
153export function parseJson(body) {
154 if (!body || !body.trim()) return { parsed: null, error: 'empty response body' };
155 try {
156 const parsed = JSON.parse(body);
157 return { parsed, error: null };
158 } catch (err) {
159 return { parsed: null, error: `invalid JSON: ${err.message}` };
160 }
161}
162
163function isPlainObject(v) {
164 return v !== null && typeof v === 'object' && !Array.isArray(v);
165}
166
167function isNonEmptyString(v) {
168 return typeof v === 'string' && v.trim() !== '';
169}
170
171
172
173
174
175
176
177export function analyzeContentType(contentTypeHeader) {
178 const ct = (contentTypeHeader || '').toLowerCase();
179 const isJson = ct.includes('application/json');
180 const isHtml = ct.includes('text/html') || ct.includes('application/xhtml');
181 const issues = [];
182 if (isHtml) {
183 issues.push('Content-Type is HTML — the path likely returns an error page, not an association file');
184 } else if (!isJson && ct !== '') {
185 issues.push(`Content-Type ${ct} is not application/json`);
186 }
187 return { contentType: contentTypeHeader || '', isJson, isHtml, issues };
188}
189
190
191
192
193
194
195
196export function analyzeAasa(parsed) {
197 const issues = [];
198 const result = {
199 applinksPresent: false,
200 appsCount: 0,
201 details: [],
202 detailsCount: 0,
203 validAppIds: 0,
204 invalidAppIds: [],
205 webcredentialsCount: 0,
206 appclipsPresent: false,
207 issues,
208 };
209
210 if (!isPlainObject(parsed)) {
211 issues.push('AASA JSON must be an object');
212 return result;
213 }
214
215
216 if (parsed.apps !== undefined) {
217 if (!Array.isArray(parsed.apps)) {
218 issues.push('top-level apps must be an array when present');
219 } else {
220 result.appsCount = parsed.apps.length;
221 for (const appId of parsed.apps) {
222 if (isNonEmptyString(appId) && /^[A-Z0-9]{8,10}\.[A-Za-z0-9._-]+$/.test(appId)) {
223 result.validAppIds++;
224 } else {
225 result.invalidAppIds.push(String(appId).slice(0, 100));
226 }
227 }
228 }
229 }
230
231
232 const applinks = parsed.applinks;
233 if (applinks === undefined || applinks === null) {
234 issues.push('applinks block is missing (required for Universal Links)');
235 } else if (!isPlainObject(applinks)) {
236 issues.push('applinks must be an object');
237 } else {
238 result.applinksPresent = true;
239
240
241
242 if (applinks.apps !== undefined && applinks.apps !== null) {
243 if (!Array.isArray(applinks.apps)) {
244 issues.push('applinks.apps must be an array');
245 } else if (applinks.apps.length > 0) {
246 issues.push('applinks.apps is not empty — appIDs belong in details entries, not in applinks.apps (modern format)');
247 }
248 }
249
250
251 const details = applinks.details;
252 if (!Array.isArray(details) || details.length === 0) {
253 issues.push('applinks.details is missing or empty (required, must be a non-empty array)');
254 } else {
255 result.detailsCount = details.length;
256 details.forEach((entry, i) => {
257 if (!isPlainObject(entry)) {
258 issues.push(`applinks.details[${i}] is not an object`);
259 return;
260 }
261 const block = {
262 index: i,
263 appIDs: [],
264 appIDCount: 0,
265 invalidAppIDs: [],
266 components: [],
267 componentCount: 0,
268 componentIssues: 0,
269 paths: [],
270 issues: [],
271 };
272
273
274 let ids = entry.appIDs !== undefined ? entry.appIDs : entry.appID;
275 if (typeof ids === 'string' && ids.trim() !== '') ids = [ids];
276 if (ids === undefined) {
277 block.issues.push('appIDs is missing or empty (required)');
278 } else if (!Array.isArray(ids) || ids.length === 0) {
279 block.issues.push('appIDs is missing or empty (required)');
280 } else if (!ids.every((x) => typeof x === 'string')) {
281 block.issues.push('appIDs must all be strings');
282 } else {
283 block.appIDs = ids;
284 block.appIDCount = ids.length;
285 for (const id of ids) {
286 if (!/^[A-Z0-9]{8,10}\.[A-Za-z0-9._-]+$/.test(id)) {
287 block.invalidAppIDs.push(id);
288 }
289 }
290 if (block.invalidAppIDs.length > 0) {
291 block.issues.push(`invalid appID format (expected TEAMID.bundle.identifier): ${block.invalidAppIDs.slice(0, 3).join(', ')}`);
292 }
293 }
294
295
296 const components = entry.components;
297 if (components !== undefined && components !== null) {
298 if (!Array.isArray(components)) {
299 block.issues.push('components must be an array when present');
300 } else {
301 block.componentCount = components.length;
302 if (components.length === 0) {
303 block.issues.push('components is empty — declare at least one component so paths open in the app');
304 }
305 components.forEach((comp, ci) => {
306 if (!isPlainObject(comp)) {
307 block.componentIssues++;
308 block.issues.push(`components[${ci}] is not an object`);
309 return;
310 }
311 const { '/': queryPattern, ...rest } = comp;
312 const keys = Object.keys(rest);
313 if (!keys.includes('/') && queryPattern === undefined) {
314
315 }
316 if (!isNonEmptyString(comp['/'])) {
317 block.componentIssues++;
318 block.issues.push(`components[${ci}]."/" is missing or empty (required)`);
319 }
320 for (const key of keys) {
321
322
323 if (!['/', '?', '#', 'exclude', 'comment', 'placeholder', 'caseSensitive', 'percentEncoded'].includes(key)) {
324 block.componentIssues++;
325 block.issues.push(`components[${ci}] has unrecognized key "${key}"`);
326 }
327 }
328 if (comp['exclude'] !== undefined && typeof comp['exclude'] !== 'boolean') {
329 block.componentIssues++;
330 block.issues.push(`components[${ci}].exclude must be a boolean when present`);
331 }
332 if (comp['comment'] !== undefined && !isNonEmptyString(comp.comment)) {
333 block.componentIssues++;
334 block.issues.push(`components[${ci}].comment must be a non-empty string when present`);
335 }
336 });
337 }
338 } else {
339 block.issues.push('components is missing — without components, Universal Links will not open the app on iOS 13+');
340 }
341
342
343 if (entry.paths !== undefined) {
344 if (!Array.isArray(entry.paths) || !entry.paths.every((p) => typeof p === 'string')) {
345 block.issues.push('paths must be an array of strings when present (legacy format)');
346 } else {
347 block.paths = entry.paths;
348 }
349 }
350 for (const key of ['not', 'notAppName']) {
351 if (entry[key] !== undefined) {
352 block.issues.push(`unrecognized legacy key "${key}" in details entry`);
353 }
354 }
355
356 for (const iss of block.issues) issues.push(`applinks.details[${i}]: ${iss}`);
357 result.details.push(block);
358 });
359 }
360 }
361
362
363 if (parsed.webcredentials !== undefined) {
364 if (!isPlainObject(parsed.webcredentials)) {
365 issues.push('webcredentials must be an object when present');
366 } else {
367 const apps = parsed.webcredentials.apps;
368 if (!Array.isArray(apps) || apps.length === 0) {
369 issues.push('webcredentials.apps is missing or empty (required when webcredentials is present)');
370 } else if (!apps.every((a) => typeof a === 'string' && /^[A-Z0-9]{8,10}\.[A-Za-z0-9._-]+$/.test(a))) {
371 issues.push('webcredentials.apps entries must match TEAMID.bundle.identifier format');
372 } else {
373 result.webcredentialsCount = apps.length;
374 }
375 }
376 }
377
378
379 if (parsed.appclips !== undefined) {
380 if (!isPlainObject(parsed.appclips)) {
381 issues.push('appclips must be an object when present');
382 } else {
383 const apps = parsed.appclips.apps;
384 if (!Array.isArray(apps) || apps.length === 0) {
385 issues.push('appclips.apps is missing or empty (required when appclips is present)');
386 } else {
387 result.appclipsPresent = true;
388 }
389 }
390 }
391
392 return result;
393}
394
395
396
397
398
399
400export function analyzeAssetlinks(parsed) {
401 const issues = [];
402 const result = {
403 isList: false,
404 statements: [],
405 statementCount: 0,
406 validTargets: 0,
407 invalidTargets: 0,
408 targetPackages: [],
409 validFingerprints: 0,
410 invalidFingerprints: [],
411 validRelations: 0,
412 invalidRelations: [],
413 sha256Count: 0,
414 issues,
415 };
416
417 if (!Array.isArray(parsed)) {
418 issues.push('assetlinks.json must be a JSON array of statements');
419 return result;
420 }
421 result.isList = true;
422 result.statementCount = parsed.length;
423
424 if (parsed.length === 0) {
425 issues.push('assetlinks.json is an empty array — declare at least one statement');
426 return result;
427 }
428
429 const packageSet = new Set();
430
431 parsed.forEach((statement, i) => {
432 if (!isPlainObject(statement)) {
433 issues.push(`statement[${i}] is not an object`);
434 return;
435 }
436 const block = {
437 index: i,
438 namespace: null,
439 package: null,
440 sha256Fingerprints: null,
441 fingerprintCount: 0,
442 relations: [],
443 issues: [],
444 };
445
446
447 const relation = statement.relation;
448 if (!Array.isArray(relation) || relation.length === 0 || !relation.every((r) => typeof r === 'string')) {
449 block.issues.push('relation is missing or not a non-empty array of strings (required)');
450 } else {
451 block.relations = relation;
452 for (const r of relation) {
453 if (!/^delegate_permission\/.+$/.test(r)) {
454 result.invalidRelations.push(r);
455 }
456 }
457 }
458
459
460
461
462 const target = statement.target;
463 if (!isPlainObject(target)) {
464 block.issues.push('target is missing or not an object (required)');
465 } else {
466 const isWeb = target.namespace === 'web';
467 block.namespace = typeof target.namespace === 'string' ? target.namespace : null;
468 if (!isNonEmptyString(target.namespace)) {
469 block.issues.push('target.namespace is missing or empty (required, expected "android_app" or "web")');
470 } else if (target.namespace !== 'android_app' && !isWeb) {
471 block.issues.push(`target.namespace "${target.namespace}" is not "android_app" or "web"`);
472 }
473
474 const packageName = target.package_name !== undefined ? target.package_name : target.package;
475 if (isWeb) {
476
477 if (!isNonEmptyString(target.site)) {
478 block.issues.push('target.site is missing or empty (required for namespace "web")');
479 } else {
480 try {
481 const siteUrl = new URL(target.site);
482 if (siteUrl.protocol !== 'https:') block.issues.push('target.site should be an HTTPS URL');
483 } catch {
484 block.issues.push(`target.site "${target.site}" is not a valid URL`);
485 }
486 }
487 } else if (!isNonEmptyString(packageName)) {
488 block.issues.push('target.package_name is missing or empty (required, Android package name)');
489 } else {
490 block.package = packageName;
491 if (!/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/.test(packageName)) {
492 block.issues.push(`target.package_name "${packageName}" does not look like a valid Android package name`);
493 } else {
494 packageSet.add(packageName);
495 result.validTargets++;
496 }
497 }
498
499 const fps = target.sha256_cert_fingerprints;
500 if (isWeb) {
501
502 } else if (!Array.isArray(fps) || fps.length === 0) {
503 block.issues.push('target.sha256_cert_fingerprints is missing or empty (required)');
504 } else if (!fps.every((f) => typeof f === 'string')) {
505 block.issues.push('target.sha256_cert_fingerprints must all be strings');
506 } else {
507 block.sha256Fingerprints = fps;
508 block.fingerprintCount = fps.length;
509 for (const fp of fps) {
510 const clean = fp.replace(/[:\s]/g, '').toLowerCase();
511 const isHex = clean.length === SHA256_HEX_LENGTH && /^[0-9a-f]+$/.test(clean);
512 const isBase64ish = /^[A-Za-z0-9+/]+={0,2}$/.test(fp) && fp.length === SHA256_BASE64_LENGTH;
513 if (isHex || isBase64ish) {
514 result.validFingerprints++;
515 result.sha256Count++;
516 } else {
517 result.invalidFingerprints.push(fp.slice(0, 100));
518 }
519 }
520 }
521 }
522
523 for (const iss of block.issues) issues.push(`statement[${i}]: ${iss}`);
524 result.statements.push(block);
525 });
526
527 result.targetPackages = [...packageSet];
528
529 if (result.validRelations === 0 && result.statementCount > 0) {
530
531 let valid = 0;
532 for (const s of result.statements) {
533 for (const r of s.relations || []) {
534 if (/^delegate_permission\/.+$/.test(r)) valid++;
535 }
536 }
537 result.validRelations = valid;
538 }
539
540 if (result.invalidFingerprints.length > 0) {
541 issues.push(`invalid sha256_cert_fingerprints format (expected 64-char hex or 44-char base64): ${result.invalidFingerprints.slice(0, 3).join(', ')}`);
542 }
543 if (result.invalidRelations.length > 0) {
544 issues.push(`invalid relation format (expected delegate_permission/...): ${result.invalidRelations.slice(0, 3).join(', ')}`);
545 }
546
547 return result;
548}
549
550
551
552
553
554export function scoreAudit(aasaSummary, assetlinksSummary, aasaFetch, assetlinksFetch) {
555 let earned = 0;
556
557
558 const aasaFound = aasaFetch && aasaFetch.ok;
559 if (aasaFound) {
560 earned += 12;
561 if (aasaFetch.https) earned += 5;
562 if (aasaSummary.applinksPresent) earned += 8;
563 if (aasaSummary.detailsCount > 0) earned += 8;
564 if (aasaSummary.details.some((d) => d.componentCount > 0)) earned += 6;
565 if (aasaSummary.details.some((d) => d.appIDCount > 0 && d.invalidAppIDs.length === 0)) earned += 6;
566 if (aasaSummary.validAppIds > 0) earned += 2;
567 if (aasaSummary.webcredentialsCount > 0) earned += 2;
568 if (aasaSummary.appclipsPresent) earned += 1;
569
570 earned = Math.min(earned, 50);
571 }
572
573
574 const alFound = assetlinksFetch && assetlinksFetch.ok;
575 if (alFound) {
576 let alEarned = 0;
577 alEarned += 12;
578 if (assetlinksFetch.https) alEarned += 5;
579 if (assetlinksSummary.isList) alEarned += 6;
580 if (assetlinksSummary.statementCount > 0) alEarned += 6;
581 if (assetlinksSummary.validTargets > 0) alEarned += 6;
582 if (assetlinksSummary.validFingerprints > 0) alEarned += 6;
583 if (assetlinksSummary.validRelations > 0) alEarned += 6;
584 if (assetlinksSummary.targetPackages.length >= 1) alEarned += 3;
585
586 earned += Math.min(alEarned, 50);
587 }
588
589
590 const issueCount = (aasaSummary.issues.length) + (assetlinksSummary.issues.length);
591 const issuePenalty = Math.min(issueCount * 6, 40);
592 earned = Math.max(0, earned - issuePenalty);
593
594 return Math.round(earned);
595}
596
597export function gradeFromScore(score) {
598 if (score >= 95) return 'A+';
599 if (score >= 85) return 'A';
600 if (score >= 75) return 'B';
601 if (score >= 65) return 'C';
602 if (score >= 50) return 'D';
603 if (score >= 30) return 'E';
604 return 'F';
605}
606
607
608
609
610
611export function buildRecommendations(aasaSummary, assetlinksSummary, aasaFetch, assetlinksFetch) {
612 const recs = new Set();
613
614
615 if (aasaFetch && !aasaFetch.ok) {
616 if (aasaFetch.status === 404) {
617 recs.add('Serve the Apple App Site Association file at https://<domain>/.well-known/apple-app-site-association (without a file extension) so iOS can associate the domain for Universal Links.');
618 } else if (aasaFetch.status !== null) {
619 recs.add(`The AASA endpoint returned HTTP ${aasaFetch.status}. Fix the server so the association file is reachable without redirects.`);
620 } else {
621 recs.add('The AASA endpoint could not be fetched. Verify the domain resolves and the well-known path is served.');
622 }
623 }
624 if (aasaFetch && aasaFetch.ok && !aasaFetch.https) {
625 recs.add('Serve the AASA file over HTTPS. iOS requires a valid certificate and HTTPS for Universal Links association.');
626 }
627 if (aasaFetch && aasaFetch.ok && aasaFetch.followedRedirect) {
628 recs.add('iOS only follows one redirect for AASA and can fail association when the file is behind a redirect. Serve the association file directly.');
629 }
630 if (aasaSummary.issues.some((i) => i.includes('applinks block is missing'))) {
631 recs.add('Add an `applinks` block with a `details` array to the AASA JSON. The legacy top-level `apps` format is no longer recommended.');
632 }
633 if (aasaSummary.issues.some((i) => i.includes('applinks.apps is not empty'))) {
634 recs.add('Move app IDs from `applinks.apps` into `details` entries. In the modern format `apps` must be an empty array.');
635 }
636 if (aasaSummary.issues.some((i) => i.includes('details is missing or empty'))) {
637 recs.add('Add at least one entry to `applinks.details` with `appIDs` (TEAMID.bundle.identifier) and path `components`.');
638 }
639 if (aasaSummary.issues.some((i) => i.includes('components is missing') || i.includes('components is empty'))) {
640 recs.add('Declare path `components` in each details entry — on iOS 13 and later, Universal Links require components and ignore legacy `paths`.');
641 }
642 if (aasaSummary.issues.some((i) => i.includes('invalid appID format'))) {
643 recs.add('Fix invalid appID entries — the format is TEAMID.bundle.identifier, for example 1234ABCD89.com.example.app.');
644 }
645 if (aasaSummary.issues.some((i) => i.includes('unrecognized legacy key'))) {
646 recs.add('Remove legacy keys (not, notAppName) from details entries — they are ignored by iOS 13+.');
647 }
648 if (aasaSummary.issues.some((i) => i.includes('webcredentials'))) {
649 recs.add('Fix the `webcredentials` block — apps must be a non-empty array of TEAMID.bundle.identifier entries.');
650 }
651 if (aasaSummary.issues.some((i) => i.includes('appclips'))) {
652 recs.add('Fix the `appclips` block — apps must be a non-empty array of App Clip bundle identifiers.');
653 }
654
655
656 if (assetlinksFetch && !assetlinksFetch.ok) {
657 if (assetlinksFetch.status === 404) {
658 recs.add('Serve the Android App Links statement list at https://<domain>/.well-known/assetlinks.json so Android can verify your app links.');
659 } else if (assetlinksFetch.status !== null) {
660 recs.add(`The assetlinks.json endpoint returned HTTP ${assetlinksFetch.status}. Fix the server so the statement list is reachable.`);
661 } else {
662 recs.add('The assetlinks.json endpoint could not be fetched. Verify the domain resolves and the well-known path is served.');
663 }
664 }
665 if (assetlinksFetch && assetlinksFetch.ok && !assetlinksFetch.https) {
666 recs.add('Serve assetlinks.json over HTTPS. Android requires HTTPS with a valid certificate for App Links verification.');
667 }
668 if (assetlinksSummary.issues.some((i) => i.includes('must be a JSON array'))) {
669 recs.add('assetlinks.json must be a JSON array of statement objects, each with a `target` and a `relation` list.');
670 }
671 if (assetlinksSummary.issues.some((i) => i.includes('empty array'))) {
672 recs.add('Declare at least one statement in assetlinks.json with target namespace android_app, package name, and SHA-256 certificate fingerprints.');
673 }
674 if (assetlinksSummary.issues.some((i) => i.includes('sha256_cert_fingerprints is missing'))) {
675 recs.add('Add `sha256_cert_fingerprints` to each target — Android cannot verify App Links without the app certificate SHA-256 fingerprint.');
676 }
677 if (assetlinksSummary.issues.some((i) => i.includes('invalid sha256_cert_fingerprints format'))) {
678 recs.add('Fix fingerprint formats — sha256_cert_fingerprints entries must be 64-character colon-separated or plain hex, or base64 SHA-256 digests.');
679 }
680 if (assetlinksSummary.issues.some((i) => i.includes('target.package is missing'))) {
681 recs.add('Add the Android `package` name to each target so the statement identifies your app.');
682 }
683 if (assetlinksSummary.issues.some((i) => i.includes('target.namespace'))) {
684 recs.add('Set `target.namespace` to "android_app" in every statement.');
685 }
686 if (assetlinksSummary.issues.some((i) => i.includes('relation is missing'))) {
687 recs.add('Add a `relation` list such as ["delegate_permission/common.handle_all_urls"] to each statement.');
688 }
689
690
691 const aasaClean = aasaFetch && aasaFetch.ok && aasaSummary.issues.length === 0;
692 const alClean = assetlinksFetch && assetlinksFetch.ok && assetlinksSummary.issues.length === 0;
693 if (aasaClean && alClean) {
694 recs.add('Both association files are well-formed. Schedule this audit periodically to catch deploy and CDN regressions.');
695 }
696
697 return [...recs];
698}
699
700
701
702
703
704function emptyAasaSummary() {
705 return {
706 applinksPresent: false,
707 appsCount: 0,
708 details: [],
709 detailsCount: 0,
710 validAppIds: 0,
711 invalidAppIds: [],
712 webcredentialsCount: 0,
713 appclipsPresent: false,
714 issues: [],
715 };
716}
717
718function emptyAssetlinksSummary() {
719 return {
720 isList: false,
721 statements: [],
722 statementCount: 0,
723 validTargets: 0,
724 invalidTargets: 0,
725 targetPackages: [],
726 validFingerprints: 0,
727 invalidFingerprints: [],
728 validRelations: 0,
729 invalidRelations: [],
730 sha256Count: 0,
731 issues: [],
732 };
733}
734
735
736
737
738
739export async function auditAppLinks(input) {
740 const baseUrl = await normalizeAndValidateUrl(input.startUrl);
741 const timeoutSeconds = Math.min(Math.max(Number(input.timeoutSeconds || DEFAULT_TIMEOUT_SECONDS), 3), 30);
742 const maxBytes = Math.min(Math.max(Number(input.maxBytes || DEFAULT_MAX_BYTES), 1024), MAX_BYTES);
743
744
745 let aasaFetch = await fetchPath(baseUrl, AASA_WELL_KNOWN_PATH, timeoutSeconds, maxBytes);
746 let aasaPathTried = AASA_WELL_KNOWN_PATH;
747 if (!aasaFetch.ok && aasaFetch.status === 404) {
748 const rootFetch = await fetchPath(baseUrl, AASA_ROOT_PATH, timeoutSeconds, maxBytes);
749 if (rootFetch.ok) {
750 aasaFetch = rootFetch;
751 aasaPathTried = AASA_ROOT_PATH;
752 }
753 }
754
755
756 const assetlinksFetch = await fetchPath(baseUrl, ASSETLINKS_PATH, timeoutSeconds, maxBytes);
757
758
759 let aasaSummary = emptyAasaSummary();
760 let aasaJsonValid = null;
761 let aasaParseError = null;
762 if (aasaFetch.error) {
763 aasaSummary.issues.push(`AASA request failed: ${aasaFetch.error}`);
764 } else if (!aasaFetch.ok) {
765 aasaSummary.issues.push(`AASA endpoint returned HTTP ${aasaFetch.status}`);
766 } else {
767 const { parsed, error } = parseJson(aasaFetch.body);
768 if (error) {
769 aasaJsonValid = false;
770 aasaParseError = error;
771 aasaSummary.issues.push(`AASA ${error}`);
772 } else {
773 aasaJsonValid = true;
774 aasaSummary = analyzeAasa(parsed);
775 }
776 const ctInfo = analyzeContentType(aasaFetch.headers['content-type']);
777 for (const iss of ctInfo.issues) {
778 if (!aasaSummary.issues.includes(iss)) aasaSummary.issues.push(`AASA ${iss}`);
779 }
780 }
781
782
783 let assetlinksSummary = emptyAssetlinksSummary();
784 let assetlinksJsonValid = null;
785 let assetlinksParseError = null;
786 if (assetlinksFetch.error) {
787 assetlinksSummary.issues.push(`assetlinks request failed: ${assetlinksFetch.error}`);
788 } else if (!assetlinksFetch.ok) {
789 assetlinksSummary.issues.push(`assetlinks endpoint returned HTTP ${assetlinksFetch.status}`);
790 } else {
791 const { parsed, error } = parseJson(assetlinksFetch.body);
792 if (error) {
793 assetlinksJsonValid = false;
794 assetlinksParseError = error;
795 assetlinksSummary.issues.push(`assetlinks ${error}`);
796 } else {
797 assetlinksJsonValid = true;
798 assetlinksSummary = analyzeAssetlinks(parsed);
799 }
800 const ctInfo = analyzeContentType(assetlinksFetch.headers['content-type']);
801 for (const iss of ctInfo.issues) {
802 if (!assetlinksSummary.issues.includes(iss)) assetlinksSummary.issues.push(`assetlinks ${iss}`);
803 }
804 }
805
806 const aasaFound = aasaFetch.ok && !aasaFetch.error;
807 const assetlinksFound = assetlinksFetch.ok && !assetlinksFetch.error;
808
809 const score = scoreAudit(aasaSummary, assetlinksSummary, aasaFound ? aasaFetch : null, assetlinksFound ? assetlinksFetch : null);
810 const grade = gradeFromScore(score);
811 const issues = [...aasaSummary.issues, ...assetlinksSummary.issues];
812 const recommendations = buildRecommendations(aasaSummary, assetlinksSummary, aasaFound ? aasaFetch : null, assetlinksFound ? assetlinksFetch : null);
813
814 return {
815 inputUrl: input.startUrl,
816 finalUrl: baseUrl.href,
817 https: baseUrl.protocol === 'https:',
818 ok: !aasaFetch.error && !assetlinksFetch.error,
819 checkedAt: new Date().toISOString(),
820
821 aasaChecked: true,
822 aasaFound,
823 aasaUrl: new URL(aasaPathTried, baseUrl.href).href,
824 aasaStatus: aasaFetch.status,
825 aasaHttps: aasaFetch.error ? null : aasaFetch.https,
826 aasaContentType: aasaFetch.headers['content-type'] || null,
827 aasaJsonValid,
828 aasaParseError,
829 aasaAppCount: aasaSummary.appsCount,
830 aasaDetailCount: aasaSummary.detailsCount,
831 aasaApplinksPresent: aasaSummary.applinksPresent,
832 aasaValidAppIds: aasaSummary.validAppIds,
833 aasaInvalidAppIds: aasaSummary.invalidAppIds,
834 aasaWebcredentialsCount: aasaSummary.webcredentialsCount,
835 aasaAppclipsPresent: aasaSummary.appclipsPresent,
836 aasaDetails: aasaSummary.details,
837 aasaRedirected: aasaFetch.followedRedirect ? true : false,
838 aasaRedirectIssue: aasaFetch.followedRedirect === true,
839 aasaIssues: aasaSummary.issues,
840
841 assetlinksChecked: true,
842 assetlinksFound,
843 assetlinksUrl: new URL(ASSETLINKS_PATH, baseUrl.href).href,
844 assetlinksStatus: assetlinksFetch.status,
845 assetlinksHttps: assetlinksFetch.error ? null : assetlinksFetch.https,
846 assetlinksContentType: assetlinksFetch.headers['content-type'] || null,
847 assetlinksJsonValid,
848 assetlinksParseError,
849 assetlinksTargetCount: assetlinksSummary.statementCount,
850 assetlinksPackageCount: assetlinksSummary.targetPackages.length,
851 assetlinksPackages: assetlinksSummary.targetPackages,
852 assetlinksValidFingerprints: assetlinksSummary.validFingerprints,
853 assetlinksInvalidFingerprints: assetlinksSummary.invalidFingerprints,
854 assetlinksValidRelations: assetlinksSummary.validRelations,
855 assetlinksInvalidRelations: assetlinksSummary.invalidRelations,
856 assetlinksStatements: assetlinksSummary.statements,
857 assetlinksIssues: assetlinksSummary.issues,
858
859 score,
860 grade,
861 issues,
862 recommendations,
863 error: null,
864 };
865}
866
867
868
869
870
871const isExecutedDirectly = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
872
873if (process.env.NODE_ENV !== 'test' && isExecutedDirectly) {
874 await Actor.init();
875 try {
876 const input = await Actor.getInput();
877 const result = await auditAppLinks(input || {});
878 await Actor.pushData(result);
879 await Actor.setValue('OUTPUT', result);
880 Actor.log.info('App Links audit complete', {
881 inputUrl: result.inputUrl,
882 aasaFound: result.aasaFound,
883 assetlinksFound: result.assetlinksFound,
884 score: result.score,
885 grade: result.grade,
886 });
887 } finally {
888 await Actor.exit();
889 }
890}