diff --git a/src/Operations/SourcePageHandler.php b/src/Operations/SourcePageHandler.php index 62bf295..512ff24 100644 --- a/src/Operations/SourcePageHandler.php +++ b/src/Operations/SourcePageHandler.php @@ -12,6 +12,7 @@ use Milpa\Command\InvocationContext; use Milpa\DevTools\Support\RootResolver; +use Milpa\DevTools\Support\SecretFiles; use Milpa\DevTools\Support\SourcePath; use Milpa\ToolRuntime\Contracts\ResultBudget; use Milpa\ToolRuntime\Contracts\ToolContext; @@ -49,6 +50,10 @@ public function handle(array $input, ?InvocationContext $context = null, ?ToolCo if ($file === null) { return ['ok' => false, 'error' => 'path must name a file inside the app root']; } + if (SecretFiles::holds($file, $root)) { + // A SECRET HAS ONE PLACE TO LIVE (greenhouse evidence/1161). + return ['ok' => false, 'error' => 'that file is where this house keeps a secret: it is not read']; + } $path = SourcePath::relative($file, $root); $content = file_get_contents($file); if ($content === false || !mb_check_encoding($content, 'UTF-8')) { diff --git a/src/Operations/SourceReadHandler.php b/src/Operations/SourceReadHandler.php index 8802e35..e0e459c 100644 --- a/src/Operations/SourceReadHandler.php +++ b/src/Operations/SourceReadHandler.php @@ -15,6 +15,7 @@ namespace Milpa\DevTools\Operations; use Milpa\DevTools\Support\RootResolver; +use Milpa\DevTools\Support\SecretFiles; use Milpa\DevTools\Support\SourcePath; /** @@ -65,6 +66,11 @@ public function handle(array $input): array if ($file === null) { return ['ok' => false, 'error' => "`path` has to be a file inside {$root} — got: {$path}"]; } + if (SecretFiles::holds($file, $root)) { + // A SECRET HAS ONE PLACE TO LIVE (greenhouse evidence/1161): the files the house keeps a secret in + // are inside the root and real, but a read does not hand them back. The refusal names the path only. + return ['ok' => false, 'error' => "{$path} is where this house keeps a secret: it is not read"]; + } $from = \is_int($input['from'] ?? null) ? max(1, $input['from']) : 1; $budget = \is_int($input['lines'] ?? null) ? $input['lines'] : self::DEFAULT_LINES; diff --git a/src/Support/SecretFiles.php b/src/Support/SecretFiles.php new file mode 100644 index 0000000..ec81377 --- /dev/null +++ b/src/Support/SecretFiles.php @@ -0,0 +1,66 @@ + + * + * @license Apache-2.0 + * + * @link https://github.com/getmilpa/devtools + */ + +declare(strict_types=1); + +namespace Milpa\DevTools\Support; + +/** + * THE FILES OF A HOUSE THAT HOLD SECRETS — a secret has one place to live. + * + * A reading operation of this package is given to a session, and it returns a slice of any file inside the app + * root. Inside the root sit the files where the house keeps a secret: its environment file and the family beside + * it (`.env`, `.env.local`, `.env.production`…), the envelope a declared key is written to, and Composer's + * credentials. A read does not hand those back — nor a COPY of one a trial or a boot candidate kept. + * + * `HouseWork` already refuses to write any file whose name begins with `.env`; this says the same of a read, a + * copy and the runner's mask (greenhouse evidence/1161). The named templates are not secrets: they are the + * placeholders a project commits, and they stay readable. + */ +final class SecretFiles +{ + /** The envelope and Composer's credentials, matched by the tail of a path. */ + private const TAIL = ['.milpa/secrets.json', 'auth.json']; + + /** `.env*` names that are placeholders, not secrets. */ + private const TEMPLATES = ['.env.example', '.env.dist', '.env.template', '.env.sample']; + + /** + * Whether a path from the root of a house names a file the house keeps a secret in — the file itself, OR a + * copy of it anywhere below (a trial under `var/trials//copy/`, a boot candidate under + * `var/boot-candidates//`). Matched case-insensitively: a case-insensitive filesystem (macOS, the + * Desktop's) resolves `.ENV` and `.Milpa/Secrets.json` to the real file, so a match that was byte-exact + * would miss them. + */ + public static function isSecret(string $relative): bool + { + $relative = str_replace('\\', '/', $relative); + $base = strtolower(basename($relative)); + if (str_starts_with($base, '.env') && ! \in_array($base, self::TEMPLATES, true)) { + return true; + } + $low = strtolower($relative); + foreach (self::TAIL as $tail) { + if ($low === $tail || str_ends_with($low, '/' . $tail)) { + return true; + } + } + + return false; + } + + /** Whether a file already admitted by {@see SourcePath::inside()} is one the house keeps a secret in. */ + public static function holds(string $file, string $root): bool + { + return self::isSecret(SourcePath::relative($file, $root)); + } +} diff --git a/tests/Operations/SourcePageHandlerTest.php b/tests/Operations/SourcePageHandlerTest.php index fff7c74..ec34633 100644 --- a/tests/Operations/SourcePageHandlerTest.php +++ b/tests/Operations/SourcePageHandlerTest.php @@ -153,4 +153,20 @@ public function testInvalidUtf8IsRejectedBeforeSplitting(): void file_put_contents($this->root . '/sample.txt', "\xFF"); self::assertFalse($this->handler->handle(['path' => 'sample.txt', 'max_chars' => 800])['ok']); } + + /** + * A SECRET HAS ONE PLACE TO LIVE (greenhouse evidence/1161): a page of source does not hand back a file + * the house keeps a secret in. + */ + public function testAFileThatHoldsASecretIsNotPaged(): void + { + file_put_contents($this->root . '/.env', "SECRET=canary\n"); + $context = new ToolContext(resultBudget: ResultBudget::json(8000)); + + $result = $this->handler->handle(['path' => '.env'], null, $context); + + self::assertFalse($result['ok']); + self::assertArrayNotHasKey('content', $result); + self::assertStringNotContainsString('canary', (string) ($result['error'] ?? '')); + } } diff --git a/tests/Operations/SourceReadHandlerTest.php b/tests/Operations/SourceReadHandlerTest.php index c328ea0..916196b 100644 --- a/tests/Operations/SourceReadHandlerTest.php +++ b/tests/Operations/SourceReadHandlerTest.php @@ -95,6 +95,105 @@ public function testATraversalOutsideTheRootIsRefused(): void } /** Without `path` there is nothing to read — answered, not thrown. */ + /** + * A SECRET HAS ONE PLACE TO LIVE (greenhouse evidence/1161). The files a house keeps a secret in — its + * environment file, the envelope a declared key is written to, Composer's credentials — are inside the + * root and real, but a read does not hand them back. Everything else reads as before. + * + * @param string $path the relative path of a file that holds a secret + */ + #[\PHPUnit\Framework\Attributes\DataProvider('secretFiles')] + public function testAFileThatHoldsASecretIsNotRead(string $path): void + { + $full = $this->root . '/' . $path; + @mkdir(\dirname($full), 0o775, true); + file_put_contents($full, "SECRET=canary\n"); + + $result = $this->handler()->handle(['path' => $path]); + + self::assertFalse($result['ok'], $path); + self::assertArrayNotHasKey('content', $result); + self::assertStringNotContainsString('canary', (string) ($result['error'] ?? ''), 'the refusal never carries the value'); + } + + /** @return iterable */ + public static function secretFiles(): iterable + { + yield 'the environment file' => ['.env']; + yield 'the secret envelope' => ['.milpa/secrets.json']; + yield 'composer credentials' => ['auth.json']; + } + + /** + * A COPY OF A SECRET IS A SECRET (greenhouse evidence/1161). A trial under `var/trials//copy/` keeps a + * copy of the envelope; a house that has not discarded its old trials still holds it. A read withholds a + * file whose path ENDS in one of the secret files, at any depth — but not one that merely looks like it. + * + * @param string $path the relative path of a copy of a secret file + */ + #[\PHPUnit\Framework\Attributes\DataProvider('secretCopies')] + public function testACopyOfASecretFileIsNotReadEither(string $path): void + { + $full = $this->root . '/' . $path; + @mkdir(\dirname($full), 0o775, true); + file_put_contents($full, "SECRET=canary\n"); + + $result = $this->handler()->handle(['path' => $path]); + + self::assertFalse($result['ok'], $path); + self::assertArrayNotHasKey('content', $result); + self::assertStringNotContainsString('canary', (string) ($result['error'] ?? '')); + } + + /** @return iterable */ + public static function secretCopies(): iterable + { + yield 'a trial copy of the envelope' => ['var/trials/w1/copy/.milpa/secrets.json']; + yield 'a trial copy of composer credentials' => ['var/trials/w1/copy/auth.json']; + yield 'a trial copy of the environment file' => ['var/trials/w1/copy/.env']; + yield 'a boot candidate copy of the envelope' => ['var/boot-candidates/b1/.milpa/secrets.json']; + yield 'an env-local of the house' => ['.env.local']; + yield 'an env for production' => ['.env.production']; + yield 'the envelope in upper case' => ['.MILPA/SECRETS.JSON']; + yield 'an env in upper case' => ['.ENV']; + yield 'composer credentials in upper case' => ['AUTH.JSON']; + yield 'a trial copy of env-local' => ['var/trials/w1/copy/.env.local']; + } + + /** + * @param string $path a file that only resembles a secret file + */ + #[\PHPUnit\Framework\Attributes\DataProvider('lookAlikes')] + public function testAFileThatOnlyLooksLikeASecretIsReadAsAlways(string $path): void + { + $full = $this->root . '/' . $path; + @mkdir(\dirname($full), 0o775, true); + file_put_contents($full, "not a secret\n"); + + self::assertTrue($this->handler()->handle(['path' => $path])['ok'], $path); + } + + /** @return iterable */ + public static function lookAlikes(): iterable + { + yield 'a secrets.json outside .milpa' => ['docs/secrets.json']; + yield 'an auth.json with a suffix' => ['src/auth.json.php']; + yield 'an env example' => ['.env.example']; + yield 'a file ending in env' => ['config/myenv']; + yield 'an env example template' => ['.env.example']; + yield 'an env dist template' => ['.env.dist']; + yield 'an env template' => ['.env.template']; + } + + public function testASourceFileNamedLikeNoSecretStillReads(): void + { + file_put_contents($this->root . '/src/secrets.php', "handler()->handle(['path' => 'src/secrets.php']); + + self::assertTrue($result['ok'], 'only the house\'s own secret files are withheld, by their exact path'); + } + public function testAMissingPathInputIsAnswered(): void { $result = $this->handler()->handle([]);