@dailephd/my-dev-kit-lab 0.3.4 → 0.4.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.
Files changed (116) hide show
  1. package/README.md +46 -270
  2. package/dist/scripts/security/validate.js +177 -70
  3. package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +5 -2
  4. package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +5 -2
  5. package/dist/src/mobile/android/audit/androidFinding.js +68 -0
  6. package/dist/src/mobile/android/audit/checkResultBuilder.js +97 -0
  7. package/dist/src/mobile/android/audit/deepLinkAudit.js +183 -0
  8. package/dist/src/mobile/android/audit/exportedComponentAudit.js +183 -0
  9. package/dist/src/mobile/android/audit/intentFilterAudit.js +128 -0
  10. package/dist/src/mobile/android/audit/permissionAudit.js +142 -0
  11. package/dist/src/mobile/android/detect/androidTargetDetection.js +18 -0
  12. package/dist/src/mobile/android/detect/buildFileEvidence.js +36 -0
  13. package/dist/src/mobile/android/detect/classify.js +150 -0
  14. package/dist/src/mobile/android/detect/detectAndroidProject.js +252 -0
  15. package/dist/src/mobile/android/detect/settingsModules.js +60 -0
  16. package/dist/src/mobile/android/detect/sourceEvidence.js +92 -0
  17. package/dist/src/mobile/android/detect/traversal.js +79 -0
  18. package/dist/src/mobile/android/detect/versionCatalogEvidence.js +37 -0
  19. package/dist/src/mobile/android/detection.js +28 -0
  20. package/dist/src/mobile/android/gradle/gradleMetadataCheckResult.js +120 -0
  21. package/dist/src/mobile/android/gradle/moduleMetadataExtractor.js +137 -0
  22. package/dist/src/mobile/android/gradle/pluginVersionExtractor.js +40 -0
  23. package/dist/src/mobile/android/gradle/readAndroidGradleMetadata.js +210 -0
  24. package/dist/src/mobile/android/gradle/releaseMetadataSummary.js +64 -0
  25. package/dist/src/mobile/android/gradle/settingsMetadata.js +20 -0
  26. package/dist/src/mobile/android/gradle/types.js +1 -0
  27. package/dist/src/mobile/android/gradle/validate/executor.js +9 -0
  28. package/dist/src/mobile/android/gradle/validate/operationCheckResult.js +99 -0
  29. package/dist/src/mobile/android/gradle/validate/operations.js +68 -0
  30. package/dist/src/mobile/android/gradle/validate/planner.js +49 -0
  31. package/dist/src/mobile/android/gradle/validate/runOptionalGradleValidation.js +113 -0
  32. package/dist/src/mobile/android/gradle/validate/targetMutation.js +45 -0
  33. package/dist/src/mobile/android/gradle/validate/taskListParser.js +24 -0
  34. package/dist/src/mobile/android/gradle/versionCatalogMetadata.js +94 -0
  35. package/dist/src/mobile/android/gradle/wrapperMetadata.js +32 -0
  36. package/dist/src/mobile/android/manifest/parseAndroidManifest.js +341 -0
  37. package/dist/src/mobile/android/manifest/types.js +29 -0
  38. package/dist/src/mobile/android/manifest/xml/parseXml.js +313 -0
  39. package/dist/src/mobile/android/profile.js +29 -0
  40. package/dist/src/mobile/android/report/model.js +104 -0
  41. package/dist/src/mobile/android/report/renderAndroidReport.js +223 -0
  42. package/dist/src/mobile/android/report/writeAndroidReportFiles.js +23 -0
  43. package/dist/src/mobile/android/targetMetadata.js +27 -0
  44. package/dist/src/mobile/android/validate/androidVerdict.js +248 -0
  45. package/dist/src/mobile/android/validate/detectionAndImmutabilityCheckResults.js +177 -0
  46. package/dist/src/mobile/android/validate/playReadinessChecklist.js +145 -0
  47. package/dist/src/mobile/android/validate/validateAndroidTarget.js +172 -0
  48. package/dist/src/mobile/android/validation/checkResult.js +51 -0
  49. package/dist/src/mobile/android/validation/result.js +25 -0
  50. package/dist/src/mobile/index.js +42 -0
  51. package/dist/src/mobile/types.js +27 -0
  52. package/dist/src/securityValidation/attackScenarios/attackProfile.js +2 -0
  53. package/dist/src/securityValidation/attackScenarios/profiles/androidProfile.js +20 -0
  54. package/dist/src/securityValidation/validate/cliOptions.js +46 -2
  55. package/package.json +2 -1
  56. package/tests/fixtures/android/compose-app/app/build.gradle.kts +21 -0
  57. package/tests/fixtures/android/compose-app/app/src/main/AndroidManifest.xml +22 -0
  58. package/tests/fixtures/android/compose-app/app/src/main/java/com/example/composeapp/MainActivity.kt +11 -0
  59. package/tests/fixtures/android/compose-app/build.gradle.kts +4 -0
  60. package/tests/fixtures/android/compose-app/gradle/wrapper/gradle-wrapper.properties +4 -0
  61. package/tests/fixtures/android/compose-app/settings.gradle.kts +2 -0
  62. package/tests/fixtures/android/gradle-metadata-fixtures/catalog-only-plugin/build.gradle.kts +3 -0
  63. package/tests/fixtures/android/gradle-metadata-fixtures/catalog-only-plugin/gradle/libs.versions.toml +5 -0
  64. package/tests/fixtures/android/gradle-metadata-fixtures/comment-safety.gradle.kts +21 -0
  65. package/tests/fixtures/android/gradle-metadata-fixtures/dynamic-values.gradle.kts +17 -0
  66. package/tests/fixtures/android/gradle-metadata-fixtures/groovy-literal.gradle +23 -0
  67. package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-malformed.properties +4 -0
  68. package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-missing-url.properties +3 -0
  69. package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-with-checksum.properties +5 -0
  70. package/tests/fixtures/android/java-app/app/build.gradle +16 -0
  71. package/tests/fixtures/android/java-app/app/src/main/AndroidManifest.xml +19 -0
  72. package/tests/fixtures/android/java-app/app/src/main/java/com/example/javaapp/MainActivity.java +4 -0
  73. package/tests/fixtures/android/java-app/build.gradle +3 -0
  74. package/tests/fixtures/android/java-app/gradle/wrapper/gradle-wrapper.properties +4 -0
  75. package/tests/fixtures/android/java-app/settings.gradle +2 -0
  76. package/tests/fixtures/android/library/build.gradle.kts +3 -0
  77. package/tests/fixtures/android/library/gradle/wrapper/gradle-wrapper.properties +4 -0
  78. package/tests/fixtures/android/library/mylibrary/build.gradle.kts +13 -0
  79. package/tests/fixtures/android/library/mylibrary/src/main/AndroidManifest.xml +2 -0
  80. package/tests/fixtures/android/library/settings.gradle.kts +2 -0
  81. package/tests/fixtures/android/manifest-audit-fixtures/incomplete-intent-filter.xml +14 -0
  82. package/tests/fixtures/android/manifest-audit-fixtures/malformed-exported.xml +10 -0
  83. package/tests/fixtures/android/manifest-audit-fixtures/malformed.xml +5 -0
  84. package/tests/fixtures/android/manifest-audit-fixtures/missing-application.xml +7 -0
  85. package/tests/fixtures/android/manifest-audit-fixtures/missing-component-name.xml +11 -0
  86. package/tests/fixtures/android/manifest-audit-fixtures/permission-coverage.xml +29 -0
  87. package/tests/fixtures/android/manifest-audit-fixtures/risky-components.xml +62 -0
  88. package/tests/fixtures/android/manifest-audit-fixtures/risky-deep-links.xml +45 -0
  89. package/tests/fixtures/android/manifest-audit-fixtures/unresolved-placeholder.xml +14 -0
  90. package/tests/fixtures/android/manifest-audit-fixtures/unusual-namespace-prefix.xml +11 -0
  91. package/tests/fixtures/android/multi-module/app/build.gradle.kts +22 -0
  92. package/tests/fixtures/android/multi-module/app/src/androidTest/java/com/example/multimodule/app/ExampleInstrumentedTest.kt +3 -0
  93. package/tests/fixtures/android/multi-module/app/src/main/AndroidManifest.xml +11 -0
  94. package/tests/fixtures/android/multi-module/app/src/test/java/com/example/multimodule/app/ExampleUnitTest.kt +3 -0
  95. package/tests/fixtures/android/multi-module/build.gradle.kts +5 -0
  96. package/tests/fixtures/android/multi-module/core/build.gradle.kts +14 -0
  97. package/tests/fixtures/android/multi-module/core/src/main/AndroidManifest.xml +2 -0
  98. package/tests/fixtures/android/multi-module/feature-login/build.gradle.kts +18 -0
  99. package/tests/fixtures/android/multi-module/feature-login/src/main/AndroidManifest.xml +2 -0
  100. package/tests/fixtures/android/multi-module/gradle/libs.versions.toml +8 -0
  101. package/tests/fixtures/android/multi-module/gradle/wrapper/gradle-wrapper.properties +4 -0
  102. package/tests/fixtures/android/multi-module/settings.gradle.kts +4 -0
  103. package/tests/fixtures/android/non-android-gradle/build.gradle.kts +3 -0
  104. package/tests/fixtures/android/non-android-gradle/core/build.gradle.kts +3 -0
  105. package/tests/fixtures/android/non-android-gradle/core/src/main/java/com/example/core/Core.java +4 -0
  106. package/tests/fixtures/android/non-android-gradle/gradle/wrapper/gradle-wrapper.properties +4 -0
  107. package/tests/fixtures/android/non-android-gradle/settings.gradle.kts +2 -0
  108. package/tests/fixtures/android/partial/app/build.gradle.kts +7 -0
  109. package/tests/fixtures/android/partial/settings.gradle.kts +2 -0
  110. package/tests/fixtures/android/xml-view-app/app/build.gradle.kts +17 -0
  111. package/tests/fixtures/android/xml-view-app/app/src/main/AndroidManifest.xml +31 -0
  112. package/tests/fixtures/android/xml-view-app/app/src/main/kotlin/com/example/xmlviewapp/MainActivity.kt +7 -0
  113. package/tests/fixtures/android/xml-view-app/app/src/main/res/layout/activity_main.xml +4 -0
  114. package/tests/fixtures/android/xml-view-app/build.gradle.kts +4 -0
  115. package/tests/fixtures/android/xml-view-app/gradle/wrapper/gradle-wrapper.properties +4 -0
  116. package/tests/fixtures/android/xml-view-app/settings.gradle.kts +2 -0
@@ -42,6 +42,7 @@ export const ARCHITECTURE_DRIFT_DETECTOR = {
42
42
  },
43
43
  run: (ctx) => {
44
44
  const issues = [];
45
+ const hasAndroidValidationSource = fs.existsSync(path.join(ctx.target.rootPath, "src", "mobile", "android", "validate", "validateAndroidTarget.ts"));
45
46
  const currentStateDocs = ctx.inventory.docsFiles.filter((f) => CURRENT_STATE_DOC_BASENAMES.has(path.basename(f.relativePath).toLowerCase()));
46
47
  for (const docFile of currentStateDocs) {
47
48
  const content = readBoundedFileText(ctx.target.rootPath, docFile.relativePath, docFile.sizeBytes, MAX_READ_BYTES);
@@ -61,7 +62,7 @@ export const ARCHITECTURE_DRIFT_DETECTOR = {
61
62
  const content = readBoundedFileText(ctx.target.rootPath, docFile.relativePath, docFile.sizeBytes, MAX_READ_BYTES);
62
63
  if (content === null)
63
64
  continue;
64
- issues.push(...findUnsupportedCurrentClaims(docFile.relativePath, content));
65
+ issues.push(...findUnsupportedCurrentClaims(docFile.relativePath, content, hasAndroidValidationSource));
65
66
  }
66
67
  return deduplicateIssuesById(issues);
67
68
  },
@@ -227,13 +228,15 @@ function findMajorDirsAbsentFromArchitectureDoc(ctx, docRelativePath, content) {
227
228
  }
228
229
  return issues;
229
230
  }
230
- function findUnsupportedCurrentClaims(docRelativePath, content) {
231
+ function findUnsupportedCurrentClaims(docRelativePath, content, hasAndroidValidationSource) {
231
232
  const issues = [];
232
233
  const subjects = [
233
234
  { id: "manual-pentest", label: "manual pentest", pattern: "manual pentest" },
234
235
  { id: "android-validation", label: "Android/mobile validation", pattern: "(?:android (?:validation|support))" },
235
236
  ];
236
237
  for (const subject of subjects) {
238
+ if (subject.id === "android-validation" && hasAndroidValidationSource)
239
+ continue;
237
240
  const pattern = currentClaimPatternFor(subject.pattern);
238
241
  const match = pattern.exec(content);
239
242
  if (!match)
@@ -67,6 +67,7 @@ export const SECURITY_VALIDATION_ASSUMPTION_ROT_DETECTOR = {
67
67
  const pkgVersion = ctx.sourceOfTruth.package?.version ?? null;
68
68
  const hasSecurityValidationSourceDir = ctx.sourceOfTruth.security.hasSecurityValidationSourceDir;
69
69
  const hasAttackScenariosDir = fs.existsSync(path.join(ctx.target.rootPath, "src", "securityValidation", "attackScenarios"));
70
+ const hasAndroidValidationSource = fs.existsSync(path.join(ctx.target.rootPath, "src", "mobile", "android", "validate", "validateAndroidTarget.ts"));
70
71
  for (const docFile of ctx.inventory.docsFiles) {
71
72
  const content = readBoundedFileText(ctx.target.rootPath, docFile.relativePath, docFile.sizeBytes, MAX_READ_BYTES);
72
73
  if (content === null)
@@ -75,7 +76,7 @@ export const SECURITY_VALIDATION_ASSUMPTION_ROT_DETECTOR = {
75
76
  issues.push(...findSkippedEqualsPassed(docFile.relativePath, content));
76
77
  issues.push(...findExhaustiveSecretClaim(docFile.relativePath, content));
77
78
  issues.push(...findCompleteNetworkIsolationClaim(docFile.relativePath, content));
78
- issues.push(...findUnsupportedCurrentClaims(docFile.relativePath, content));
79
+ issues.push(...findUnsupportedCurrentClaims(docFile.relativePath, content, hasAndroidValidationSource));
79
80
  if (pkgVersion)
80
81
  issues.push(...findStaleV021SecurityMention(docFile.relativePath, content, pkgVersion));
81
82
  if (hasSecurityValidationSourceDir && hasAttackScenariosDir) {
@@ -237,13 +238,15 @@ function findCompleteNetworkIsolationClaim(relativePath, content) {
237
238
  }),
238
239
  ];
239
240
  }
240
- function findUnsupportedCurrentClaims(relativePath, content) {
241
+ function findUnsupportedCurrentClaims(relativePath, content, hasAndroidValidationSource) {
241
242
  const issues = [];
242
243
  const subjects = [
243
244
  { id: "manual-pentest", label: "manual pentest", pattern: "manual pentest" },
244
245
  { id: "android-validation", label: "Android/mobile validation", pattern: "(?:android (?:validation|support)|mobile validation)" },
245
246
  ];
246
247
  for (const subject of subjects) {
248
+ if (subject.id === "android-validation" && hasAndroidValidationSource)
249
+ continue;
247
250
  const match = currentClaimPatternFor(subject.pattern).exec(content);
248
251
  if (!match)
249
252
  continue;
@@ -0,0 +1,68 @@
1
+ // ---------------------------------------------------------------------------
2
+ // v0.4.0 Batch 3 — Android SecurityFinding factory.
3
+ //
4
+ // Reuses SecurityFinding as-is (no parallel Android finding schema). Android
5
+ // findings use category "static-scan" — an existing SecurityCheckCategory
6
+ // value that already covers static-analysis-derived findings (CodeQL,
7
+ // Semgrep) and accurately describes manifest analysis, so no widening of the
8
+ // shared, heavily-tested SecurityCheckCategory union was needed. Per-finding
9
+ // audit family is instead carried by the owning AndroidCheckResult.category
10
+ // (android-permissions/android-components/android-intent-filters/
11
+ // android-deep-links) and by the finding id/title.
12
+ //
13
+ // SecurityFinding has no `confidence` or structured `location` field (the
14
+ // existing codebase convention, e.g. AttackResult, keeps confidence on the
15
+ // *check result*, not the individual finding — see
16
+ // src/securityValidation/attackScenarios/attackResult.ts). Android check
17
+ // results already carry `confidence: MobileConfidence` (Batch 1). Per-finding
18
+ // confidence and location are therefore folded into the free-text `evidence`
19
+ // field here, consistent with how existing scenario code joins evidence
20
+ // previews into that same field.
21
+ // ---------------------------------------------------------------------------
22
+ function slug(value) {
23
+ return value
24
+ .replace(/[^a-zA-Z0-9]+/g, "-")
25
+ .replace(/^-+|-+$/g, "")
26
+ .toLowerCase();
27
+ }
28
+ // Deterministic, content-derived id — never depends on a counter, timestamp,
29
+ // or enumeration order, so equivalent evidence always yields the same id.
30
+ function buildFindingId(ruleId, manifestPath, identity, location) {
31
+ const parts = [ruleId, slug(manifestPath), identity ? slug(identity) : undefined, location?.line !== undefined ? `l${location.line}` : undefined].filter((part) => Boolean(part));
32
+ return parts.join("--");
33
+ }
34
+ function defaultReleaseImpact(severity) {
35
+ switch (severity) {
36
+ case "blocker":
37
+ return "Must fix before release";
38
+ case "major":
39
+ return "Should fix before release";
40
+ case "minor":
41
+ return "Review recommended before release";
42
+ case "informational":
43
+ return "No release impact; informational only";
44
+ case "skipped":
45
+ return "Not evaluated";
46
+ }
47
+ }
48
+ export function makeAndroidFinding(input) {
49
+ const id = buildFindingId(input.ruleId, input.manifestPath, input.identity, input.location);
50
+ const locationSuffix = input.location?.line !== undefined ? `:${input.location.line}` : "";
51
+ const evidenceParts = [
52
+ `confidence=${input.confidence}`,
53
+ input.identity ? `identity=${input.identity}` : undefined,
54
+ `location=${input.manifestPath}${locationSuffix}`,
55
+ ...(input.evidenceDetails ?? []),
56
+ ].filter((part) => Boolean(part));
57
+ return {
58
+ id,
59
+ title: input.title,
60
+ severity: input.severity,
61
+ category: "static-scan",
62
+ description: input.description,
63
+ evidence: evidenceParts.join(" | "),
64
+ affectedFiles: [input.manifestPath],
65
+ recommendation: input.recommendation,
66
+ releaseImpact: input.releaseImpact ?? defaultReleaseImpact(input.severity),
67
+ };
68
+ }
@@ -0,0 +1,97 @@
1
+ function isMalformed(entry) {
2
+ return entry.manifest.parseWarnings.some((w) => w.startsWith("Malformed XML:"));
3
+ }
4
+ export function buildAndroidManifestCheckResult(input) {
5
+ const { id, category, title, detection, manifests, findings, evidence, warnings } = input;
6
+ const sourcePaths = manifests.map((m) => m.manifestPath);
7
+ if (detection.projectKind === "non-android") {
8
+ return {
9
+ id,
10
+ category,
11
+ title,
12
+ status: "unsupported",
13
+ requirementLevel: "optional",
14
+ ran: false,
15
+ skipped: true,
16
+ skipInfo: {
17
+ checkId: id,
18
+ reason: "Target was not detected as an Android project.",
19
+ requirementLevel: "optional",
20
+ missingCapability: "android-detection",
21
+ verdictImpact: "does not apply to a non-Android target",
22
+ recommendedNextAction: "Re-run against an Android Gradle project, or verify project detection.",
23
+ },
24
+ evidence: [],
25
+ findings: [],
26
+ warnings: [],
27
+ errors: [],
28
+ sourcePaths: [],
29
+ confidence: "unknown",
30
+ environmentRequirements: [],
31
+ };
32
+ }
33
+ if (manifests.length === 0) {
34
+ return {
35
+ id,
36
+ category,
37
+ title,
38
+ status: "inconclusive",
39
+ requirementLevel: "required",
40
+ ran: true,
41
+ skipped: false,
42
+ evidence,
43
+ findings: [],
44
+ warnings: [...warnings, "No AndroidManifest.xml was found for this target; audit could not run."],
45
+ errors: [],
46
+ sourcePaths: [],
47
+ confidence: "unknown",
48
+ environmentRequirements: [],
49
+ };
50
+ }
51
+ const malformedEntries = manifests.filter(isMalformed);
52
+ const allMalformed = malformedEntries.length === manifests.length;
53
+ if (allMalformed) {
54
+ return {
55
+ id,
56
+ category,
57
+ title,
58
+ status: "error",
59
+ requirementLevel: "required",
60
+ ran: true,
61
+ skipped: false,
62
+ evidence,
63
+ findings: [],
64
+ warnings,
65
+ errors: malformedEntries.flatMap((entry) => entry.manifest.parseWarnings.filter((w) => w.startsWith("Malformed XML:"))),
66
+ sourcePaths,
67
+ confidence: "unknown",
68
+ environmentRequirements: [],
69
+ };
70
+ }
71
+ const confidence = malformedEntries.length > 0 ? "medium" : "high";
72
+ // Informational-only findings (e.g. "standard launcher activity is
73
+ // exported, as expected") are evidence/inventory, not a reportable issue —
74
+ // only minor/major/blocker findings cause a "failed" status, matching
75
+ // section 7.17's "completed and found no reportable issues may pass".
76
+ const hasReportableFinding = findings.some((finding) => finding.severity !== "informational");
77
+ const status = hasReportableFinding ? "failed" : "passed";
78
+ return {
79
+ id,
80
+ category,
81
+ title,
82
+ status,
83
+ requirementLevel: "required",
84
+ ran: true,
85
+ skipped: false,
86
+ evidence,
87
+ findings,
88
+ warnings: [
89
+ ...warnings,
90
+ ...malformedEntries.flatMap((entry) => entry.manifest.parseWarnings.filter((w) => w.startsWith("Malformed XML:"))),
91
+ ],
92
+ errors: [],
93
+ sourcePaths,
94
+ confidence,
95
+ environmentRequirements: [],
96
+ };
97
+ }
@@ -0,0 +1,183 @@
1
+ import { makeAndroidFinding } from "./androidFinding.js";
2
+ import { buildAndroidManifestCheckResult } from "./checkResultBuilder.js";
3
+ // ---------------------------------------------------------------------------
4
+ // v0.4.0 Batch 3 — initial deep-link audit.
5
+ //
6
+ // Distinguishes web links (http/https) from custom-scheme deep links, flags
7
+ // missing host/scheme restrictions and broad path patterns, and treats
8
+ // BROWSABLE-without-VIEW and VIEW-without-BROWSABLE as separate "incomplete
9
+ // filter" observations (agents.txt Batch 3 section 7.16). Never claims
10
+ // Digital Asset Links verification, domain ownership, or exploitability —
11
+ // findings are framed as static exposure/configuration review, at most
12
+ // "major" for the narrow high-confidence case of an explicitly exported,
13
+ // unprotected, wildcard-path activity.
14
+ // ---------------------------------------------------------------------------
15
+ const VIEW_ACTION = "android.intent.action.VIEW";
16
+ const BROWSABLE_CATEGORY = "android.intent.category.BROWSABLE";
17
+ const WILDCARD_PATTERN = /[*.]{1,2}\*|\.\*/;
18
+ function isPlaceholder(value) {
19
+ return value !== undefined && (value.includes("${") || value.startsWith("@"));
20
+ }
21
+ function classifyScheme(dataElements) {
22
+ const schemes = dataElements.map((d) => d.dataScheme).filter((s) => Boolean(s));
23
+ if (schemes.length === 0)
24
+ return "none";
25
+ if (schemes.some((s) => s === "http" || s === "https"))
26
+ return "web";
27
+ return "custom";
28
+ }
29
+ function auditDeepLinkFilter(entry, kind, component, filter, findings) {
30
+ const identity = component.name || "(unnamed component)";
31
+ const actions = filter.actions ?? [];
32
+ const categories = filter.categories ?? [];
33
+ const dataElements = filter.dataElements ?? filter.filterData;
34
+ const hasView = actions.includes(VIEW_ACTION);
35
+ const hasBrowsable = categories.includes(BROWSABLE_CATEGORY);
36
+ if (!hasView && !hasBrowsable)
37
+ return;
38
+ if (hasBrowsable && !hasView) {
39
+ findings.push(makeAndroidFinding({
40
+ ruleId: "android-deep-link-browsable-without-view",
41
+ title: `BROWSABLE category without a VIEW action on ${kind} "${identity}"`,
42
+ severity: "informational",
43
+ confidence: "high",
44
+ description: "This <intent-filter> declares category BROWSABLE without action VIEW, so it will not actually be treated as a browsable web/deep link by the platform.",
45
+ manifestPath: entry.manifestPath,
46
+ identity,
47
+ location: filter.location,
48
+ }));
49
+ return;
50
+ }
51
+ if (hasView && !hasBrowsable && dataElements.length === 0) {
52
+ // Plain VIEW with no data and no BROWSABLE — not a deep link, nothing to audit here.
53
+ return;
54
+ }
55
+ const exportedExplicitTrue = component.exported === true;
56
+ const unprotected = !component.permission;
57
+ const scheme = classifyScheme(dataElements);
58
+ const hasHost = dataElements.some((d) => Boolean(d.dataHost));
59
+ const placeholderEvidence = dataElements.some((d) => isPlaceholder(d.dataScheme) || isPlaceholder(d.dataHost) || isPlaceholder(d.dataPath) || isPlaceholder(d.dataPathPrefix) || isPlaceholder(d.dataPathPattern));
60
+ const broadPattern = dataElements.some((d) => (d.dataPathPattern && WILDCARD_PATTERN.test(d.dataPathPattern)) || d.dataPathPattern === ".*" || d.dataPathPattern === "*");
61
+ const evidenceDetails = [
62
+ `scheme=${scheme}`,
63
+ `hasHost=${hasHost}`,
64
+ `exported=${component.exported ?? "unspecified"}`,
65
+ `hasComponentPermission=${!unprotected}`,
66
+ `dataElementCount=${dataElements.length}`,
67
+ ];
68
+ if (placeholderEvidence) {
69
+ findings.push(makeAndroidFinding({
70
+ ruleId: "android-deep-link-placeholder-value",
71
+ title: `Deep-link data on ${kind} "${identity}" contains an unresolved placeholder or resource reference`,
72
+ severity: "informational",
73
+ confidence: "low",
74
+ description: "One or more deep-link data attributes (scheme/host/path) reference an unresolved manifest placeholder (e.g. ${applicationId}) or Android resource (e.g. @string/...). Effective exposure cannot be assessed statically.",
75
+ manifestPath: entry.manifestPath,
76
+ identity,
77
+ location: filter.location,
78
+ evidenceDetails,
79
+ }));
80
+ return;
81
+ }
82
+ if (broadPattern) {
83
+ const strongEvidence = exportedExplicitTrue && unprotected;
84
+ findings.push(makeAndroidFinding({
85
+ ruleId: "android-deep-link-broad-path-pattern",
86
+ title: `Broad/wildcard deep-link path pattern on ${kind} "${identity}"`,
87
+ severity: strongEvidence ? "major" : "minor",
88
+ confidence: strongEvidence ? "high" : "medium",
89
+ description: strongEvidence
90
+ ? "This deep-link filter uses a highly permissive pathPattern, the component is explicitly exported, and no component-level permission protects it — a strong static combination of broad matching and open reachability."
91
+ : "This deep-link filter uses a broad/wildcard-like pathPattern. Broad path matching is not automatically exploitable, but widens the set of URLs the app will handle.",
92
+ manifestPath: entry.manifestPath,
93
+ identity,
94
+ location: filter.location,
95
+ evidenceDetails,
96
+ recommendation: "Narrow the pathPattern to only the routes the app actually needs to handle, and validate any path/query data before use.",
97
+ }));
98
+ return;
99
+ }
100
+ if (scheme === "web" && !hasHost) {
101
+ findings.push(makeAndroidFinding({
102
+ ruleId: "android-deep-link-web-link-without-host",
103
+ title: `HTTP/HTTPS deep link on ${kind} "${identity}" has no host restriction`,
104
+ severity: "minor",
105
+ confidence: "medium",
106
+ description: "This web-link intent filter declares an http/https scheme without a host, so it is not restricted to a specific domain.",
107
+ manifestPath: entry.manifestPath,
108
+ identity,
109
+ location: filter.location,
110
+ evidenceDetails,
111
+ recommendation: "Add android:host to restrict which domains this filter matches.",
112
+ }));
113
+ return;
114
+ }
115
+ if (scheme === "custom" && !hasHost) {
116
+ findings.push(makeAndroidFinding({
117
+ ruleId: "android-deep-link-custom-scheme-without-host",
118
+ title: `Custom-scheme deep link on ${kind} "${identity}" has no host restriction`,
119
+ severity: "minor",
120
+ confidence: "medium",
121
+ description: "This custom-scheme intent filter declares no host restriction. Custom schemes are not automatically vulnerabilities, but an unrestricted scheme accepts any host/path combination.",
122
+ manifestPath: entry.manifestPath,
123
+ identity,
124
+ location: filter.location,
125
+ evidenceDetails,
126
+ recommendation: "Consider restricting the host or validating incoming URIs defensively in the handling code.",
127
+ }));
128
+ return;
129
+ }
130
+ // Bounded/safe deep link: explicit scheme + host (+ optional path
131
+ // restriction), no wildcard pattern, no placeholders — recorded for
132
+ // inventory purposes at informational severity only (ANDROID-B3-24).
133
+ findings.push(makeAndroidFinding({
134
+ ruleId: "android-deep-link-bounded",
135
+ title: `Deep link declared on ${kind} "${identity}"`,
136
+ severity: "informational",
137
+ confidence: "high",
138
+ description: "This deep-link filter declares an explicit scheme and host with bounded path evidence. No static exposure concern was identified.",
139
+ manifestPath: entry.manifestPath,
140
+ identity,
141
+ location: filter.location,
142
+ evidenceDetails,
143
+ }));
144
+ }
145
+ function componentGroups(entry) {
146
+ return [
147
+ { kind: "activity", components: entry.manifest.activities },
148
+ { kind: "activity-alias", components: entry.manifest.activityAliases ?? [] },
149
+ ];
150
+ }
151
+ export function auditAndroidDeepLinks(detection, manifests) {
152
+ const findings = [];
153
+ const evidence = [];
154
+ const warnings = [];
155
+ for (const entry of manifests) {
156
+ let candidateCount = 0;
157
+ for (const group of componentGroups(entry)) {
158
+ for (const component of group.components) {
159
+ for (const filter of component.intentFilters) {
160
+ const actions = filter.actions ?? [];
161
+ const categories = filter.categories ?? [];
162
+ if (actions.includes(VIEW_ACTION) || categories.includes(BROWSABLE_CATEGORY)) {
163
+ candidateCount += 1;
164
+ auditDeepLinkFilter(entry, group.kind, component, filter, findings);
165
+ }
166
+ }
167
+ }
168
+ }
169
+ if (candidateCount > 0) {
170
+ evidence.push(`${entry.manifestPath}: ${candidateCount} deep-link candidate intent-filter(s) inspected`);
171
+ }
172
+ }
173
+ return buildAndroidManifestCheckResult({
174
+ id: "android-deep-links-audit",
175
+ category: "android-deep-links",
176
+ title: "Android deep-link audit",
177
+ detection,
178
+ manifests,
179
+ findings,
180
+ evidence,
181
+ warnings,
182
+ });
183
+ }
@@ -0,0 +1,183 @@
1
+ import { makeAndroidFinding } from "./androidFinding.js";
2
+ import { buildAndroidManifestCheckResult } from "./checkResultBuilder.js";
3
+ function componentGroups(manifest) {
4
+ return [
5
+ { kind: "activity", components: manifest.activities },
6
+ { kind: "activity-alias", components: manifest.activityAliases ?? [] },
7
+ { kind: "service", components: manifest.services },
8
+ { kind: "receiver", components: manifest.receivers },
9
+ { kind: "provider", components: manifest.providers },
10
+ ];
11
+ }
12
+ function isProtectedNonProvider(component, applicationPermission) {
13
+ return Boolean(component.permission) || Boolean(applicationPermission);
14
+ }
15
+ function providerProtection(component) {
16
+ if (component.permission)
17
+ return "protected";
18
+ if (component.readPermission && component.writePermission)
19
+ return "protected";
20
+ if (component.readPermission || component.writePermission)
21
+ return "partial";
22
+ return "unprotected";
23
+ }
24
+ export function auditAndroidExportedComponents(detection, manifests) {
25
+ const findings = [];
26
+ const evidence = [];
27
+ const warnings = [];
28
+ for (const entry of manifests) {
29
+ const applicationPermission = entry.manifest.application.permission;
30
+ let inspectedCount = 0;
31
+ for (const group of componentGroups(entry.manifest)) {
32
+ for (const component of group.components) {
33
+ inspectedCount += 1;
34
+ const hasFilters = component.intentFilters.length > 0;
35
+ const identity = component.name || "(unnamed component)";
36
+ // Malformed exported value: never normalized to true or false.
37
+ if (component.exported === undefined && component.exportedRaw !== undefined) {
38
+ findings.push(makeAndroidFinding({
39
+ ruleId: "android-component-malformed-exported",
40
+ title: `${group.kind} "${identity}" has a malformed android:exported value`,
41
+ severity: "minor",
42
+ confidence: "low",
43
+ description: `android:exported="${component.exportedRaw}" could not be statically resolved to true or false. Effective exported state is unknown and must be reviewed manually.`,
44
+ manifestPath: entry.manifestPath,
45
+ identity,
46
+ location: component.location,
47
+ evidenceDetails: [`kind=${group.kind}`, `exportedRaw=${component.exportedRaw}`],
48
+ recommendation: "Resolve the android:exported value to an explicit true/false literal.",
49
+ }));
50
+ continue;
51
+ }
52
+ if (component.exported === false) {
53
+ continue;
54
+ }
55
+ if (component.exported === undefined) {
56
+ if (hasFilters) {
57
+ findings.push(makeAndroidFinding({
58
+ ruleId: "android-component-unspecified-exported-with-filter",
59
+ title: `${group.kind} "${identity}" has an intent filter but no explicit android:exported value`,
60
+ severity: "minor",
61
+ confidence: "medium",
62
+ description: "This component declares an intent filter but does not set android:exported explicitly. Effective exported state depends on the target/compile SDK and manifest context, and must be reviewed rather than assumed.",
63
+ manifestPath: entry.manifestPath,
64
+ identity,
65
+ location: component.location,
66
+ evidenceDetails: [`kind=${group.kind}`, `intentFilterCount=${component.intentFilters.length}`],
67
+ recommendation: "Set android:exported explicitly to document the intended behavior.",
68
+ }));
69
+ }
70
+ continue;
71
+ }
72
+ // component.exported === true from here on.
73
+ if (group.kind === "provider") {
74
+ const protection = providerProtection(component);
75
+ if (protection === "unprotected") {
76
+ findings.push(makeAndroidFinding({
77
+ ruleId: "android-provider-exported-unprotected",
78
+ title: `Exported provider "${identity}" has no permission protection`,
79
+ severity: "major",
80
+ confidence: "high",
81
+ description: "This <provider> is explicitly exported and declares no permission, readPermission, or writePermission. Any app on the device may query or modify its data unless finer-grained protection exists elsewhere (e.g. path-level permissions not visible here).",
82
+ manifestPath: entry.manifestPath,
83
+ identity,
84
+ location: component.location,
85
+ evidenceDetails: [
86
+ `authorities=${(component.authorities ?? []).join(",") || "(none parsed)"}`,
87
+ `grantUriPermissions=${component.grantUriPermissions ?? "unspecified"}`,
88
+ ],
89
+ recommendation: "Add android:permission, or both readPermission and writePermission, or set android:exported=\"false\" if external access is not required.",
90
+ }));
91
+ }
92
+ else if (protection === "partial") {
93
+ findings.push(makeAndroidFinding({
94
+ ruleId: "android-provider-exported-partial-protection",
95
+ title: `Exported provider "${identity}" only protects one of read/write access`,
96
+ severity: "minor",
97
+ confidence: "medium",
98
+ description: "This <provider> defines only one of readPermission/writePermission, leaving the other operation unprotected.",
99
+ manifestPath: entry.manifestPath,
100
+ identity,
101
+ location: component.location,
102
+ evidenceDetails: [`readPermission=${component.readPermission ?? "(none)"}`, `writePermission=${component.writePermission ?? "(none)"}`],
103
+ recommendation: "Protect both read and write access, or document why only one is intentionally protected.",
104
+ }));
105
+ }
106
+ else {
107
+ findings.push(makeAndroidFinding({
108
+ ruleId: "android-provider-exported-protected",
109
+ title: `Exported provider "${identity}" is permission-protected`,
110
+ severity: "informational",
111
+ confidence: "high",
112
+ description: "This <provider> is explicitly exported but protection evidence (permission or read+write permissions) is present.",
113
+ manifestPath: entry.manifestPath,
114
+ identity,
115
+ location: component.location,
116
+ evidenceDetails: [`permission=${component.permission ?? "(none)"}`],
117
+ }));
118
+ }
119
+ continue;
120
+ }
121
+ const protected_ = isProtectedNonProvider(component, applicationPermission);
122
+ if (protected_) {
123
+ findings.push(makeAndroidFinding({
124
+ ruleId: "android-component-exported-protected",
125
+ title: `Exported ${group.kind} "${identity}" is permission-protected`,
126
+ severity: "informational",
127
+ confidence: "high",
128
+ description: `This ${group.kind} is explicitly exported and protected by ${component.permission ? `component-level permission "${component.permission}"` : `an application-level permission ("${applicationPermission}")`}.`,
129
+ manifestPath: entry.manifestPath,
130
+ identity,
131
+ location: component.location,
132
+ evidenceDetails: [`kind=${group.kind}`, `hasIntentFilters=${hasFilters}`, `permission=${component.permission ?? applicationPermission}`],
133
+ }));
134
+ }
135
+ else if (component.isLauncherActivity) {
136
+ // A standard launcher activity being exported and unprotected is
137
+ // expected, required Android behavior (it must be exported to be
138
+ // launchable from the home screen) — informational only, not a
139
+ // review-required finding (agents.txt Batch 3 section 7.19,
140
+ // "Standard launcher activity evidence" = informational/low tier).
141
+ findings.push(makeAndroidFinding({
142
+ ruleId: "android-component-launcher-activity-exported",
143
+ title: `Launcher activity "${identity}" is exported (expected)`,
144
+ severity: "informational",
145
+ confidence: "high",
146
+ description: "This is the app's launcher activity (MAIN/LAUNCHER intent filter). Being exported without permission protection is expected, standard behavior for a launcher entry point.",
147
+ manifestPath: entry.manifestPath,
148
+ identity,
149
+ location: component.location,
150
+ evidenceDetails: [`kind=${group.kind}`],
151
+ }));
152
+ }
153
+ else {
154
+ findings.push(makeAndroidFinding({
155
+ ruleId: "android-component-exported-unprotected",
156
+ title: `Exported ${group.kind} "${identity}" has no deterministic permission protection`,
157
+ severity: "minor",
158
+ confidence: hasFilters ? "high" : "medium",
159
+ description: `This ${group.kind} is explicitly exported and no component-level or application-level permission was found in this manifest. Effective protection could still exist via a permission defined in another source set or module, which lowers confidence here.`,
160
+ manifestPath: entry.manifestPath,
161
+ identity,
162
+ location: component.location,
163
+ evidenceDetails: [`kind=${group.kind}`, `hasIntentFilters=${hasFilters}`],
164
+ recommendation: "Add android:permission if this component should not be freely reachable by other apps, or confirm android:exported=\"true\" is intentional.",
165
+ }));
166
+ }
167
+ }
168
+ }
169
+ if (inspectedCount > 0) {
170
+ evidence.push(`${entry.manifestPath}: ${inspectedCount} component(s) inspected`);
171
+ }
172
+ }
173
+ return buildAndroidManifestCheckResult({
174
+ id: "android-exported-components-audit",
175
+ category: "android-components",
176
+ title: "Android exported-component audit",
177
+ detection,
178
+ manifests,
179
+ findings,
180
+ evidence,
181
+ warnings,
182
+ });
183
+ }