@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.
- package/README.md +46 -270
- package/dist/scripts/security/validate.js +177 -70
- package/dist/src/audits/codeRot/detectors/architectureDriftDetector.js +5 -2
- package/dist/src/audits/codeRot/detectors/securityValidationAssumptionRotDetector.js +5 -2
- package/dist/src/mobile/android/audit/androidFinding.js +68 -0
- package/dist/src/mobile/android/audit/checkResultBuilder.js +97 -0
- package/dist/src/mobile/android/audit/deepLinkAudit.js +183 -0
- package/dist/src/mobile/android/audit/exportedComponentAudit.js +183 -0
- package/dist/src/mobile/android/audit/intentFilterAudit.js +128 -0
- package/dist/src/mobile/android/audit/permissionAudit.js +142 -0
- package/dist/src/mobile/android/detect/androidTargetDetection.js +18 -0
- package/dist/src/mobile/android/detect/buildFileEvidence.js +36 -0
- package/dist/src/mobile/android/detect/classify.js +150 -0
- package/dist/src/mobile/android/detect/detectAndroidProject.js +252 -0
- package/dist/src/mobile/android/detect/settingsModules.js +60 -0
- package/dist/src/mobile/android/detect/sourceEvidence.js +92 -0
- package/dist/src/mobile/android/detect/traversal.js +79 -0
- package/dist/src/mobile/android/detect/versionCatalogEvidence.js +37 -0
- package/dist/src/mobile/android/detection.js +28 -0
- package/dist/src/mobile/android/gradle/gradleMetadataCheckResult.js +120 -0
- package/dist/src/mobile/android/gradle/moduleMetadataExtractor.js +137 -0
- package/dist/src/mobile/android/gradle/pluginVersionExtractor.js +40 -0
- package/dist/src/mobile/android/gradle/readAndroidGradleMetadata.js +210 -0
- package/dist/src/mobile/android/gradle/releaseMetadataSummary.js +64 -0
- package/dist/src/mobile/android/gradle/settingsMetadata.js +20 -0
- package/dist/src/mobile/android/gradle/types.js +1 -0
- package/dist/src/mobile/android/gradle/validate/executor.js +9 -0
- package/dist/src/mobile/android/gradle/validate/operationCheckResult.js +99 -0
- package/dist/src/mobile/android/gradle/validate/operations.js +68 -0
- package/dist/src/mobile/android/gradle/validate/planner.js +49 -0
- package/dist/src/mobile/android/gradle/validate/runOptionalGradleValidation.js +113 -0
- package/dist/src/mobile/android/gradle/validate/targetMutation.js +45 -0
- package/dist/src/mobile/android/gradle/validate/taskListParser.js +24 -0
- package/dist/src/mobile/android/gradle/versionCatalogMetadata.js +94 -0
- package/dist/src/mobile/android/gradle/wrapperMetadata.js +32 -0
- package/dist/src/mobile/android/manifest/parseAndroidManifest.js +341 -0
- package/dist/src/mobile/android/manifest/types.js +29 -0
- package/dist/src/mobile/android/manifest/xml/parseXml.js +313 -0
- package/dist/src/mobile/android/profile.js +29 -0
- package/dist/src/mobile/android/report/model.js +104 -0
- package/dist/src/mobile/android/report/renderAndroidReport.js +223 -0
- package/dist/src/mobile/android/report/writeAndroidReportFiles.js +23 -0
- package/dist/src/mobile/android/targetMetadata.js +27 -0
- package/dist/src/mobile/android/validate/androidVerdict.js +248 -0
- package/dist/src/mobile/android/validate/detectionAndImmutabilityCheckResults.js +177 -0
- package/dist/src/mobile/android/validate/playReadinessChecklist.js +145 -0
- package/dist/src/mobile/android/validate/validateAndroidTarget.js +172 -0
- package/dist/src/mobile/android/validation/checkResult.js +51 -0
- package/dist/src/mobile/android/validation/result.js +25 -0
- package/dist/src/mobile/index.js +42 -0
- package/dist/src/mobile/types.js +27 -0
- package/dist/src/securityValidation/attackScenarios/attackProfile.js +2 -0
- package/dist/src/securityValidation/attackScenarios/profiles/androidProfile.js +20 -0
- package/dist/src/securityValidation/validate/cliOptions.js +46 -2
- package/package.json +2 -1
- package/tests/fixtures/android/compose-app/app/build.gradle.kts +21 -0
- package/tests/fixtures/android/compose-app/app/src/main/AndroidManifest.xml +22 -0
- package/tests/fixtures/android/compose-app/app/src/main/java/com/example/composeapp/MainActivity.kt +11 -0
- package/tests/fixtures/android/compose-app/build.gradle.kts +4 -0
- package/tests/fixtures/android/compose-app/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/compose-app/settings.gradle.kts +2 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/catalog-only-plugin/build.gradle.kts +3 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/catalog-only-plugin/gradle/libs.versions.toml +5 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/comment-safety.gradle.kts +21 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/dynamic-values.gradle.kts +17 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/groovy-literal.gradle +23 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-malformed.properties +4 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-missing-url.properties +3 -0
- package/tests/fixtures/android/gradle-metadata-fixtures/wrapper-with-checksum.properties +5 -0
- package/tests/fixtures/android/java-app/app/build.gradle +16 -0
- package/tests/fixtures/android/java-app/app/src/main/AndroidManifest.xml +19 -0
- package/tests/fixtures/android/java-app/app/src/main/java/com/example/javaapp/MainActivity.java +4 -0
- package/tests/fixtures/android/java-app/build.gradle +3 -0
- package/tests/fixtures/android/java-app/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/java-app/settings.gradle +2 -0
- package/tests/fixtures/android/library/build.gradle.kts +3 -0
- package/tests/fixtures/android/library/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/library/mylibrary/build.gradle.kts +13 -0
- package/tests/fixtures/android/library/mylibrary/src/main/AndroidManifest.xml +2 -0
- package/tests/fixtures/android/library/settings.gradle.kts +2 -0
- package/tests/fixtures/android/manifest-audit-fixtures/incomplete-intent-filter.xml +14 -0
- package/tests/fixtures/android/manifest-audit-fixtures/malformed-exported.xml +10 -0
- package/tests/fixtures/android/manifest-audit-fixtures/malformed.xml +5 -0
- package/tests/fixtures/android/manifest-audit-fixtures/missing-application.xml +7 -0
- package/tests/fixtures/android/manifest-audit-fixtures/missing-component-name.xml +11 -0
- package/tests/fixtures/android/manifest-audit-fixtures/permission-coverage.xml +29 -0
- package/tests/fixtures/android/manifest-audit-fixtures/risky-components.xml +62 -0
- package/tests/fixtures/android/manifest-audit-fixtures/risky-deep-links.xml +45 -0
- package/tests/fixtures/android/manifest-audit-fixtures/unresolved-placeholder.xml +14 -0
- package/tests/fixtures/android/manifest-audit-fixtures/unusual-namespace-prefix.xml +11 -0
- package/tests/fixtures/android/multi-module/app/build.gradle.kts +22 -0
- package/tests/fixtures/android/multi-module/app/src/androidTest/java/com/example/multimodule/app/ExampleInstrumentedTest.kt +3 -0
- package/tests/fixtures/android/multi-module/app/src/main/AndroidManifest.xml +11 -0
- package/tests/fixtures/android/multi-module/app/src/test/java/com/example/multimodule/app/ExampleUnitTest.kt +3 -0
- package/tests/fixtures/android/multi-module/build.gradle.kts +5 -0
- package/tests/fixtures/android/multi-module/core/build.gradle.kts +14 -0
- package/tests/fixtures/android/multi-module/core/src/main/AndroidManifest.xml +2 -0
- package/tests/fixtures/android/multi-module/feature-login/build.gradle.kts +18 -0
- package/tests/fixtures/android/multi-module/feature-login/src/main/AndroidManifest.xml +2 -0
- package/tests/fixtures/android/multi-module/gradle/libs.versions.toml +8 -0
- package/tests/fixtures/android/multi-module/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/multi-module/settings.gradle.kts +4 -0
- package/tests/fixtures/android/non-android-gradle/build.gradle.kts +3 -0
- package/tests/fixtures/android/non-android-gradle/core/build.gradle.kts +3 -0
- package/tests/fixtures/android/non-android-gradle/core/src/main/java/com/example/core/Core.java +4 -0
- package/tests/fixtures/android/non-android-gradle/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/non-android-gradle/settings.gradle.kts +2 -0
- package/tests/fixtures/android/partial/app/build.gradle.kts +7 -0
- package/tests/fixtures/android/partial/settings.gradle.kts +2 -0
- package/tests/fixtures/android/xml-view-app/app/build.gradle.kts +17 -0
- package/tests/fixtures/android/xml-view-app/app/src/main/AndroidManifest.xml +31 -0
- package/tests/fixtures/android/xml-view-app/app/src/main/kotlin/com/example/xmlviewapp/MainActivity.kt +7 -0
- package/tests/fixtures/android/xml-view-app/app/src/main/res/layout/activity_main.xml +4 -0
- package/tests/fixtures/android/xml-view-app/build.gradle.kts +4 -0
- package/tests/fixtures/android/xml-view-app/gradle/wrapper/gradle-wrapper.properties +4 -0
- package/tests/fixtures/android/xml-view-app/settings.gradle.kts +2 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.4.0 Batch 4 — per-operation Gradle validation check-result construction
|
|
3
|
+
// (agents.txt Batch 4 section 7.26).
|
|
4
|
+
//
|
|
5
|
+
// Status semantics differ slightly by operation:
|
|
6
|
+
// - wrapper-version: nonzero/spawn-failure -> "error" (this is the
|
|
7
|
+
// simplest possible invocation; a failure here is an orchestration
|
|
8
|
+
// problem, not a build/environment nuance).
|
|
9
|
+
// - tasks: nonzero/spawn-failure -> "inconclusive" (configuration or
|
|
10
|
+
// environment prevented task discovery, not a security-relevant
|
|
11
|
+
// failure).
|
|
12
|
+
// - assemble-debug / unit-test-debug / lint-debug: spawn-failure ->
|
|
13
|
+
// "inconclusive" (environment incomplete); nonzero exit with a real
|
|
14
|
+
// process result -> "failed" (a genuine build/test/lint failure is
|
|
15
|
+
// validation evidence, never a fabricated SecurityFinding — section
|
|
16
|
+
// 7.27).
|
|
17
|
+
// A timed-out operation is always "inconclusive", never "passed" or
|
|
18
|
+
// silently "error" (section 7.22).
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const MAX_OUTPUT_SUMMARY_LENGTH = 4000;
|
|
21
|
+
function boundedSummary(text) {
|
|
22
|
+
if (text.length <= MAX_OUTPUT_SUMMARY_LENGTH)
|
|
23
|
+
return { summary: text, truncated: false };
|
|
24
|
+
return { summary: text.slice(0, MAX_OUTPUT_SUMMARY_LENGTH), truncated: true };
|
|
25
|
+
}
|
|
26
|
+
function statusForResult(operationId, result) {
|
|
27
|
+
if (result.timedOut)
|
|
28
|
+
return "inconclusive";
|
|
29
|
+
if (result.exitCode === null) {
|
|
30
|
+
return operationId === "wrapper-version" ? "error" : "inconclusive";
|
|
31
|
+
}
|
|
32
|
+
if (result.exitCode === 0)
|
|
33
|
+
return "passed";
|
|
34
|
+
if (operationId === "wrapper-version")
|
|
35
|
+
return "error";
|
|
36
|
+
if (operationId === "tasks")
|
|
37
|
+
return "inconclusive";
|
|
38
|
+
return "failed";
|
|
39
|
+
}
|
|
40
|
+
function confidenceForStatus(status) {
|
|
41
|
+
switch (status) {
|
|
42
|
+
case "passed":
|
|
43
|
+
case "failed":
|
|
44
|
+
return "high";
|
|
45
|
+
case "inconclusive":
|
|
46
|
+
return "medium";
|
|
47
|
+
default:
|
|
48
|
+
return "unknown";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function buildGradleOperationCheckResult(plan, result, mutation) {
|
|
52
|
+
const status = statusForResult(plan.operationId, result);
|
|
53
|
+
const confidence = confidenceForStatus(status);
|
|
54
|
+
const stdoutBound = boundedSummary(result.stdout);
|
|
55
|
+
const stderrBound = boundedSummary(result.stderr);
|
|
56
|
+
const warnings = [];
|
|
57
|
+
if (result.timedOut)
|
|
58
|
+
warnings.push(`Operation timed out after ${plan.timeoutMs}ms.`);
|
|
59
|
+
if (result.exitCode === null && !result.timedOut)
|
|
60
|
+
warnings.push("Command could not be spawned or resolved (see stderr evidence).");
|
|
61
|
+
if (stdoutBound.truncated)
|
|
62
|
+
warnings.push("stdout was truncated for this result.");
|
|
63
|
+
if (stderrBound.truncated)
|
|
64
|
+
warnings.push("stderr was truncated for this result.");
|
|
65
|
+
if (!mutation.comparable)
|
|
66
|
+
warnings.push(`Target mutation evidence unavailable: ${mutation.reason ?? "unknown reason"}`);
|
|
67
|
+
if (mutation.unexpectedChanges.length > 0) {
|
|
68
|
+
warnings.push(`Unexpected target change(s) observed after this operation: ${mutation.unexpectedChanges.join(", ")}`);
|
|
69
|
+
}
|
|
70
|
+
const boundedCommand = {
|
|
71
|
+
...result,
|
|
72
|
+
stdout: stdoutBound.summary,
|
|
73
|
+
stderr: stderrBound.summary,
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
id: `android-gradle-${plan.operationId}`,
|
|
77
|
+
category: "android-gradle",
|
|
78
|
+
title: `Gradle operation: ${plan.operationId}`,
|
|
79
|
+
status,
|
|
80
|
+
requirementLevel: "optional",
|
|
81
|
+
ran: true,
|
|
82
|
+
skipped: false,
|
|
83
|
+
evidence: [
|
|
84
|
+
`exitCode=${result.exitCode ?? "null"}`,
|
|
85
|
+
`durationMs=${result.durationMs}`,
|
|
86
|
+
`expectedGeneratedPaths=${plan.expectedGeneratedPaths.join(",") || "(none)"}`,
|
|
87
|
+
...mutation.expectedGeneratedChanges.map((p) => `generatedOutput=${p}`),
|
|
88
|
+
],
|
|
89
|
+
findings: [],
|
|
90
|
+
warnings,
|
|
91
|
+
errors: result.exitCode === null && !result.timedOut ? [stderrBound.summary || "Command could not be spawned or resolved."] : [],
|
|
92
|
+
durationMs: result.durationMs,
|
|
93
|
+
command: boundedCommand,
|
|
94
|
+
sourcePaths: [plan.wrapperExecutablePath],
|
|
95
|
+
confidence,
|
|
96
|
+
environmentRequirements: plan.environmentLimitations,
|
|
97
|
+
targetModificationObserved: mutation.comparable ? mutation.unexpectedChanges.length > 0 : undefined,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.4.0 Batch 4 — allowlisted optional Gradle validation operations
|
|
3
|
+
// (agents.txt Batch 4 section 7.17).
|
|
4
|
+
//
|
|
5
|
+
// This is the ONLY place operation-to-argument mappings are defined. The
|
|
6
|
+
// planner (planner.ts) rejects any operation id not in ALLOWLISTED_OPERATION_IDS
|
|
7
|
+
// before a command is ever constructed — arbitrary task strings are never
|
|
8
|
+
// accepted from a caller.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
export const ALLOWLISTED_OPERATION_IDS = ["wrapper-version", "tasks", "assemble-debug", "unit-test-debug", "lint-debug"];
|
|
11
|
+
export function isAllowlistedOperationId(value) {
|
|
12
|
+
return ALLOWLISTED_OPERATION_IDS.includes(value);
|
|
13
|
+
}
|
|
14
|
+
// Conservative, bounded timeouts (agents.txt section 7.22). No retries, no
|
|
15
|
+
// unbounded execution — a timed-out operation is terminated and reported
|
|
16
|
+
// inconclusive/error, never passed.
|
|
17
|
+
const SHORT_TIMEOUT_MS = 30_000;
|
|
18
|
+
const MODERATE_TIMEOUT_MS = 120_000;
|
|
19
|
+
const LONG_TIMEOUT_MS = 600_000;
|
|
20
|
+
// Safety flags applied to every operation: --no-daemon avoids leaving a
|
|
21
|
+
// background Gradle daemon running after this process exits, --console=plain
|
|
22
|
+
// keeps output parseable, and --offline prevents this batch from
|
|
23
|
+
// intentionally triggering network access (agents.txt section 7.18) — the
|
|
24
|
+
// tradeoff is that operations needing uncached dependencies will fail or
|
|
25
|
+
// report inconclusive rather than silently downloading them.
|
|
26
|
+
const SAFETY_FLAGS = ["--no-daemon", "--console=plain"];
|
|
27
|
+
const OFFLINE_SAFETY_FLAGS = [...SAFETY_FLAGS, "--offline"];
|
|
28
|
+
export const GRADLE_OPERATIONS = {
|
|
29
|
+
"wrapper-version": {
|
|
30
|
+
id: "wrapper-version",
|
|
31
|
+
args: ["--version"],
|
|
32
|
+
timeoutMs: SHORT_TIMEOUT_MS,
|
|
33
|
+
mayCreateBuildOutputs: true, // Gradle creates/updates .gradle/ on first invocation.
|
|
34
|
+
description: "Reports the Gradle version the wrapper would use, without building anything.",
|
|
35
|
+
},
|
|
36
|
+
tasks: {
|
|
37
|
+
id: "tasks",
|
|
38
|
+
gradleTaskName: "tasks",
|
|
39
|
+
args: ["tasks", "--all", ...OFFLINE_SAFETY_FLAGS],
|
|
40
|
+
timeoutMs: MODERATE_TIMEOUT_MS,
|
|
41
|
+
mayCreateBuildOutputs: true,
|
|
42
|
+
description: "Lists available Gradle tasks so later operations can be skipped safely when unavailable.",
|
|
43
|
+
},
|
|
44
|
+
"assemble-debug": {
|
|
45
|
+
id: "assemble-debug",
|
|
46
|
+
gradleTaskName: "assembleDebug",
|
|
47
|
+
args: ["assembleDebug", ...OFFLINE_SAFETY_FLAGS],
|
|
48
|
+
timeoutMs: LONG_TIMEOUT_MS,
|
|
49
|
+
mayCreateBuildOutputs: true,
|
|
50
|
+
description: "Assembles the debug variant (no signing, no release build, no publishing).",
|
|
51
|
+
},
|
|
52
|
+
"unit-test-debug": {
|
|
53
|
+
id: "unit-test-debug",
|
|
54
|
+
gradleTaskName: "testDebugUnitTest",
|
|
55
|
+
args: ["testDebugUnitTest", ...OFFLINE_SAFETY_FLAGS],
|
|
56
|
+
timeoutMs: LONG_TIMEOUT_MS,
|
|
57
|
+
mayCreateBuildOutputs: true,
|
|
58
|
+
description: "Runs local (non-instrumented) debug unit tests.",
|
|
59
|
+
},
|
|
60
|
+
"lint-debug": {
|
|
61
|
+
id: "lint-debug",
|
|
62
|
+
gradleTaskName: "lintDebug",
|
|
63
|
+
args: ["lintDebug", ...OFFLINE_SAFETY_FLAGS],
|
|
64
|
+
timeoutMs: LONG_TIMEOUT_MS,
|
|
65
|
+
mayCreateBuildOutputs: true,
|
|
66
|
+
description: "Runs Android Lint for the debug variant. Lint output is not parsed into security findings in this batch.",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { resolveWithinRoot } from "../../../../core/pathSafety.js";
|
|
4
|
+
import { GRADLE_OPERATIONS, isAllowlistedOperationId } from "./operations.js";
|
|
5
|
+
function wrapperScriptNameForPlatform(platform) {
|
|
6
|
+
return platform === "win32" ? "gradlew.bat" : "gradlew";
|
|
7
|
+
}
|
|
8
|
+
export function buildGradleCommandPlan(operationId, targetRoot, detection, platform = process.platform) {
|
|
9
|
+
if (!isAllowlistedOperationId(operationId)) {
|
|
10
|
+
return { rejected: true, reason: `Unknown or unsupported Gradle operation id: "${operationId}". Only allowlisted operations may be planned.` };
|
|
11
|
+
}
|
|
12
|
+
const resolvedRoot = path.resolve(targetRoot);
|
|
13
|
+
const wrapperScriptName = wrapperScriptNameForPlatform(platform);
|
|
14
|
+
let wrapperExecutablePath;
|
|
15
|
+
try {
|
|
16
|
+
wrapperExecutablePath = resolveWithinRoot(resolvedRoot, wrapperScriptName);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { rejected: true, reason: `Wrapper path escapes the target root: ${wrapperScriptName}` };
|
|
20
|
+
}
|
|
21
|
+
if (!fs.existsSync(wrapperExecutablePath)) {
|
|
22
|
+
return {
|
|
23
|
+
rejected: true,
|
|
24
|
+
reason: `No ${wrapperScriptName} was found at the target root. Optional Gradle validation requires a Gradle wrapper checked into the target for this platform.`,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
const definition = GRADLE_OPERATIONS[operationId];
|
|
28
|
+
const expectedGeneratedPaths = definition.mayCreateBuildOutputs
|
|
29
|
+
? [".gradle/", "build/", ...detection.modules.map((m) => `${m.path}/build/`)]
|
|
30
|
+
: [];
|
|
31
|
+
const environmentLimitations = [
|
|
32
|
+
"Requires a local or downloadable Gradle distribution matching the wrapper version — this planner does not verify one is available.",
|
|
33
|
+
"Requires a Java runtime on PATH — this planner does not verify Java availability.",
|
|
34
|
+
];
|
|
35
|
+
if (definition.args.includes("--offline")) {
|
|
36
|
+
environmentLimitations.push("Runs with --offline; tasks needing uncached dependencies will fail or report inconclusive rather than downloading them.");
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
rejected: false,
|
|
40
|
+
operationId,
|
|
41
|
+
wrapperExecutablePath,
|
|
42
|
+
args: definition.args,
|
|
43
|
+
cwd: resolvedRoot,
|
|
44
|
+
timeoutMs: definition.timeoutMs,
|
|
45
|
+
mayCreateBuildOutputs: definition.mayCreateBuildOutputs,
|
|
46
|
+
expectedGeneratedPaths,
|
|
47
|
+
environmentLimitations,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { GRADLE_OPERATIONS } from "./operations.js";
|
|
4
|
+
import { buildGradleCommandPlan } from "./planner.js";
|
|
5
|
+
import { createRealGradleCommandExecutor } from "./executor.js";
|
|
6
|
+
import { buildGradleOperationCheckResult } from "./operationCheckResult.js";
|
|
7
|
+
import { buildTargetMutationReport, captureTargetSnapshot } from "./targetMutation.js";
|
|
8
|
+
import { isGradleTaskAvailable, parseGradleTaskNames } from "./taskListParser.js";
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// v0.4.0 Batch 4 — optional Gradle validation orchestrator (agents.txt Batch
|
|
11
|
+
// 4 sections 7.17, 7.20, 7.24, 7.26).
|
|
12
|
+
//
|
|
13
|
+
// Static metadata collection (readAndroidGradleMetadata.ts) never calls this
|
|
14
|
+
// module. This orchestrator only ever runs a Gradle command when
|
|
15
|
+
// `enabled: true` is passed explicitly — every other code path returns a
|
|
16
|
+
// "not-run" result without touching the filesystem beyond a target
|
|
17
|
+
// git-status snapshot. Task-gated operations (assemble/test/lint) are
|
|
18
|
+
// skipped, not attempted, unless task availability was already discovered
|
|
19
|
+
// via the "tasks" operation earlier in the same call (or the caller opts in
|
|
20
|
+
// via allowWithoutTaskDiscovery).
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
const TASK_GATED_OPERATIONS = new Set(["assemble-debug", "unit-test-debug", "lint-debug"]);
|
|
23
|
+
function notRunResult(operationId) {
|
|
24
|
+
return {
|
|
25
|
+
id: `android-gradle-${operationId}`,
|
|
26
|
+
category: "android-gradle",
|
|
27
|
+
title: `Gradle operation: ${operationId}`,
|
|
28
|
+
status: "not-run",
|
|
29
|
+
requirementLevel: "optional",
|
|
30
|
+
ran: false,
|
|
31
|
+
skipped: false,
|
|
32
|
+
evidence: [],
|
|
33
|
+
findings: [],
|
|
34
|
+
warnings: [],
|
|
35
|
+
errors: [],
|
|
36
|
+
sourcePaths: [],
|
|
37
|
+
confidence: "unknown",
|
|
38
|
+
environmentRequirements: [],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function skippedResult(operationId, reason, missingCapability) {
|
|
42
|
+
return {
|
|
43
|
+
id: `android-gradle-${operationId}`,
|
|
44
|
+
category: "android-gradle",
|
|
45
|
+
title: `Gradle operation: ${operationId}`,
|
|
46
|
+
status: "skipped",
|
|
47
|
+
requirementLevel: "optional",
|
|
48
|
+
ran: false,
|
|
49
|
+
skipped: true,
|
|
50
|
+
skipInfo: {
|
|
51
|
+
checkId: `android-gradle-${operationId}`,
|
|
52
|
+
reason,
|
|
53
|
+
requirementLevel: "optional",
|
|
54
|
+
missingCapability,
|
|
55
|
+
verdictImpact: "optional Gradle validation evidence is unavailable for this operation",
|
|
56
|
+
recommendedNextAction: "Re-run with the required precondition satisfied, or accept reduced evidence.",
|
|
57
|
+
},
|
|
58
|
+
evidence: [],
|
|
59
|
+
findings: [],
|
|
60
|
+
warnings: [],
|
|
61
|
+
errors: [],
|
|
62
|
+
sourcePaths: [],
|
|
63
|
+
confidence: "unknown",
|
|
64
|
+
environmentRequirements: [],
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function hasGitDirectory(targetRoot) {
|
|
68
|
+
try {
|
|
69
|
+
return fs.existsSync(path.join(targetRoot, ".git"));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function runOptionalGradleValidation(options) {
|
|
76
|
+
if (!options.enabled) {
|
|
77
|
+
return options.operationIds.map((id) => notRunResult(id));
|
|
78
|
+
}
|
|
79
|
+
const executor = options.executor ?? createRealGradleCommandExecutor();
|
|
80
|
+
const platform = options.platform ?? process.platform;
|
|
81
|
+
const resolvedRoot = path.resolve(options.targetRoot);
|
|
82
|
+
const hasGit = hasGitDirectory(resolvedRoot);
|
|
83
|
+
let discoveredTaskNames;
|
|
84
|
+
const results = [];
|
|
85
|
+
for (const operationId of options.operationIds) {
|
|
86
|
+
if (TASK_GATED_OPERATIONS.has(operationId)) {
|
|
87
|
+
if (!discoveredTaskNames && !options.allowWithoutTaskDiscovery) {
|
|
88
|
+
results.push(skippedResult(operationId, "Task availability was not discovered (the 'tasks' operation did not run successfully first) and allowWithoutTaskDiscovery was not set.", "gradle-task-discovery"));
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const gradleTaskName = GRADLE_OPERATIONS[operationId].gradleTaskName;
|
|
92
|
+
if (discoveredTaskNames && !isGradleTaskAvailable(discoveredTaskNames, gradleTaskName)) {
|
|
93
|
+
results.push(skippedResult(operationId, `Task "${gradleTaskName}" is not available in this project.`, "gradle-task"));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const plan = buildGradleCommandPlan(operationId, resolvedRoot, options.detection, platform);
|
|
98
|
+
if (plan.rejected) {
|
|
99
|
+
results.push(skippedResult(operationId, plan.reason, "gradle-wrapper"));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const before = captureTargetSnapshot(resolvedRoot, hasGit);
|
|
103
|
+
const commandResult = await executor(plan);
|
|
104
|
+
const after = captureTargetSnapshot(resolvedRoot, hasGit);
|
|
105
|
+
const mutation = buildTargetMutationReport(before, after);
|
|
106
|
+
const checkResult = buildGradleOperationCheckResult(plan, commandResult, mutation);
|
|
107
|
+
results.push(checkResult);
|
|
108
|
+
if (operationId === "tasks" && checkResult.status === "passed") {
|
|
109
|
+
discoveredTaskNames = parseGradleTaskNames(commandResult.stdout);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return results;
|
|
113
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { captureTargetSnapshot, diffTargetSnapshots } from "../../../../securityValidation/attackScenarios/targetSnapshot.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// v0.4.0 Batch 4 — target mutation evidence for optional Gradle execution
|
|
4
|
+
// (agents.txt Batch 4 section 7.24).
|
|
5
|
+
//
|
|
6
|
+
// Reuses the existing read-only git-status snapshot/diff helpers from
|
|
7
|
+
// src/securityValidation/attackScenarios/targetSnapshot.ts (Batch 1's
|
|
8
|
+
// "reuse, don't create a second target-status model" rule) and layers a
|
|
9
|
+
// narrow Android-specific classifier on top of the diff to separate expected
|
|
10
|
+
// generated build output from unexpected tracked-file changes.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
export { captureTargetSnapshot };
|
|
13
|
+
const ANDROID_GENERATED_PATH_PATTERNS = [/^\.gradle\//, /(^|\/)build\//];
|
|
14
|
+
export function isExpectedAndroidGeneratedPath(entryPath) {
|
|
15
|
+
const normalized = entryPath.replace(/\\/g, "/");
|
|
16
|
+
return ANDROID_GENERATED_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
|
|
17
|
+
}
|
|
18
|
+
// Compares a before/after snapshot pair, distinguishing generated build
|
|
19
|
+
// output (untracked/added paths under build/ or .gradle/) from anything
|
|
20
|
+
// else — a tracked-file modification, or a new file outside those
|
|
21
|
+
// directories, is always reported as unexpected regardless of its status
|
|
22
|
+
// code.
|
|
23
|
+
export function buildTargetMutationReport(before, after) {
|
|
24
|
+
const diff = diffTargetSnapshots(before, after);
|
|
25
|
+
if (!diff.comparable) {
|
|
26
|
+
return { comparable: false, reason: diff.reason, expectedGeneratedChanges: [], unexpectedChanges: [], preExistingChangeCount: 0 };
|
|
27
|
+
}
|
|
28
|
+
const expectedGeneratedChanges = [];
|
|
29
|
+
const unexpectedChanges = [];
|
|
30
|
+
for (const entry of diff.newEntries) {
|
|
31
|
+
const isUntrackedOrAdded = entry.code === "??" || entry.code.includes("A");
|
|
32
|
+
if (isUntrackedOrAdded && isExpectedAndroidGeneratedPath(entry.path)) {
|
|
33
|
+
expectedGeneratedChanges.push(entry.path);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
unexpectedChanges.push(entry.path);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
comparable: true,
|
|
41
|
+
expectedGeneratedChanges: expectedGeneratedChanges.sort((a, b) => a.localeCompare(b)),
|
|
42
|
+
unexpectedChanges: unexpectedChanges.sort((a, b) => a.localeCompare(b)),
|
|
43
|
+
preExistingChangeCount: diff.preExistingEntries.length,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.4.0 Batch 4 — bounded `gradlew tasks --all` output parsing
|
|
3
|
+
// (agents.txt Batch 4 section 7.20).
|
|
4
|
+
//
|
|
5
|
+
// Extracts task names conservatively: only lines shaped like
|
|
6
|
+
// `taskName - description text` are treated as task declarations. Task
|
|
7
|
+
// descriptions are never treated as identifiers, and this parser never
|
|
8
|
+
// treats its output as authoritative security evidence — it is only used to
|
|
9
|
+
// decide whether a later allowlisted operation should be attempted or
|
|
10
|
+
// skipped.
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
const TASK_LINE_PATTERN = /^([A-Za-z][\w:]*)\s+-\s+.+$/;
|
|
13
|
+
export function parseGradleTaskNames(tasksOutput) {
|
|
14
|
+
const names = new Set();
|
|
15
|
+
for (const line of tasksOutput.split(/\r?\n/)) {
|
|
16
|
+
const match = line.match(TASK_LINE_PATTERN);
|
|
17
|
+
if (match)
|
|
18
|
+
names.add(match[1]);
|
|
19
|
+
}
|
|
20
|
+
return names;
|
|
21
|
+
}
|
|
22
|
+
export function isGradleTaskAvailable(taskNames, taskName) {
|
|
23
|
+
return taskNames.has(taskName);
|
|
24
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// v0.4.0 Batch 4 — bounded gradle/libs.versions.toml version resolution.
|
|
3
|
+
//
|
|
4
|
+
// Extends Batch 2's alias-name-only evidence (detect/versionCatalogEvidence.ts)
|
|
5
|
+
// with just enough section-aware parsing to resolve a plugin/library alias to
|
|
6
|
+
// its declared `id`/`module` and version literal (including one level of
|
|
7
|
+
// `version.ref` indirection into the `[versions]` table). This is still not
|
|
8
|
+
// a general TOML parser: bundles, rich-version constraints, and nested
|
|
9
|
+
// tables beyond [versions]/[plugins]/[libraries] are not resolved.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
const SECTION_HEADER_PATTERN = /^\[(versions|plugins|libraries|bundles)\]\s*$/;
|
|
12
|
+
const SIMPLE_VERSION_PATTERN = /^([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"\s*$/;
|
|
13
|
+
const TABLE_ENTRY_PATTERN = /^([A-Za-z0-9_-]+)\s*=\s*\{([^}]*)\}\s*$/;
|
|
14
|
+
function parseEntryBody(body) {
|
|
15
|
+
const entry = {};
|
|
16
|
+
const idMatch = body.match(/\bid\s*=\s*"([^"]*)"/);
|
|
17
|
+
if (idMatch)
|
|
18
|
+
entry.id = idMatch[1];
|
|
19
|
+
const moduleMatch = body.match(/\bmodule\s*=\s*"([^"]*)"/);
|
|
20
|
+
if (moduleMatch)
|
|
21
|
+
entry.module = moduleMatch[1];
|
|
22
|
+
const versionRefMatch = body.match(/\bversion\.ref\s*=\s*"([^"]*)"/);
|
|
23
|
+
if (versionRefMatch) {
|
|
24
|
+
entry.versionRef = versionRefMatch[1];
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
const versionMatch = body.match(/\bversion\s*=\s*"([^"]*)"/);
|
|
28
|
+
if (versionMatch)
|
|
29
|
+
entry.version = versionMatch[1];
|
|
30
|
+
}
|
|
31
|
+
return entry;
|
|
32
|
+
}
|
|
33
|
+
// Parses gradle/libs.versions.toml text into [versions]/[plugins]/[libraries]
|
|
34
|
+
// maps. Unknown sections (e.g. [bundles]) and malformed lines are ignored
|
|
35
|
+
// rather than causing a parse failure.
|
|
36
|
+
export function parseVersionCatalog(text) {
|
|
37
|
+
const versions = {};
|
|
38
|
+
const plugins = {};
|
|
39
|
+
const libraries = {};
|
|
40
|
+
let currentSection;
|
|
41
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
42
|
+
const line = rawLine.trim();
|
|
43
|
+
if (!line || line.startsWith("#"))
|
|
44
|
+
continue;
|
|
45
|
+
const sectionMatch = line.match(SECTION_HEADER_PATTERN);
|
|
46
|
+
if (sectionMatch) {
|
|
47
|
+
currentSection = sectionMatch[1];
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!currentSection)
|
|
51
|
+
continue;
|
|
52
|
+
if (currentSection === "versions") {
|
|
53
|
+
const match = line.match(SIMPLE_VERSION_PATTERN);
|
|
54
|
+
if (match)
|
|
55
|
+
versions[match[1]] = match[2];
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (currentSection === "plugins" || currentSection === "libraries") {
|
|
59
|
+
const tableMatch = line.match(TABLE_ENTRY_PATTERN);
|
|
60
|
+
if (tableMatch) {
|
|
61
|
+
const [, alias, body] = tableMatch;
|
|
62
|
+
(currentSection === "plugins" ? plugins : libraries)[alias] = parseEntryBody(body);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const simpleMatch = line.match(SIMPLE_VERSION_PATTERN);
|
|
66
|
+
if (simpleMatch) {
|
|
67
|
+
// A bare `alias = "id:group:version"` shorthand form (used mainly
|
|
68
|
+
// for libraries) — preserved as the module string, unresolved
|
|
69
|
+
// further, since splitting it safely requires assumptions this
|
|
70
|
+
// batch does not make.
|
|
71
|
+
(currentSection === "plugins" ? plugins : libraries)[simpleMatch[1]] = { module: simpleMatch[2] };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { versions, plugins, libraries };
|
|
76
|
+
}
|
|
77
|
+
// Resolves a catalog entry's version literal, following one level of
|
|
78
|
+
// version.ref indirection into the [versions] table. Returns undefined
|
|
79
|
+
// (unresolved) rather than guessing when the reference is missing.
|
|
80
|
+
export function resolveCatalogVersion(catalog, entry) {
|
|
81
|
+
if (entry.version)
|
|
82
|
+
return entry.version;
|
|
83
|
+
if (entry.versionRef)
|
|
84
|
+
return catalog.versions[entry.versionRef];
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
// Converts a Gradle version-catalog accessor path (e.g. from
|
|
88
|
+
// `libs.plugins.android.application`, the `android.application` suffix) back
|
|
89
|
+
// to its kebab-case alias key convention (`android-application`), the
|
|
90
|
+
// inverse of the accessor-generation convention used elsewhere in this
|
|
91
|
+
// codebase (see detect/detectAndroidProject.ts's catalogAliasToAccessorSuffix).
|
|
92
|
+
export function accessorSuffixToCatalogAlias(accessorSuffix) {
|
|
93
|
+
return accessorSuffix.replace(/\./g, "-");
|
|
94
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const DISTRIBUTION_URL_PATTERN = /^distributionUrl\s*=\s*(.+)$/m;
|
|
2
|
+
const CHECKSUM_PROPERTY_PATTERN = /^distributionSha256Sum\s*=\s*(.+)$/m;
|
|
3
|
+
// Gradle distribution URLs look like:
|
|
4
|
+
// https\://services.gradle.org/distributions/gradle-8.5-bin.zip
|
|
5
|
+
// (the colon is escaped because .properties files use `\:` for literal
|
|
6
|
+
// colons in values). Captures the version and bin/all distribution type.
|
|
7
|
+
const GRADLE_VERSION_PATTERN = /gradle-(\d+(?:\.\d+){1,3}(?:-\w+)?)-(bin|all)\.zip/;
|
|
8
|
+
function unescapePropertiesValue(raw) {
|
|
9
|
+
return raw.trim().replace(/\\:/g, ":").replace(/\\\//g, "/");
|
|
10
|
+
}
|
|
11
|
+
export function parseWrapperProperties(text) {
|
|
12
|
+
const warnings = [];
|
|
13
|
+
const urlMatch = text.match(DISTRIBUTION_URL_PATTERN);
|
|
14
|
+
const distributionUrl = urlMatch ? unescapePropertiesValue(urlMatch[1]) : undefined;
|
|
15
|
+
const checksumPropertyPresent = CHECKSUM_PROPERTY_PATTERN.test(text);
|
|
16
|
+
if (!distributionUrl) {
|
|
17
|
+
warnings.push("gradle-wrapper.properties has no distributionUrl property.");
|
|
18
|
+
return { distributionType: "unknown", checksumPropertyPresent, warnings };
|
|
19
|
+
}
|
|
20
|
+
const versionMatch = distributionUrl.match(GRADLE_VERSION_PATTERN);
|
|
21
|
+
if (!versionMatch) {
|
|
22
|
+
warnings.push(`Could not deterministically extract a Gradle version from distributionUrl: "${distributionUrl}".`);
|
|
23
|
+
return { distributionUrl, distributionType: "unknown", checksumPropertyPresent, warnings };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
distributionUrl,
|
|
27
|
+
gradleVersion: versionMatch[1],
|
|
28
|
+
distributionType: versionMatch[2],
|
|
29
|
+
checksumPropertyPresent,
|
|
30
|
+
warnings,
|
|
31
|
+
};
|
|
32
|
+
}
|