This is an automated email from the ASF dual-hosted git repository. luigidemasi pushed a commit to branch fix/security-model-verification-153-158 in repository https://gitbox.apache.org/repos/asf/camel-kit.git
commit 03cdfaeb358fbd6fc7a46125ff9fd5a2c3361df0 Author: Luigi De Masi <[email protected]> AuthorDate: Mon Nov 9 19:15:20 2026 +0100 Fix #154: confine and bound graph parser inputs and deadlines Fixes #154 Co-authored-by: OpenAI Codex <[email protected]> --- CHANGELOG.md | 2 + docs/modules/ROOT/pages/graph.adoc | 12 + docs/security-model.md | 25 +- docs/security-model.yaml | 9 +- docs/security-verification.md | 16 ++ .../org/apache/camel/kit/graph/GraphBuilder.java | 42 +++- .../camel/kit/graph/parser/BizTalkParser.java | 4 +- .../camel/kit/graph/parser/ConfigParser.java | 4 +- .../camel/kit/graph/parser/DataWeaveParser.java | 4 +- .../apache/camel/kit/graph/parser/GraphParser.java | 106 ++++++++- .../camel/kit/graph/parser/GroovyGraphParser.java | 5 +- .../camel/kit/graph/parser/JavaGraphParser.java | 5 +- .../camel/kit/graph/parser/MuleXmlFlowParser.java | 7 +- .../apache/camel/kit/graph/parser/PomParser.java | 6 +- .../camel/kit/graph/parser/XmlRouteParser.java | 7 +- .../camel/kit/graph/parser/YamlRouteParser.java | 3 +- .../graph/parser/biztalk/BizTalkBindingParser.java | 4 +- .../kit/graph/parser/biztalk/BizTalkBtmParser.java | 4 +- .../kit/graph/parser/biztalk/BizTalkBtpParser.java | 4 +- .../kit/graph/parser/biztalk/BizTalkOdxParser.java | 3 +- .../camel/kit/graph/GraphInputBoundaryTest.java | 252 +++++++++++++++++++++ 21 files changed, 484 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d84d6f31..2862997a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Graph input boundaries (#154)** — reject outside-root and symlink inputs, bound source bytes and discovery, and apply the parser timeout to POM analysis. + - **Parser source-reading regression coverage (#153)** — exercise all nine graph parsers with execution, class-loading, placeholder and external-entity canaries. - **Google Antigravity native Ship (#95)** — eligible sessions relay controller-issued stages through a dedicated diff --git a/docs/modules/ROOT/pages/graph.adoc b/docs/modules/ROOT/pages/graph.adoc index db7021d7..3f6d019f 100644 --- a/docs/modules/ROOT/pages/graph.adoc +++ b/docs/modules/ROOT/pages/graph.adoc @@ -143,3 +143,15 @@ architecture. * `camel-implement` loads `project-context` and `camel-test` loads `route-context` when a graph exists. * `/camel-validate` consumes `project-norms` and includes the `dead-code` report. * `/camel-start` and `/camel-knowledge` do not use it. + +== Input safety and limits + +Graph analysis reads sources without running the project. It skips symbolic-link files and +directories below the selected root, rejects outside-root inputs, and accepts an explicitly +selected root alias. Each source is limited to 16 MiB; recognized source files together are +limited to 64 MiB, and discovery stops after 100,000 filesystem entries. Large unrelated +binary artifacts do not count toward the source-byte limit. + +Each parser, including POM parsing, has a 60-second timeout. A timed-out parser contributes +no partial result and produces a diagnostic. Cancellation is cooperative; these are input +and waiting-time limits, not an operating-system memory or CPU sandbox. diff --git a/docs/security-model.md b/docs/security-model.md index 8427e7e5..28bfe1dc 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -349,7 +349,7 @@ a report against `0.0.2` is triaged on what that release documents. | Process (configuration) | Listening sockets | Child processes | Network | Filesystem writes | Environment read | Telemetry | Limits | | --- | --- | --- | --- | --- | --- | --- | --- | | **Kit CLI** `camel-kit` / `camel kit`: `init`, `doctor`, `doc`, `nextId`, `plan analyze`, `graph` | none (inspected) | version probes only: `java -version`, `jbang version`, `camel --version`, `mvn`/`mvnw`, `pi -a` *(documented — `PrerequisiteChecker`, `DoctorService`, `docs/agent-architectures.md`)* | `init` without `--no-fetch`: HTTPS to `repo1.maven.org`, `raw.githubusercontent.com/apache/camel`, `api.github.com` (Kamelet listing) and `search.maven.org`; redirects followed except HT [...] -| **Graph analysis** (inside the CLI process) | none (inspected) | none *(documented — `ParserSourceReadingContractTest`; [V1 evidence](security-verification.md#v1-source-reading))* | none *(documented — `ParserSourceReadingContractTest`; [V1 evidence](security-verification.md#v1-source-reading))* | `.camel-kit/project-graph.json` or the `--output` path *(documented — `GraphGenerateCommand`)* | none (inspected) | none (inspected) | 60 s per parser except the POM parser; no file-size or m [...] +| **Graph analysis** (inside the CLI process) | none (inspected) | none *(documented — `ParserSourceReadingContractTest`; [V1 evidence](security-verification.md#v1-source-reading))* | none *(documented — `ParserSourceReadingContractTest`; [V1 evidence](security-verification.md#v1-source-reading))* | `.camel-kit/project-graph.json` or the `--output` path *(documented — `GraphGenerateCommand`)* | none (inspected) | none (inspected) | 60 s per parser including POM; 16 MiB/source, 64 MiB agg [...] | **Ship controller** `camel-kit ship` | none (inspected) | `pi` and `node` (Pi backend), `/usr/bin/setsid`, `/bin/kill`, the controller's own `java` for evidence *(documented — `PiWorker`, `LocalCommandRunner`, `EvidenceRunner`)* | HTTPS to Maven Central through the resolver; no redirects; checksum `fail` *(documented — `ShipMavenResolver`)* | `.camel-kit/ship/state/` or `$CAMEL_KIT_SHIP_STATE_HOME`; the live project only through journaled publication *(documented — `docs/commands.md`, [...] | **Ship Pi worker** (`--backend pi`) | Pi's own process | Pi's tools — `bash`, `edit`, `write` in EXECUTE — started in the staging cwd, which is a working directory, not a confinement *(documented — `PiWorker` Javadoc)* | whatever Pi and its provider need — external behaviour, outside this model (§4.3); launched with `--offline`, `--no-extensions`, `--no-skills`, `--no-context-files` *(documented — `PiWorker` arguments)* | *Intended:* the staging workspace and its session directory unde [...] | **Ship evidence JVMs** | none (inspected) | none (inspected) | none by design: every non-direct endpoint stubbed *(documented — `docs/commands.md`)* | private `HOME` and `TMPDIR` under the run; frozen read-only copy *(documented — `EvidenceRunner`)* | scrubbed to `LANG`, `LC_ALL`, `HOME`, `TMPDIR`, `GIT_CEILING_DIRECTORIES` and controller-provided variables *(documented — `EvidenceRunner`)* | none (inspected) | 64 MiB log cap; stage timeout *(documented — `EvidenceRunner`)* | @@ -426,8 +426,7 @@ Per-entry-point trust table. "Caller" is the developer unless stated otherwise. | Knowledge configuration (`knowledge.index.*`, `KNOWLEDGE_INDEX_*`, `quarkus.http.*`) and a local `knowledge.index.path` | properties, environment, directory | no — operator | — | | Knowledge cache directory (`~/.camel-kit/knowledge-index`) | version directories, `current`, `etag`, staging | same user only | Server: version directories must be real directories; marker and validator rebound on activation; staging cleaned under the update lock (§4.8 P13) | -**Size, shape and rate.** Ship states its limits (§4.8 P11). Graph analysis reads whole files; the per-parser timeout -in P1 is the only bound in place today *(inferred — V2)*. `init` downloads are whole artifacts with a 30–60 s request timeout *(documented — downloader +**Size, shape and rate.** Ship states its limits (§4.8 P11). Graph input bounds are 16 MiB per source, 64 MiB aggregate recognized source and 100,000 filesystem entries; every parser has a 60-second timeout (P1). `init` downloads are whole artifacts with a 30–60 s request timeout *(documented — downloader constants)*. --- @@ -496,11 +495,14 @@ violation warrants coordinated disclosure and a CVE; **hardening** means it is f `IS_SUPPORTING_EXTERNAL_ENTITIES=false` and `SUPPORT_DTD=false` *(documented — `BizTalk*Parser`)*; the Java parser is AST-only (JavaParser, no symbol solving) *(documented — `ParserSourceReadingContractTest`; [V1 evidence](security-verification.md#v1-source-reading))*; the Groovy parser compiles to the `CONVERSION` phase only *(documented — `ParserSourceReadingContractTest`; [V1 evidence](security-verification.md#v1-source-reading))*; YAML routes and configuration use Jackson's tree model, DataWeave and properties are - regular-expression scans *(documented — `ParserSourceReadingContractTest`; [V1 evidence](security-verification.md#v1-source-reading))*. Bound in place: `GraphBuilder` runs every parser other than the POM - parser as a task with a 60-second timeout (`DEFAULT_PARSER_TIMEOUT`); a parser that exceeds it is cancelled and - recorded as a timeout diagnostic *(documented — `GraphBuilder`)*. Not yet verified: a bound for the POM parser, a - memory or file-size bound, and confinement of file reads to the analysed root when the tree contains symbolic - links *(inferred — V2)*. *Violation symptom:* a child process, a class loaded or defined from the source, an + regular-expression scans *(documented — `ParserSourceReadingContractTest`; [V1 evidence](security-verification.md#v1-source-reading))*. `GraphBuilder` applies a 60-second timeout to every parser, including the POM parser. + A timeout discards that parser's partial result. `GraphParser` refuses file links and outside-root + inputs, bounds each source to 16 MiB, aggregate recognized source to 64 MiB, and discovery to + 100,000 filesystem entries. Reads enforce the per-file bound again. The selected root may itself + be an operator-chosen alias. See [V2 evidence](security-verification.md#v2-graph-inputs). + Cancellation is cooperative inside a daemon thread; it bounds the caller's wait, not a hard JVM + heap or CPU quota. These controls do not claim protection against a same-account process + concurrently replacing directories. *Violation symptom:* a child process, a class loaded or defined from the source, an outbound connection, a read of a file outside the analysed root (including through a symbolic link), a placeholder resolved, non-termination, or unbounded memory during `camel-kit graph` or `init --here`. *Severity:* security-critical — the umbrella lists "reads a file" and "does not finish" among the breaches of this @@ -634,7 +636,7 @@ violation warrants coordinated disclosure and a CVE; **hardening** means it is f ≤ 1 MiB, RPC log ≤ 16 MiB, session ≤ 64 MiB *(documented — `PiWorker`)*; evidence log ≤ 64 MiB and stage timeout 10 min default *(documented — `EvidenceRunner`, `docs/commands.md`)*; resolver bounds as in P4; tree quotas as in P6; `graph migration-context` "defaults to depth 3, and returns at most 50 expanded nodes" - *(documented — `docs/commands.md`)*; graph parsers other than the POM parser are bounded to 60 s each + *(documented — `docs/commands.md`)*; all graph parsers including the POM parser are bounded to 60 s each *(documented — `GraphBuilder`)*; Knowledge returns at most 25 results per call, reads release pages of at most 2 MiB and at most 10 pages, and applies 3 s connect, 5 s manifest and 5 min archive deadlines *(documented — `KnowledgeMcpServer`, `IndexDownloader`)*. *Violation:* a limit not enforced at its documented value. *Severity:* @@ -1040,9 +1042,8 @@ own issue, linked below. - **V1 — Per-parser regression coverage (Q1; #153): implemented.** `ParserSourceReadingContractTest` exercises all nine parsers, with observable output and hostile source canaries. See [V1 evidence](security-verification.md#v1-source-reading), including mutation controls. A demonstrated breach remains `VALID` under P1. -- **V2 — Graph confinement and resource bounds (Q2; #154) → §4.6, §4.8 P1, P11.** Verify or add: confinement of file reads to - the analysed root when the tree contains symbolic links; the POM parser under the parser timeout; a memory or - file-size bound. *Interim triage:* a demonstrated outside-root read, non-termination or unbounded memory is `VALID`. +- **V2 — Graph confinement and resource bounds (Q2; #154): implemented.** `GraphInputBoundaryTest` covers symlink/outside inputs, selected-root aliases, oversized files and aggregate input, large unrelated artifacts and the POM timeout. [V2 evidence](security-verification.md#v2-graph-inputs) states the bounds and cancellation limits. A demonstrated P1 breach remains `VALID`. + - **V3 — Narrower Claude MCP grants (Q3; #155) → §4.5a, §4.8 P10, §4.11a.** Replace `mcp__camel__*`, `mcp__camel-knowledge__*` and `mcp__citrus__*` in the generated `settings.json` with explicit per-tool grants aligned with the workflow manifest's `allowed_tools`, and verify the generated behaviour in Claude Code. *Interim triage:* a diff --git a/docs/security-model.yaml b/docs/security-model.yaml index 4249a50b..622dc261 100644 --- a/docs/security-model.yaml +++ b/docs/security-model.yaml @@ -281,10 +281,10 @@ configuration_variants: claimed_properties: - id: P1 - property: Source-reading contract for graph analysis, inherited in full from the umbrella model - no code execution, class loading, placeholder/secret resolution, network or child processes; always finishes in bounded time and memory; output is data. Bound in place - GraphBuilder 60-second timeout per parser other than the POM parser. Not yet verified - POM parser bound, memory or file-size bound, confinement of reads to the analysed root when the tree contains symbolic links (V2). + property: "Source-reading contract inherited from the umbrella model: no project code execution, project class loading, runtime placeholder/secret resolution, network or child processes; output is data. GraphParser confines inputs, refuses file links, limits source files to 16 MiB, recognized source total to 64 MiB and discovery to 100000 entries. GraphBuilder times out every parser including POM after 60 seconds, discards its partial result and cancels its daemon task. Cancellation [...] violation_symptom: Child process, class loaded or defined from the source, outbound connection, read of a file outside the analysed root (including through a symbolic link), placeholder resolved, non-termination, or unbounded memory during graph analysis. severity: security-critical (VALID regardless of the verification status in V1/V2) - provenance: contract - documented (umbrella) and maintainer (2026-10-27, Q1 and Q2); XML/StAX hardening documented (SecureXml, BizTalk parsers); GraphBuilder timeout documented; all nine parsers covered by ParserSourceReadingContractTest (security-verification.md V1); confinement and resource enforcement inferred (V2) + provenance: "Contract: umbrella and maintainer (2026-10-27, Q1/Q2). Controls: GraphParser, GraphBuilder, SecureXml and BizTalk parsers. Regressions: ParserSourceReadingContractTest, GraphInputBoundaryTest; security-verification.md V1/V2." - id: P2 property: Hardened XML parsing at every XML entry point, including Ship pom.xml validation. violation_symptom: XXE, SSRF or local file read through XML. @@ -331,7 +331,7 @@ claimed_properties: severity: hardening; security-critical when the widened grant reaches a credential or destructive action without a prompt provenance: "documented (docs/agent-architectures.md, docs/ship-claude.md, docs/ship-native.md, templates/mcp-configs/*, templates/claude/settings.json, Claude Code permissions reference); current Claude grant - maintainer, 2026-10-22 (review of #6); accepted posture - maintainer, 2026-10-27 (Q3)" - id: P11 - property: Stated resource bounds - route/Citrus YAML 2 MiB, native result 16 MiB and 1000 files, Pi prompt 1 MiB, Pi log 16 MiB, session 64 MiB, evidence log 64 MiB, stage timeout 10m, resolver 64 roots/512 artifacts/128 MiB JAR/4 MiB POM, tree quotas, migration-context depth 3 and 50 nodes; graph parsers other than the POM parser 60 s each (serves P1); Knowledge tool results at most 25 per call, release pages at most 2 MiB and 10 pages, manifest 5 s and archive 5 min deadlines. + property: Stated resource bounds - route/Citrus YAML 2 MiB, native result 16 MiB and 1000 files, Pi prompt 1 MiB, Pi log 16 MiB, session 64 MiB, evidence log 64 MiB, stage timeout 10m, resolver 64 roots/512 artifacts/128 MiB JAR/4 MiB POM, tree quotas, migration-context depth 3 and 50 nodes; all graph parsers including POM 60 s each, source16 MiB/file and64 MiB total, discovery100000 entries (serves P1); Knowledge tool results at most 25 per call, release pages at most 2 MiB and 10 p [...] violation_symptom: A limit not enforced at its documented value. severity: hardening provenance: documented (ShipArtifactLimits, ShipNativeWorker, PiWorker, EvidenceRunner, ShipMavenResolver, ShipTreePolicy, docs/commands.md, KnowledgeMcpServer, IndexDownloader) @@ -589,7 +589,8 @@ verification_items: sections: ["4.6", "4.8 P1", "4.8 P11"] work: Verify or add - confinement of file reads to the analysed root when the tree contains symbolic links; the POM parser under the parser timeout; a memory or file-size bound. interim_triage: A demonstrated outside-root read, non-termination or unbounded memory is VALID under P1. - status: open + status: "implemented" + evidence: "GraphInputBoundaryTest and GraphBuilderTest; source/entry bounds and cooperative timeout limits documented in security-verification.md V2" - id: V3 tracking: apache/camel-kit#155 title: Narrower Claude MCP grants in the generated settings.json diff --git a/docs/security-verification.md b/docs/security-verification.md index 8af1e3fd..ad2f4bfc 100644 --- a/docs/security-verification.md +++ b/docs/security-verification.md @@ -19,3 +19,19 @@ process and enabling XML entity expansion all caused failures. The XML mutation text: an external entity in an attribute would be rejected even by an otherwise unsafe parser. These probes protect the covered execution paths; they do not prove safety of arbitrary future parser/dependency changes. Website impact: not required; this pins existing behavior. + + +## V2: graph inputs + +`GraphInputBoundaryTest` exercises actual parsing and `GraphBuilder`: outside file links, +directory links, an operator-selected root alias, direct supplied outside/oversized lists, +16 MiB and 16 MiB+1 sources, aggregate overflow, large unrelated artifacts and a deliberately +slow POM parser. Production bounds are 16 MiB per input, 64 MiB aggregate recognized source +and 100,000 visited entries. Sources are checked before parser sniffing and bounded again +when read. The POM pass uses the same timeout policy as the remaining parsers. + +The baseline reproduced file-link escape, missing POM timeout and oversized-input acceptance. +A timed-out parser's fragment is discarded; daemon threads do not hold the CLI open. +Cancellation remains cooperative, and parser/library expansion can exceed the input byte size: +there is no hard heap quota. Filesystem checks cover a pre-existing hostile tree, not concurrent +same-account directory substitution. Website impact: required; graph reference updated. diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/GraphBuilder.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/GraphBuilder.java index 82804d9b..83ca27a6 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/GraphBuilder.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/GraphBuilder.java @@ -78,15 +78,15 @@ public class GraphBuilder { // Run PomParser first so MAVEN_ARTIFACT nodes are available for all other parsers ParserRun pomRun - = runParser(pomParser, projectRoot, new ProjectGraph(), - parserTask(pomParser, projectRoot, projectFiles)); + = runFirstParser(projectRoot, projectFiles); diagnostics.add(pomRun.diagnostic()); if (pomRun.diagnostic().successful()) { mergeDeterministically(graph, pomRun.graph()); } ExecutorService executor = Executors.newFixedThreadPool( - Math.max(1, Math.min(remainingParsers.size(), Runtime.getRuntime().availableProcessors()))); + Math.max(1, Math.min(remainingParsers.size(), Runtime.getRuntime().availableProcessors())), + GraphBuilder::parserThread); try { ProjectGraph baseGraph = new ProjectGraph(); baseGraph.merge(graph); @@ -109,7 +109,9 @@ public class GraphBuilder { for (int i = 0; i < futures.size(); i++) { ParserFuture parserFuture = futures.get(i); try { - ParserRun parserRun = parserFuture.future().get(parserTimeout, parserTimeoutUnit); + long remaining = parserTimeoutUnit.toNanos(parserTimeout) + - (System.nanoTime() - parserFuture.submittedAt()); + ParserRun parserRun = parserFuture.future().get(Math.max(0, remaining), TimeUnit.NANOSECONDS); diagnostics.add(parserRun.diagnostic()); if (parserRun.diagnostic().successful()) { mergeDeterministically(graph, parserRun.graph()); @@ -176,6 +178,9 @@ public class GraphBuilder { try { ProjectGraph fragment = parser.parseFragment(projectRoot, baseGraph, task.scannedFilePaths()); delta = deltaFrom(fragment, baseGraph); + if (System.nanoTime() - startedAt > parserTimeoutUnit.toNanos(parserTimeout)) { + return new ParserRun(new ProjectGraph(), timeoutDiagnostic(parser, task.scannedFiles())); + } ParserDiagnostic diagnostic = diagnostic( parser, task.scannedFiles(), @@ -193,6 +198,35 @@ public class GraphBuilder { } } + private ParserRun runFirstParser(Path projectRoot, List<Path> projectFiles) { + ParserTask task = parserTask(pomParser, projectRoot, projectFiles); + ExecutorService executor = Executors.newSingleThreadExecutor(GraphBuilder::parserThread); + Future<ParserRun> future = executor.submit(() -> runParser(pomParser, projectRoot, new ProjectGraph(), task)); + long startedAt = System.nanoTime(); + try { + return future.get(parserTimeout, parserTimeoutUnit); + } catch (TimeoutException e) { + return new ParserRun(new ProjectGraph(), timeoutDiagnostic(pomParser, task.scannedFiles())); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new ParserRun(new ProjectGraph(), interruptedDiagnostic(pomParser, task.scannedFiles())); + } catch (ExecutionException e) { + return new ParserRun( + new ProjectGraph(), failureDiagnostic(pomParser, task.scannedFiles(), + e.getCause(), elapsedMillis(startedAt))); + } finally { + future.cancel(true); + executor.shutdownNow(); + } + } + + private static Thread parserThread(Runnable task) { + Thread thread = new Thread(task, "camel-kit-graph-parser"); + // A timed-out library parser must not keep the CLI alive if it ignores interruption. + thread.setDaemon(true); + return thread; + } + private ParserTask parserTask(GraphParser parser, Path projectRoot, List<Path> projectFiles) { try { List<Path> scannedFilePaths = parser.scannedFilePaths(projectRoot, projectFiles); diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/BizTalkParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/BizTalkParser.java index 1283a00d..f5c4afb9 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/BizTalkParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/BizTalkParser.java @@ -5,6 +5,7 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.nio.file.LinkOption; import java.util.ArrayList; import java.util.List; import java.util.Locale; @@ -44,6 +45,7 @@ public class BizTalkParser implements GraphParser { @Override public void parseFiles(Path projectRoot, List<Path> files, ProjectGraph graph) { + GraphParser.requireSafeFiles(projectRoot, files); warnings.clear(); files.forEach(file -> parseArtifact(projectRoot, file, graph)); } @@ -92,7 +94,7 @@ public class BizTalkParser implements GraphParser { * @return true if the file contains BizTalk binding markers */ private boolean isBizTalkBindingXml(Path file) { - try (InputStream is = Files.newInputStream(file)) { + try (InputStream is = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { byte[] buf = new byte[SNIFF_BYTES]; int read = is.read(buf); if (read <= 0) { diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/ConfigParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/ConfigParser.java index ddcfa159..938d26f8 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/ConfigParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/ConfigParser.java @@ -1,5 +1,6 @@ package org.apache.camel.kit.graph.parser; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.*; @@ -27,6 +28,7 @@ public class ConfigParser implements GraphParser { @Override public void parseFiles(Path projectRoot, List<Path> files, ProjectGraph graph) { + GraphParser.requireSafeFiles(projectRoot, files); files.forEach(file -> parseProperties(file, projectRoot, graph)); } @@ -37,7 +39,7 @@ public class ConfigParser implements GraphParser { private void parseProperties(Path file, Path projectRoot, ProjectGraph graph) { Properties props = new Properties(); - try (InputStream in = Files.newInputStream(file)) { + try (InputStream in = new ByteArrayInputStream(GraphParser.readSource(file))) { props.load(in); } catch (IOException e) { throw new RuntimeException("Failed to parse properties: " + file, e); diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/DataWeaveParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/DataWeaveParser.java index 9a16e801..cd18d4f0 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/DataWeaveParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/DataWeaveParser.java @@ -1,6 +1,7 @@ package org.apache.camel.kit.graph.parser; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; import java.util.regex.Matcher; @@ -43,13 +44,14 @@ public class DataWeaveParser implements GraphParser { @Override public void parseFiles(Path projectRoot, List<Path> files, ProjectGraph graph) { + GraphParser.requireSafeFiles(projectRoot, files); files.forEach(file -> parseDwlFile(file, projectRoot, graph)); } private void parseDwlFile(Path file, Path projectRoot, ProjectGraph graph) { String content; try { - content = Files.readString(file); + content = new String(GraphParser.readSource(file), StandardCharsets.UTF_8); } catch (IOException e) { throw new RuntimeException("Failed to read DataWeave file: " + file, e); } diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/GraphParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/GraphParser.java index 0f3cf1f0..5a75df81 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/GraphParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/GraphParser.java @@ -1,15 +1,23 @@ package org.apache.camel.kit.graph.parser; import java.io.IOException; +import java.io.InputStream; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.function.Predicate; import java.util.stream.Stream; import org.apache.camel.kit.graph.ProjectGraph; public interface GraphParser { + int MAX_SOURCE_BYTES = 16 * 1024 * 1024; + long MAX_TOTAL_SOURCE_BYTES = 64L * 1024 * 1024; + int MAX_PROJECT_ENTRIES = 100_000; + void parse(Path projectRoot, ProjectGraph graph); default ProjectGraph parseFragment(Path projectRoot, ProjectGraph baseGraph) { @@ -49,11 +57,35 @@ public interface GraphParser { return List.of(); } - try (Stream<Path> paths = Files.walk(projectRoot)) { - return paths - .filter(Files::isRegularFile) - .sorted() - .toList(); + Path realRoot; + try { + realRoot = projectRoot.toRealPath(); + } catch (IOException e) { + throw new IllegalArgumentException("Cannot resolve graph project root: " + projectRoot, e); + } + try (Stream<Path> paths = Files.walk(realRoot)) { + List<Path> files = new ArrayList<>(); + var iterator = paths.iterator(); + int entries = 0; + long sourceBytes = 0; + while (iterator.hasNext()) { + Path path = projectRoot.resolve(realRoot.relativize(iterator.next())); + if (++entries > MAX_PROJECT_ENTRIES) { + throw new IllegalArgumentException( + "Project exceeds " + MAX_PROJECT_ENTRIES + " filesystem entries"); + } + if (Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { + if (isSourceFile(path)) { + sourceBytes += requireSafeFile(projectRoot, path); + if (sourceBytes > MAX_TOTAL_SOURCE_BYTES) { + throw new IllegalArgumentException( + "Project source exceeds " + MAX_TOTAL_SOURCE_BYTES + " bytes"); + } + } + files.add(path); + } + } + return files.stream().sorted().toList(); } catch (IOException e) { throw new RuntimeException("Failed to scan files under " + projectRoot, e); } @@ -65,11 +97,75 @@ public interface GraphParser { static List<Path> findFilePaths(Path projectRoot, List<Path> projectFiles, Predicate<Path> matcher) { return projectFiles.stream() + .peek(file -> { + if (isSourceFile(file)) { + requireSafeFile(projectRoot, file); + } + }) .filter(matcher) + .peek(file -> requireSafeFile(projectRoot, file)) .sorted() .toList(); } + static void requireSafeFiles(Path projectRoot, List<Path> files) { + long bytes = 0; + if (files.size() > MAX_PROJECT_ENTRIES) { + throw new IllegalArgumentException("Too many graph input files"); + } + for (Path file : files) { + bytes += requireSafeFile(projectRoot, file); + if (bytes > MAX_TOTAL_SOURCE_BYTES) { + throw new IllegalArgumentException("Project source exceeds " + MAX_TOTAL_SOURCE_BYTES + " bytes"); + } + } + } + + private static long requireSafeFile(Path projectRoot, Path file) { + Path root = projectRoot.toAbsolutePath().normalize(); + Path input = file.toAbsolutePath().normalize(); + if (!input.startsWith(root) || input.equals(root)) { + throw new IllegalArgumentException("Graph input is outside the project: " + file); + } + Path current = root; + for (Path component : root.relativize(input)) { + current = current.resolve(component); + if (Files.isSymbolicLink(current)) { + throw new IllegalArgumentException("Graph input is a symbolic link: " + current); + } + } + try { + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) + || !file.toRealPath().startsWith(projectRoot.toRealPath())) { + throw new IllegalArgumentException("Graph input is not a confined regular file: " + file); + } + long bytes = Files.size(file); + if (bytes > MAX_SOURCE_BYTES) { + throw new IllegalArgumentException("Graph input exceeds " + MAX_SOURCE_BYTES + " bytes: " + file); + } + return bytes; + } catch (IOException e) { + throw new IllegalArgumentException("Cannot validate graph input: " + file, e); + } + } + + static byte[] readSource(Path file) throws IOException { + try (InputStream input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { + byte[] bytes = input.readNBytes(MAX_SOURCE_BYTES + 1); + if (bytes.length > MAX_SOURCE_BYTES) { + throw new IOException("Graph input exceeds " + MAX_SOURCE_BYTES + " bytes: " + file); + } + return bytes; + } + } + + private static boolean isSourceFile(Path file) { + String name = file.getFileName().toString().toLowerCase(Locale.ROOT); + return name.endsWith(".java") || name.endsWith(".groovy") || name.endsWith(".xml") + || name.endsWith(".yaml") || name.endsWith(".yml") || name.endsWith(".properties") + || name.endsWith(".dwl") || name.endsWith(".odx") || name.endsWith(".btm") || name.endsWith(".btp"); + } + static List<String> relativeFileNames(Path projectRoot, List<Path> files) { return files.stream() .map(file -> relativeFileName(projectRoot, file)) diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/GroovyGraphParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/GroovyGraphParser.java index 2d5250d3..7f87f3cb 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/GroovyGraphParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/GroovyGraphParser.java @@ -1,6 +1,7 @@ package org.apache.camel.kit.graph.parser; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; @@ -40,6 +41,7 @@ public class GroovyGraphParser implements GraphParser { @Override public void parseFiles(Path projectRoot, List<Path> files, ProjectGraph graph) { + GraphParser.requireSafeFiles(projectRoot, files); files.forEach(file -> parseGroovyFile(file, projectRoot, graph)); } @@ -51,7 +53,8 @@ public class GroovyGraphParser implements GraphParser { Thread.currentThread().getContextClassLoader(), config)) { CompilationUnit compilationUnit = new CompilationUnit(config, null, classLoader); - compilationUnit.addSource(file.toFile()); + compilationUnit.addSource(file.toString(), + new String(GraphParser.readSource(file), StandardCharsets.UTF_8)); compilationUnit.compile(Phases.CONVERSION); // Get the modules from the compilation unit diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/JavaGraphParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/JavaGraphParser.java index 8f41c7f8..2e1a70a9 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/JavaGraphParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/JavaGraphParser.java @@ -1,6 +1,7 @@ package org.apache.camel.kit.graph.parser; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.*; import java.util.regex.Matcher; @@ -49,6 +50,7 @@ public class JavaGraphParser implements GraphParser { @Override public void parseFiles(Path projectRoot, List<Path> files, ProjectGraph graph) { + GraphParser.requireSafeFiles(projectRoot, files); JavaParser parser = new JavaParser(); files.forEach(file -> parseJavaFile(parser, file, projectRoot, graph)); } @@ -75,7 +77,8 @@ public class JavaGraphParser implements GraphParser { private void parseJavaFile(JavaParser parser, Path file, Path projectRoot, ProjectGraph graph) { try { - var parseResult = parser.parse(file); + var parseResult + = parser.parse(new String(GraphParser.readSource(file), StandardCharsets.UTF_8)); if (parseResult.getResult().isEmpty()) { String problems = parseResult.getProblems().stream() .map(Object::toString) diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/MuleXmlFlowParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/MuleXmlFlowParser.java index c9e251ae..e40881c4 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/MuleXmlFlowParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/MuleXmlFlowParser.java @@ -1,9 +1,11 @@ package org.apache.camel.kit.graph.parser; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.nio.file.LinkOption; import java.util.*; import javax.xml.parsers.DocumentBuilder; @@ -57,6 +59,7 @@ public class MuleXmlFlowParser implements GraphParser { @Override public void parseFiles(Path projectRoot, List<Path> files, ProjectGraph graph) { + GraphParser.requireSafeFiles(projectRoot, files); // Two passes across ALL files: first declare every flow/sub-flow node, then parse flow bodies, so // flow-ref targets resolve regardless of file processing order. List<ParsedMuleDocument> documents = new ArrayList<>(); @@ -90,7 +93,7 @@ public class MuleXmlFlowParser implements GraphParser { * Namespace sniff: read first 1KB and check for the MuleSoft namespace marker. */ private boolean isMuleXml(Path file) { - try (InputStream is = Files.newInputStream(file)) { + try (InputStream is = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { byte[] buf = new byte[SNIFF_BYTES]; int read = is.read(buf); if (read <= 0) @@ -107,7 +110,7 @@ public class MuleXmlFlowParser implements GraphParser { try { var factory = SecureXml.documentBuilderFactory(); DocumentBuilder builder = factory.newDocumentBuilder(); - Document doc = builder.parse(xmlFile.toFile()); + Document doc = builder.parse(new ByteArrayInputStream(GraphParser.readSource(xmlFile))); String relPath = projectRoot.relativize(xmlFile).toString(); return Optional.of(new ParsedMuleDocument(relPath, elementChildren(doc.getDocumentElement()))); } catch (Exception e) { diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/PomParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/PomParser.java index 7f942b20..63ac46dd 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/PomParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/PomParser.java @@ -1,6 +1,8 @@ package org.apache.camel.kit.graph.parser; import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.util.List; import java.util.Map; @@ -32,11 +34,13 @@ public class PomParser implements GraphParser { @Override public void parseFiles(Path projectRoot, List<Path> files, ProjectGraph graph) { + GraphParser.requireSafeFiles(projectRoot, files); files.forEach(file -> parsePom(file, projectRoot, graph)); } private void parsePom(Path pomFile, Path projectRoot, ProjectGraph graph) { - try (Reader reader = Files.newBufferedReader(pomFile)) { + try (Reader reader = new StringReader( + new String(GraphParser.readSource(pomFile), StandardCharsets.UTF_8))) { Model model = new MavenXpp3Reader().read(reader); String groupId = model.getGroupId() != null diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/XmlRouteParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/XmlRouteParser.java index 510a12f2..f338ea2b 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/XmlRouteParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/XmlRouteParser.java @@ -1,9 +1,11 @@ package org.apache.camel.kit.graph.parser; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.*; +import java.nio.file.LinkOption; import java.util.*; import javax.xml.parsers.DocumentBuilder; @@ -40,6 +42,7 @@ public class XmlRouteParser implements GraphParser { @Override public void parseFiles(Path projectRoot, List<Path> files, ProjectGraph graph) { + GraphParser.requireSafeFiles(projectRoot, files); files.forEach(file -> parseXmlFile(file, projectRoot, graph)); } @@ -71,7 +74,7 @@ public class XmlRouteParser implements GraphParser { try { var factory = SecureXml.documentBuilderFactory(); DocumentBuilder builder = factory.newDocumentBuilder(); - Document doc = builder.parse(xmlFile.toFile()); + Document doc = builder.parse(new ByteArrayInputStream(GraphParser.readSource(xmlFile))); // Find all route elements NodeList routes = doc.getElementsByTagNameNS("*", "route"); @@ -86,7 +89,7 @@ public class XmlRouteParser implements GraphParser { } private String readHead(Path file) throws IOException { - try (InputStream input = Files.newInputStream(file)) { + try (InputStream input = Files.newInputStream(file, LinkOption.NOFOLLOW_LINKS)) { byte[] buffer = new byte[SNIFF_BYTES]; int read = input.read(buffer); if (read <= 0) { diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/YamlRouteParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/YamlRouteParser.java index b7c54729..feca4475 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/YamlRouteParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/YamlRouteParser.java @@ -36,6 +36,7 @@ public class YamlRouteParser implements GraphParser { @Override public void parseFiles(Path projectRoot, List<Path> files, ProjectGraph graph) { + GraphParser.requireSafeFiles(projectRoot, files); files.forEach(file -> parseYamlFile(file, projectRoot, graph)); } @@ -57,7 +58,7 @@ public class YamlRouteParser implements GraphParser { private void parseYamlFile(Path yamlFile, Path projectRoot, ProjectGraph graph) { try { - JsonNode root = yamlMapper.readTree(yamlFile.toFile()); + JsonNode root = yamlMapper.readTree(GraphParser.readSource(yamlFile)); // Handle array of routes or single route if (root.isArray()) { diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBindingParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBindingParser.java index 82bc94c2..7ca45833 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBindingParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBindingParser.java @@ -1,5 +1,6 @@ package org.apache.camel.kit.graph.parser.biztalk; +import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -66,7 +67,8 @@ public class BizTalkBindingParser { factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); - try (InputStream input = Files.newInputStream(bindingFile)) { + try (InputStream input = new ByteArrayInputStream( + org.apache.camel.kit.graph.parser.GraphParser.readSource(bindingFile))) { XMLStreamReader reader = factory.createXMLStreamReader(input); try { diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBtmParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBtmParser.java index bec8fd6d..e00c07e4 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBtmParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBtmParser.java @@ -1,5 +1,6 @@ package org.apache.camel.kit.graph.parser.biztalk; +import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -130,7 +131,8 @@ public class BizTalkBtmParser { factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); - try (InputStream input = Files.newInputStream(btmFile)) { + try (InputStream input + = new ByteArrayInputStream(org.apache.camel.kit.graph.parser.GraphParser.readSource(btmFile))) { XMLStreamReader reader = factory.createXMLStreamReader(input); try { diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBtpParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBtpParser.java index cd3a333f..34cb9b8f 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBtpParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkBtpParser.java @@ -1,5 +1,6 @@ package org.apache.camel.kit.graph.parser.biztalk; +import java.io.ByteArrayInputStream; import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; @@ -89,7 +90,8 @@ public class BizTalkBtpParser { factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); - try (InputStream input = Files.newInputStream(btpFile)) { + try (InputStream input + = new ByteArrayInputStream(org.apache.camel.kit.graph.parser.GraphParser.readSource(btpFile))) { XMLStreamReader reader = factory.createXMLStreamReader(input); try { diff --git a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkOdxParser.java b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkOdxParser.java index 46021a66..723ab8ca 100644 --- a/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkOdxParser.java +++ b/kit/camel-kit-graph/src/main/java/org/apache/camel/kit/graph/parser/biztalk/BizTalkOdxParser.java @@ -53,7 +53,8 @@ public class BizTalkOdxParser { return; } - String content = Files.readString(odxFile, StandardCharsets.UTF_8); + String content = new String( + org.apache.camel.kit.graph.parser.GraphParser.readSource(odxFile), StandardCharsets.UTF_8); String xmlContent = extractXmlContent(content); if (xmlContent == null || xmlContent.isEmpty()) { return; diff --git a/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/GraphInputBoundaryTest.java b/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/GraphInputBoundaryTest.java new file mode 100644 index 00000000..9fcc32e3 --- /dev/null +++ b/kit/camel-kit-graph/src/test/java/org/apache/camel/kit/graph/GraphInputBoundaryTest.java @@ -0,0 +1,252 @@ +package org.apache.camel.kit.graph; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.apache.camel.kit.graph.model.GraphNode; +import org.apache.camel.kit.graph.model.NodeType; +import org.apache.camel.kit.graph.parser.GraphParser; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.*; + +class GraphInputBoundaryTest { + + private static final int MAX_SOURCE_BYTES = 16 * 1024 * 1024; + + @Test + void selectedRootMayBeASymbolicLink(@TempDir Path parent) throws Exception { + Path root = Files.createDirectory(parent.resolve("real")); + Files.writeString(root.resolve("application.properties"), "inside.canary=ordinary-value\n"); + Path alias = Files.createSymbolicLink(parent.resolve("selected"), root); + assertTrue(new GraphBuilder().build(alias).hasNode("config:inside.canary")); + } + + @Test + void aggregateSourceLimitIsCheckedBeforeParsing(@TempDir Path root) throws Exception { + for (int i = 0; i < 5; i++) { + try (var file + = new java.io.RandomAccessFile(root.resolve("application-" + i + ".properties").toFile(), "rw")) { + file.setLength(MAX_SOURCE_BYTES); + } + } + GraphBuildResult result = new GraphBuilder().buildWithDiagnostics(root); + assertFalse(result.successful()); + assertTrue(result.failureSummary().contains("Project source exceeds")); + assertEquals(0, result.graph().nodeCount()); + } + + @Test + void suppliedFileListsCannotBypassConfinementOrSizeBounds(@TempDir Path parent) throws Exception { + Path root = Files.createDirectory(parent.resolve("project")); + Path outside = Files.writeString(parent.resolve("outside.properties"), "secret=value"); + Path oversized = root.resolve("oversized.properties"); + try (var file = new java.io.RandomAccessFile(oversized.toFile(), "rw")) { + file.setLength(MAX_SOURCE_BYTES + 1L); + } + List<GraphParser> parsers = List.of( + new org.apache.camel.kit.graph.parser.PomParser(), + new org.apache.camel.kit.graph.parser.JavaGraphParser(), + new org.apache.camel.kit.graph.parser.GroovyGraphParser(), + new org.apache.camel.kit.graph.parser.XmlRouteParser(), + new org.apache.camel.kit.graph.parser.MuleXmlFlowParser(), + new org.apache.camel.kit.graph.parser.DataWeaveParser(), + new org.apache.camel.kit.graph.parser.BizTalkParser(), + new org.apache.camel.kit.graph.parser.YamlRouteParser(), + new org.apache.camel.kit.graph.parser.ConfigParser()); + for (GraphParser parser : parsers) { + assertThrows(IllegalArgumentException.class, + () -> parser.parseFiles(root, List.of(outside), new ProjectGraph())); + assertThrows(IllegalArgumentException.class, + () -> parser.parseFiles(root, List.of(oversized), new ProjectGraph())); + } + assertThrows(IllegalArgumentException.class, + () -> GraphParser.requireSafeFiles(root, java.util.Collections.nCopies(100_001, oversized))); + } + + @Test + void suppliedPathsCannotHideAnOutsideSymlinkBehindAParentSegment(@TempDir Path parent) throws Exception { + Path root = Files.createDirectory(parent.resolve("project")); + Path outside = Files.createDirectory(parent.resolve("outside")); + Path outsideChild = Files.createDirectory(outside.resolve("child")); + Files.writeString(root.resolve("application.properties"), "inside.canary=ordinary-value\n"); + Files.writeString(outside.resolve("application.properties"), "outside.canary=private-value\n"); + Path link = Files.createSymbolicLink(root.resolve("link"), outsideChild); + Path input = link.resolve("../application.properties"); + assertEquals(root.resolve("application.properties"), input.normalize()); + assertEquals(outside.resolve("application.properties"), input.toRealPath()); + ProjectGraph graph = new ProjectGraph(); + + assertThrows(IllegalArgumentException.class, + () -> new org.apache.camel.kit.graph.parser.ConfigParser().parseFiles(root, List.of(input), graph)); + assertFalse(graph.hasNode("config:outside.canary")); + } + + @Test + void outsideRootFileSymlinkCannotIntroducePrivateProperties(@TempDir Path tempDir) throws Exception { + Path root = Files.createDirectory(tempDir.resolve("project")); + Path sibling = Files.createDirectory(tempDir.resolve("project-other")); + Path outside = Files.writeString(sibling.resolve("application.properties"), "outside.canary=private-value\n"); + Files.writeString(root.resolve("application-local.properties"), "inside.canary=ordinary-value\n"); + Files.createSymbolicLink(root.resolve("application.properties"), root.relativize(outside)); + + GraphBuildResult result = new GraphBuilder().buildWithDiagnostics(root); + + assertFalse(result.graph().hasNode("config:outside.canary"), + "An input filename inside the project must not authorize reading its outside symlink target"); + assertTrue(result.graph().getNodes().values().stream() + .noneMatch(node -> node.properties().containsValue("private-value"))); + } + + @Test + void directorySymlinkCannotIntroduceOutsideProperties(@TempDir Path tempDir) throws Exception { + Path root = Files.createDirectory(tempDir.resolve("project")); + Path outside = Files.createDirectory(tempDir.resolve("outside")); + Files.writeString(outside.resolve("application.properties"), "outside.canary=private-value\n"); + Files.createSymbolicLink(root.resolve("linked-directory"), outside); + + GraphBuildResult result = new GraphBuilder().buildWithDiagnostics(root); + + assertFalse(result.graph().hasNode("config:outside.canary")); + } + + @Test + void ordinaryInputStillProducesItsGraph(@TempDir Path root) throws Exception { + Files.writeString(root.resolve("application.properties"), "inside.canary=ordinary-value\n"); + + GraphBuildResult result = new GraphBuilder().buildWithDiagnostics(root); + + assertTrue(result.successful(), result::failureSummary); + assertEquals("ordinary-value", result.graph().getNode("config:inside.canary").properties().get("value")); + } + + @Test + void pomParserUsesTheSameTimeoutAndCannotPublishItsPartialGraph(@TempDir Path root) { + GraphBuilder builder = new GraphBuilder(new SlowPomParser(), List.of(), 20, TimeUnit.MILLISECONDS); + + GraphBuildResult result = builder.buildWithDiagnostics(root); + ParserDiagnostic pom = result.diagnostics().stream() + .filter(diagnostic -> diagnostic.parserName().equals("SlowPomParser")) + .findFirst().orElseThrow(); + + assertTrue(pom.timedOut(), "The first parser must obey the configured parser budget too"); + assertFalse(result.successful()); + assertFalse(result.graph().hasNode("maven:partial:timeout"), + "A cancelled parser cannot publish partial results even if it returns after interruption"); + } + + @Test + void laterParserCannotReceiveAFreshBudgetAfterTheFirstTimesOut(@TempDir Path root) { + CountDownLatch firstCancelled = new CountDownLatch(1); + GraphParser first = (project, graph) -> { + try { + firstCancelled.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + firstCancelled.countDown(); + } + }; + GraphParser second = (project, graph) -> { + try { + firstCancelled.await(5, TimeUnit.SECONDS); + // Complete after the first timeout, still inside the old, incorrectly renewed budget. + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + graph.addNode(new GraphNode("class:over-budget", NodeType.CLASS, Map.of())); + }; + GraphBuilder builder = new GraphBuilder((project, graph) -> { + }, List.of(first, second), + 200, TimeUnit.MILLISECONDS); + + GraphBuildResult result = builder.buildWithDiagnostics(root); + + assertTrue(result.diagnostics().get(1).timedOut()); + assertTrue(result.diagnostics().get(2).timedOut(), "Waiting for another parser must not renew this budget"); + assertFalse(result.graph().hasNode("class:over-budget")); + } + + @Test + void sourceAtTheDocumentedLimitIsAccepted(@TempDir Path root) throws Exception { + Path source = root.resolve("application.properties"); + writeProperties(source, MAX_SOURCE_BYTES); + + GraphBuildResult result = new GraphBuilder().buildWithDiagnostics(root); + + assertEquals(MAX_SOURCE_BYTES, Files.size(source)); + assertTrue(result.successful(), result::failureSummary); + assertEquals("ordinary-value", result.graph().getNode("config:inside.canary").properties().get("value")); + } + + @Test + void sourceOneByteOverTheLimitFailsWithoutPublishingItsContents(@TempDir Path root) throws Exception { + Path source = root.resolve("application.properties"); + writeProperties(source, MAX_SOURCE_BYTES + 1); + + GraphBuildResult result = new GraphBuilder().buildWithDiagnostics(root); + + assertEquals(MAX_SOURCE_BYTES + 1L, Files.size(source)); + assertFalse(result.successful(), "Oversized input must produce a diagnostic instead of being parsed"); + assertFalse(result.graph().hasNode("config:inside.canary")); + assertFalse(result.failedDiagnostics().isEmpty()); + } + + @Test + void unrelatedLargeArtifactDoesNotPreventParsingSources(@TempDir Path root) throws Exception { + Files.writeString(root.resolve("application.properties"), "inside.canary=ordinary-value\n"); + try (FileChannel artifact = FileChannel.open(root.resolve("application.jar"), + StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + artifact.position(MAX_SOURCE_BYTES); + artifact.write(ByteBuffer.wrap(new byte[]{0})); + } + + GraphBuildResult result = new GraphBuilder().buildWithDiagnostics(root); + + assertTrue(result.successful(), result::failureSummary); + assertEquals("ordinary-value", result.graph().getNode("config:inside.canary").properties().get("value")); + } + + private static void writeProperties(Path file, int bytes) throws IOException { + byte[] property = "inside.canary=ordinary-value\n".getBytes(StandardCharsets.UTF_8); + byte[] comment = new byte[8192]; + Arrays.fill(comment, (byte) ' '); + comment[0] = '#'; + comment[comment.length - 1] = '\n'; + try (OutputStream output = Files.newOutputStream(file)) { + output.write(property); + int remaining = bytes - property.length; + while (remaining > 0) { + int length = Math.min(remaining, comment.length); + output.write(comment, 0, length); + remaining -= length; + } + } + } + + private static final class SlowPomParser implements GraphParser { + @Override + public void parse(Path root, ProjectGraph graph) { + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + graph.addNode(new GraphNode("maven:partial:timeout", NodeType.MAVEN_ARTIFACT, Map.of())); + } + } +}
