Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Operations/SourcePageHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')) {
Expand Down
6 changes: 6 additions & 0 deletions src/Operations/SourceReadHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
namespace Milpa\DevTools\Operations;

use Milpa\DevTools\Support\RootResolver;
use Milpa\DevTools\Support\SecretFiles;
use Milpa\DevTools\Support\SourcePath;

/**
Expand Down Expand Up @@ -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;
Expand Down
66 changes: 66 additions & 0 deletions src/Support/SecretFiles.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

/**
* This file is part of Milpa DevTools — the generate-verify-inspect developer loop of the Milpa PHP framework.
*
* (c) Rodrigo Vicente - TeamX Agency — https://teamx.agency <hola@teamx.agency>
*
* @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/<id>/copy/`, a boot candidate under
* `var/boot-candidates/<id>/`). 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));
}
}
16 changes: 16 additions & 0 deletions tests/Operations/SourcePageHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] ?? ''));
}
}
99 changes: 99 additions & 0 deletions tests/Operations/SourceReadHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{string}> */
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/<id>/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<string, array{string}> */
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<string, array{string}> */
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', "<?php // not the envelope\n");

$result = $this->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([]);
Expand Down
Loading