From bbaee3ca68acc1ff6e0dda775aa84f8960b0b3a6 Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 11:25:31 -0400 Subject: [PATCH 1/2] Guard Composer surfaces and canonical futures paths --- .github/workflows/test.yml | 24 ++++++++++----- CHANGELOG.md | 9 ++++++ README.md | 2 +- scripts/clean-install-smoke.sh | 2 +- scripts/validate-public-claims.php | 49 ++++++++++++++++++++++++++++-- src/Client.php | 4 +-- src/RawClient.php | 4 +-- tests/ClientTest.php | 6 ++-- tests/PublicClaimsTest.php | 48 ++++++++++++++++++++++++++++- 9 files changed, 128 insertions(+), 20 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c56298f..064ec4e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,9 @@ on: tags: ["v*"] pull_request: +permissions: + contents: read + jobs: test: name: PHP ${{ matrix.php }} @@ -15,10 +18,12 @@ jobs: matrix: php: ["8.1", "8.2", "8.3", "8.4", "8.5"] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Setup PHP ${{ matrix.php }} - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: ${{ matrix.php }} extensions: curl, json @@ -37,10 +42,12 @@ jobs: name: Packaged Composer example runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" extensions: curl, json @@ -48,17 +55,20 @@ jobs: - name: Install exact Composer archive and run recovery fixtures env: - SDK_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '2.1.0' }} + SDK_VERSION: ${{ startsWith(github.ref, 'refs/tags/v') && github.ref_name || '2.1.1' }} run: ./scripts/clean-install-smoke.sh live: name: Live canonical first request + if: github.event_name != 'pull_request' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false - name: Setup PHP - uses: shivammathur/setup-php@v2 + uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 with: php-version: "8.3" extensions: curl, json diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f89946..68f90cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.1.1 (2026-08-11) + +### Fixed + +- Use the instrument-generic Brent futures path in every packaged raw-client + example and guard future Composer surfaces against venue-path regressions. +- Pin CI actions, disable persisted checkout credentials, and keep + authenticated production smoke credentials out of pull-request jobs. + ## 2.1.0 (2026-07-19) ### Fixed diff --git a/README.md b/README.md index 85f69fd..753fbb7 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ private compatible endpoint is required. Use `raw()` for a versioned GET route that does not yet have a typed method: ```php -$curve = $client->raw()->get('/v1/futures/ice-brent/curve'); +$curve = $client->raw()->get('/v1/futures/brent/curve'); ``` Availability varies by dataset, plan, source, and account entitlement. Review diff --git a/scripts/clean-install-smoke.sh b/scripts/clean-install-smoke.sh index 3e1df08..d57d9e8 100755 --- a/scripts/clean-install-smoke.sh +++ b/scripts/clean-install-smoke.sh @@ -4,7 +4,7 @@ set -euo pipefail root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" tmp_dir="$(mktemp -d)" server_pid="" -sdk_version="${SDK_VERSION:-2.1.0}" +sdk_version="${SDK_VERSION:-2.1.1}" cleanup() { if [[ -n "$server_pid" ]]; then diff --git a/scripts/validate-public-claims.php b/scripts/validate-public-claims.php index cdaed4c..40aee32 100644 --- a/scripts/validate-public-claims.php +++ b/scripts/validate-public-claims.php @@ -63,17 +63,60 @@ function oilpriceapiClaimFailures(string $root, array $files): array foreach ($patterns as $label => $pattern) { preg_match_all($pattern, $content, $matches); foreach ($matches[0] as $match) { - if ($label === 'fixed demo rate' && strtolower($match) === '50 requests/day') { - continue; - } $failures[] = sprintf('%s: %s matched %s', $file, $label, $match); } } + foreach (oilpriceapiFixedCadenceClaims($content) as $claim) { + $failures[] = sprintf('%s: fixed request cadence matched %s', $file, $claim); + } } return $failures; } +/** + * Find mutable count/action/cadence claims in any word order within a bounded + * paragraph or sentence. Non-request counts such as tests or records per page + * are intentionally outside the invariant. + * + * @return list + */ +function oilpriceapiFixedCadenceClaims(string $content): array +{ + $segments = preg_split('~(?:\R\s*\R)|(?<=[.!?])\s+~u', $content) ?: []; + $patterns = [ + '~\b\d[\d,]*\b~u', + '~\b(?:api\s+)?(?:requests?|calls?|queries|hits?|credits?)\b~iu', + '~(?:\b(?:daily|hourly|minutely|monthly)\b|(?:/|\bper\b|\bevery\b|\beach\b|\bin\b)\s*(?:(?:a|an|one|1|24)\s+)?(?:minutes?|mins?|hours?|hrs?|days?|months?)\b)~iu', + ]; + + $claims = []; + foreach ($segments as $segment) { + $positions = []; + foreach ($patterns as $pattern) { + preg_match_all($pattern, $segment, $matches, PREG_OFFSET_CAPTURE); + $positions[] = array_map(static fn (array $match): int => $match[1], $matches[0]); + } + if (in_array([], $positions, true)) { + continue; + } + + foreach ($positions[0] as $countPosition) { + foreach ($positions[1] as $actionPosition) { + foreach ($positions[2] as $cadencePosition) { + if (max($countPosition, $actionPosition, $cadencePosition) + - min($countPosition, $actionPosition, $cadencePosition) <= 200) { + $claims[] = trim((string) preg_replace('~\s+~u', ' ', $segment)); + continue 4; + } + } + } + } + } + + return $claims; +} + if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { $root = $argv[1] ?? ''; $files = oilpriceapiPublicTextFiles($root); diff --git a/src/Client.php b/src/Client.php index 5196a75..47eb801 100644 --- a/src/Client.php +++ b/src/Client.php @@ -28,7 +28,7 @@ */ final class Client { - public const VERSION = '2.1.0'; + public const VERSION = '2.1.1'; public const DEFAULT_BASE_URL = 'https://api.oilpriceapi.com'; public const DEFAULT_TIMEOUT = 10.0; public const DEFAULT_MAX_RETRIES = 3; @@ -170,7 +170,7 @@ public function demoPrices(): array /** * Escape hatch: call a versioned GET endpoint and get its decoded envelope. * - * $curve = $client->raw()->get('/v1/futures/ice-brent/curve'); + * $curve = $client->raw()->get('/v1/futures/brent/curve'); */ public function raw(): RawClient { diff --git a/src/RawClient.php b/src/RawClient.php index 05f842a..ad97aae 100644 --- a/src/RawClient.php +++ b/src/RawClient.php @@ -12,7 +12,7 @@ * Returns the full decoded JSON envelope as an associative array, so ANY * OilPriceAPI endpoint is reachable without waiting for an SDK release: * - * $curve = $client->raw()->get('/v1/futures/ice-brent/curve'); + * $curve = $client->raw()->get('/v1/futures/brent/curve'); * foreach ($curve['data']['contracts'] ?? [] as $contract) { * // ... * } @@ -31,7 +31,7 @@ public function __construct(private readonly Closure $requester) /** * Perform a GET request against any API path. * - * @param string $path API path, e.g. '/v1/futures/ice-brent/curve' + * @param string $path API path, e.g. '/v1/futures/brent/curve' * @param array $params Query string parameters * * @return array Full decoded JSON response (envelope included) diff --git a/tests/ClientTest.php b/tests/ClientTest.php index 299277e..7a54c46 100644 --- a/tests/ClientTest.php +++ b/tests/ClientTest.php @@ -365,7 +365,7 @@ public function testRawEscapeHatchReachesAnyEndpoint(): void $envelope = [ 'status' => 'success', 'data' => [ - 'contract' => 'ice-brent', + 'contract' => 'brent', 'curve' => [ ['month' => '2026-08', 'price' => 71.50], ['month' => '2026-09', 'price' => 71.10], @@ -374,11 +374,11 @@ public function testRawEscapeHatchReachesAnyEndpoint(): void ]; $this->transport->queue(200, $envelope); - $result = $this->client()->raw()->get('/v1/futures/ice-brent/curve', ['unit' => 'usd']); + $result = $this->client()->raw()->get('/v1/futures/brent/curve', ['unit' => 'usd']); $this->assertSame($envelope, $result, 'raw() must return the full decoded envelope'); $this->assertSame( - 'https://api.oilpriceapi.com/v1/futures/ice-brent/curve?unit=usd', + 'https://api.oilpriceapi.com/v1/futures/brent/curve?unit=usd', $this->transport->requests[0]['url'], ); $this->assertSame('Token test_key', $this->transport->requests[0]['headers']['Authorization']); diff --git a/tests/PublicClaimsTest.php b/tests/PublicClaimsTest.php index 36e95d1..b24c0c9 100644 --- a/tests/PublicClaimsTest.php +++ b/tests/PublicClaimsTest.php @@ -29,6 +29,18 @@ public function testFutureComposerTextFilesAndQuotaAliasesCannotBypassDiscovery( file_put_contents($root . '/CUSTOMER_GUIDE', "See current product facts.\n"); file_put_contents($root . '/docs/nested/guide.md', "Includes 1,000 API requests/month.\n"); file_put_contents($root . '/src/data/catalog.json', '{"rate": "100 requests per hour"}'); + $cadenceClaims = [ + '50 requests/day', + '50 API calls/day', + 'daily 50-request limit', + '50 requests every 24 hours', + '50-call limit per day', + '50 requests allowed daily', + ]; + foreach ($cadenceClaims as $index => $claim) { + file_put_contents($root . sprintf('/src/data/cadence-%d.txt', $index), $claim . "\n"); + } + file_put_contents($root . '/src/data/negative.txt', "50 tests daily\n50 records per page\n"); file_put_contents($root . '/src/data/cache.pyc', "\x00\xff\x00"); try { @@ -41,6 +53,12 @@ public function testFutureComposerTextFilesAndQuotaAliasesCannotBypassDiscovery( $failures = oilpriceapiClaimFailures($root, $files); self::assertTrue($this->containsFailure($failures, 'docs/nested/guide.md', 'fixed allowance')); self::assertTrue($this->containsFailure($failures, 'src/data/catalog.json', 'fixed demo rate')); + foreach (array_keys($cadenceClaims) as $index) { + self::assertTrue( + $this->containsFailure($failures, sprintf('src/data/cadence-%d.txt', $index), 'fixed request cadence'), + ); + } + self::assertFalse($this->containsFailure($failures, 'src/data/negative.txt', 'fixed request cadence')); } finally { $this->removeDirectory($root); } @@ -93,6 +111,34 @@ public function testPublicSurfacesContainNoHighRiskProductClaims(): void } } + public function testPackagedSurfacesUseInstrumentGenericFuturesPaths(): void + { + $root = dirname(__DIR__); + foreach ($this->publicSurfaceFiles($root) as $file) { + $content = (string) file_get_contents($root . '/' . $file); + self::assertDoesNotMatchRegularExpression( + '~/(?:ice-(?:brent|wti|gasoil)|eua-carbon)(?:/|[\'"`])~i', + $content, + sprintf('%s contains a venue-specific futures path', $file), + ); + } + } + + public function testWorkflowActionsArePinnedAndCheckoutCredentialsAreNotPersisted(): void + { + $workflow = (string) file_get_contents(dirname(__DIR__) . '/.github/workflows/test.yml'); + self::assertStringContainsString( + 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', + $workflow, + ); + self::assertStringContainsString( + 'shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240', + $workflow, + ); + self::assertSame(substr_count($workflow, 'actions/checkout@'), substr_count($workflow, 'persist-credentials: false')); + self::assertStringContainsString("live:\n name: Live canonical first request\n if: github.event_name != 'pull_request'", $workflow); + } + /** * @return list */ @@ -162,7 +208,7 @@ public function testCanonicalDeveloperContractIsDiscoverable(): void ); self::assertSame('oilpriceapi/oilpriceapi', $composer['name']); self::assertSame('>=8.1', $composer['require']['php']); - self::assertSame('2.1.0', Client::VERSION); + self::assertSame('2.1.1', Client::VERSION); self::assertSame('https://api.oilpriceapi.com', Client::DEFAULT_BASE_URL); $readme = (string) file_get_contents($root . '/README.md'); From 33705c5225516fac239755783829faaffea7e4d0 Mon Sep 17 00:00:00 2001 From: Karl Waldman Date: Tue, 11 Aug 2026 11:45:15 -0400 Subject: [PATCH 2/2] test: bind package and workflow guards --- .github/workflows/test.yml | 13 ++--- scripts/validate-public-claims.php | 42 +++++++++++++- tests/PublicClaimsTest.php | 88 +++++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 13 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 064ec4e..b085374 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,7 @@ jobs: live: name: Live canonical first request - if: github.event_name != 'pull_request' + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -77,17 +77,16 @@ jobs: - name: Install dependencies run: composer update --no-interaction --prefer-dist --no-progress - # Optional live smoke test. IMPORTANT: do NOT gate this step with + # Required default-branch live smoke test. IMPORTANT: do NOT gate this step with # `if: ${{ secrets.* }}` - the secrets context is unavailable in # step-level `if` expressions and invalidates the whole workflow file. - # Guard inside the shell instead so it skips gracefully when the - # secret is not configured. - - name: Live smoke test (skips when no secret) + # Validate inside the shell so a missing production credential fails closed. + - name: Live smoke test env: OILPRICEAPI_TEST_KEY: ${{ secrets.OILPRICEAPI_TEST_KEY }} run: | if [ -z "$OILPRICEAPI_TEST_KEY" ]; then - echo "OILPRICEAPI_TEST_KEY not configured - skipping live smoke test." - exit 0 + echo "OILPRICEAPI_TEST_KEY is required for the default-branch live smoke test." >&2 + exit 1 fi OILPRICEAPI_KEY="$OILPRICEAPI_TEST_KEY" php examples/smoke.php diff --git a/scripts/validate-public-claims.php b/scripts/validate-public-claims.php index 40aee32..7e5834f 100644 --- a/scripts/validate-public-claims.php +++ b/scripts/validate-public-claims.php @@ -5,17 +5,52 @@ /** * @return list */ -function oilpriceapiPublicTextFiles(string $root): array +function oilpriceapiPublicTextFiles(string $root, array $excludedPaths = []): array { $root = realpath($root) ?: $root; if (!is_dir($root)) { throw new InvalidArgumentException(sprintf('Package root does not exist: %s', $root)); } + $excludedPaths = array_map( + static function (string $path): string { + $path = trim(str_replace(DIRECTORY_SEPARATOR, '/', $path), '/'); + if ($path === '' || strpbrk($path, '*?[]!') !== false) { + throw new InvalidArgumentException(sprintf('Unsupported Composer archive exclusion: %s', $path)); + } + + return $path; + }, + $excludedPaths, + ); + $isExcluded = static function (string $relative) use ($excludedPaths): bool { + foreach ($excludedPaths as $excludedPath) { + if ($relative === $excludedPath || str_starts_with($relative, $excludedPath . '/')) { + return true; + } + } + + return false; + }; + $files = []; - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), + $directory = new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS); + $filter = new RecursiveCallbackFilterIterator( + $directory, + static function (SplFileInfo $file) use ($root, $isExcluded): bool { + if ($file->isLink()) { + return false; + } + $relative = str_replace( + DIRECTORY_SEPARATOR, + '/', + substr($file->getPathname(), strlen($root) + 1), + ); + + return !$isExcluded($relative); + }, ); + $iterator = new RecursiveIteratorIterator($filter); foreach ($iterator as $file) { if (!$file instanceof SplFileInfo || !$file->isFile() || $file->isLink()) { continue; @@ -51,6 +86,7 @@ function oilpriceapiClaimFailures(string $root, array $files): array 'real-time claim' => '~\breal[- ]time\b~i', 'free-tier claim' => '~\bfree\s+tier\b|\bfree\s+api\s+key\b~i', 'fixed demo rate' => '~\b\d+\s+(requests?|reqs?\.?)\s*((per|an?)\s+|/\s*)(minutes?|mins?|hours?|hrs?|days?)\b~i', + 'venue-specific futures path' => '~/(?:ice-(?:brent|wti|gasoil)|eua-carbon)(?:/|[\'"`])~i', ]; $failures = []; diff --git a/tests/PublicClaimsTest.php b/tests/PublicClaimsTest.php index b24c0c9..52e641e 100644 --- a/tests/PublicClaimsTest.php +++ b/tests/PublicClaimsTest.php @@ -41,6 +41,7 @@ public function testFutureComposerTextFilesAndQuotaAliasesCannotBypassDiscovery( file_put_contents($root . sprintf('/src/data/cadence-%d.txt', $index), $claim . "\n"); } file_put_contents($root . '/src/data/negative.txt', "50 tests daily\n50 records per page\n"); + file_put_contents($root . '/docs/nested/futures.md', "GET /ice-brent/curve\n"); file_put_contents($root . '/src/data/cache.pyc', "\x00\xff\x00"); try { @@ -53,6 +54,9 @@ public function testFutureComposerTextFilesAndQuotaAliasesCannotBypassDiscovery( $failures = oilpriceapiClaimFailures($root, $files); self::assertTrue($this->containsFailure($failures, 'docs/nested/guide.md', 'fixed allowance')); self::assertTrue($this->containsFailure($failures, 'src/data/catalog.json', 'fixed demo rate')); + self::assertTrue( + $this->containsFailure($failures, 'docs/nested/futures.md', 'venue-specific futures path'), + ); foreach (array_keys($cadenceClaims) as $index) { self::assertTrue( $this->containsFailure($failures, sprintf('src/data/cadence-%d.txt', $index), 'fixed request cadence'), @@ -114,7 +118,7 @@ public function testPublicSurfacesContainNoHighRiskProductClaims(): void public function testPackagedSurfacesUseInstrumentGenericFuturesPaths(): void { $root = dirname(__DIR__); - foreach ($this->publicSurfaceFiles($root) as $file) { + foreach ($this->composerPackageTextFiles($root) as $file) { $content = (string) file_get_contents($root . '/' . $file); self::assertDoesNotMatchRegularExpression( '~/(?:ice-(?:brent|wti|gasoil)|eua-carbon)(?:/|[\'"`])~i', @@ -135,8 +139,30 @@ public function testWorkflowActionsArePinnedAndCheckoutCredentialsAreNotPersiste 'shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240', $workflow, ); - self::assertSame(substr_count($workflow, 'actions/checkout@'), substr_count($workflow, 'persist-credentials: false')); - self::assertStringContainsString("live:\n name: Live canonical first request\n if: github.event_name != 'pull_request'", $workflow); + self::assertTrue($this->checkoutStepsAreHardened($workflow)); + self::assertStringContainsString( + "live:\n name: Live canonical first request\n" + . " if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)", + $workflow, + ); + self::assertStringContainsString('OILPRICEAPI_TEST_KEY is required', $workflow); + self::assertStringNotContainsString('skipping live smoke test', $workflow); + } + + public function testCheckoutCredentialsMustBeDisabledOnEveryCheckoutStep(): void + { + $workflow = <<<'YAML' +steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + env: + persist-credentials: false + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + duplicate-proof: persist-credentials: false +YAML; + + self::assertFalse($this->checkoutStepsAreHardened($workflow)); } /** @@ -165,6 +191,62 @@ private function publicSurfaceFiles(string $root): array return $files; } + /** + * @return list + */ + private function composerPackageTextFiles(string $root): array + { + $composer = json_decode( + (string) file_get_contents($root . '/composer.json'), + true, + flags: JSON_THROW_ON_ERROR, + ); + + return oilpriceapiPublicTextFiles($root, $composer['archive']['exclude'] ?? []); + } + + private function checkoutStepsAreHardened(string $workflow): bool + { + $lines = preg_split('~\R~', $workflow) ?: []; + $checkoutCount = 0; + + foreach ($lines as $index => $line) { + if (preg_match('~^(\s*)-\s+uses:\s*actions/checkout@[0-9a-f]{40}(?:\s*#.*)?$~', $line, $match) !== 1) { + continue; + } + ++$checkoutCount; + $stepIndent = strlen($match[1]); + $hardened = false; + $insideWith = false; + + for ($cursor = $index + 1; $cursor < count($lines); ++$cursor) { + $candidate = $lines[$cursor]; + if (preg_match('~^\s{' . $stepIndent . '}-\s+~', $candidate) === 1) { + break; + } + if (preg_match('~^ {' . ($stepIndent + 2) . '}with:\s*(?:#.*)?$~', $candidate) === 1) { + $insideWith = true; + continue; + } + if ($insideWith && preg_match('~^ {' . ($stepIndent + 2) . '}\S~', $candidate) === 1) { + $insideWith = false; + } + if ($insideWith && preg_match( + '~^ {' . ($stepIndent + 4) . '}persist-credentials:\s*false\s*(?:#.*)?$~', + $candidate, + ) === 1) { + $hardened = true; + } + } + + if (!$hardened) { + return false; + } + } + + return $checkoutCount > 0; + } + /** * @param list $failures */