Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
15 changes: 15 additions & 0 deletions scripts/hourly-commercial-readiness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,24 @@ function bound(value, limit = MAX_REPORT_DETAIL_CHARS) {
return text.length <= limit ? text : `${text.slice(0, limit)}…`;
}

export function createGhSubprocessEnvironment(sourceEnvironment = process.env) {
const childEnvironment = {
GH_HOST: "github.com",
NO_COLOR: "1",
};
if (typeof sourceEnvironment.PATH === "string" && sourceEnvironment.PATH.length > 0) {
childEnvironment.PATH = sourceEnvironment.PATH;
}
if (typeof sourceEnvironment.GH_TOKEN === "string" && sourceEnvironment.GH_TOKEN.length > 0) {
childEnvironment.GH_TOKEN = sourceEnvironment.GH_TOKEN;
}
return childEnvironment;
}
Comment on lines +43 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

GH_TOKEN을 process.env에서 읽지 마십시오.

Line 32의 기본 인자와 Line 40의 접근은 process.env.GH_TOKEN을 직접 읽습니다. GH_TOKEN은 실제 자격 증명입니다. KV 또는 credential registry에서 토큰을 읽고, 명시적 입력으로 createGhSubprocessEnvironment에 전달하십시오. process.env를 기본 시크릿 소스로 사용하지 마십시오.

As per coding guidelines, scripts/*.mjs: Scripts may read process.env only for non-secret build-time configuration such as file paths and thresholds; real secrets must come from KV or a credential registry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/hourly-commercial-readiness.mjs` around lines 32 - 44, Update
createGhSubprocessEnvironment so GH_TOKEN is supplied through an explicit
argument populated by the KV or credential registry, rather than read from
sourceEnvironment or process.env. Keep process.env usage limited to non-secret
configuration such as PATH, and include the provided token in the child
environment only when valid.

Source: Coding guidelines


function runGh(args, { input } = {}) {
const completed = spawnSync("gh", args, {
encoding: "utf8",
env: createGhSubprocessEnvironment(),
input,
maxBuffer: MAX_GH_OUTPUT_BYTES,
shell: false,
Expand Down
51 changes: 49 additions & 2 deletions test/hourly-commercial-readiness-script.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
createGhSubprocessEnvironment,
flattenArrayPages,
hasActiveNoemaReviewRun,
latestCheckRunsBySuite,
latestReviewStates,
parseNoemaReviewDecision,
redactSensitiveValue,
} from "../scripts/hourly-commercial-readiness.mjs";

const repository = "ContextualWisdomLab/noema";
Expand Down Expand Up @@ -242,11 +244,56 @@
).toBe(false);
});

it("passes only explicit GitHub CLI authority into child processes", () => {
expect(createGhSubprocessEnvironment({
PATH: "/trusted/bin",
GH_TOKEN: "read-only-maintainer-token",
GH_HOST: "evil.example",
NO_COLOR: "0",
GITHUB_TOKEN: "ambient-workflow-token",
NVIDIA_NIM_API_KEY: "model-secret",
NOEMA_MAINTAINER_APP_PRIVATE_KEY: "maintainer-private-key",
NOEMA_REVIEWER_APP_PRIVATE_KEY: "reviewer-private-key",
NOEMA_REVIEWER_LOGIN: "reviewer[bot]",
CLOUDFLARE_API_TOKEN: "cloudflare-secret",
HTTPS_PROXY: "http://proxy.invalid",
HTTP_PROXY: "http://proxy.invalid",
ALL_PROXY: "socks5://proxy.invalid",
HOME: "/credential-bearing-home",
NODE_OPTIONS: "--require /tmp/preload.cjs",
NOEMA_MAINTENANCE_ENABLED: "true",
})).toEqual({
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
GH_HOST: "github.com",
NO_COLOR: "1",
PATH: "/trusted/bin",
GH_TOKEN: "read-only-maintainer-token",
});

expect(createGhSubprocessEnvironment({})).toEqual({
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
GH_HOST: "github.com",
NO_COLOR: "1",
});
});

it("redacts an explicit maintainer token before child diagnostics can reach retained outputs", () => {
const token = "read-only-maintainer-token";
const detail = `gh failed with ${token}; retry also exposed ${token}`;

expect(redactSensitiveValue(detail, [token])).toBe(

Check failure on line 282 in test/hourly-commercial-readiness-script.test.ts

View workflow job for this annotation

GitHub Actions / verify

test/hourly-commercial-readiness-script.test.ts > hourly commercial-readiness GitHub adapter > redacts an explicit maintainer token before child diagnostics can reach retained outputs

TypeError: redactSensitiveValue is not a function ❯ test/hourly-commercial-readiness-script.test.ts:282:12
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
"gh failed with [REDACTED]; retry also exposed [REDACTED]",
);
expect(redactSensitiveValue(detail, ["", null, undefined, token])).not.toContain(token);
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
expect(redactSensitiveValue("safe diagnostic", [])).toBe("safe diagnostic");
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
});

it("uses shell-free complete pagination and exact-head write contracts", () => {
const script = readFileSync("scripts/hourly-commercial-readiness.mjs", "utf8");

expect(script).toContain('spawnSync("gh"');
expect(script).toContain("shell: false");
expect(script).toContain("env: childEnvironment");

Check failure on line 294 in test/hourly-commercial-readiness-script.test.ts

View workflow job for this annotation

GitHub Actions / verify

test/hourly-commercial-readiness-script.test.ts > hourly commercial-readiness GitHub adapter > uses shell-free complete pagination and exact-head write contracts

AssertionError: expected '#!/usr/bin/env node\nimport { spawnSy…' to contain 'env: childEnvironment' - Expected + Received - env: childEnvironment + #!/usr/bin/env node + import { spawnSync } from "node:child_process"; + import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"; + import { dirname, resolve } from "node:path"; + import { pathToFileURL } from "node:url"; + import { evaluatePullRequest } from "./lib/commercial-readiness-loop.mjs"; + + const MAX_ERROR_CHARS = 4_000; + const MAX_REPORT_DETAIL_CHARS = 1_000; + const MAX_GH_OUTPUT_BYTES = 16 * 1024 * 1024; + const repositoryPattern = /^ContextualWisdomLab\/[A-Za-z0-9_.-]+$/; + const botLoginPattern = /^[A-Za-z0-9-]+\[bot\]$/; + const fullShaPattern = /^[0-9a-f]{40}$/i; + const noemaMarkerPattern = /<!--\s*noema-review-gate\s+head_sha=([0-9a-f]{40})\s+decision=(approve|request_changes|blocked)\s*-->/gi; + const noemaCredentialMarker = "Reviewer credential: `noema-github-app`"; + const reviewThreadQuery = "query($owner:String!,$name:String!,$number:Int!,$endCursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}"; + const activeWorkflowRunStatuses = new Set([ + "requested", + "waiting", + "pending", + "queued", + "in_progress", + ]); + + function bound(value, limit = MAX_REPORT_DETAIL_CHARS) { + const text = String(value ?? "") + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "") + .trim(); + return text.length <= limit ? text : `${text.slice(0, limit)}…`; + } + + export function createGhSubprocessEnvironment(sourceEnvironment = process.env) { + const childEnvironment = { + GH_HOST: "github.com", + NO_COLOR: "1", + }; + if (typeof sourceEnvironment.PATH === "string" && sourceEnvironment.PATH.length > 0) { + childEnvironment.PATH = sourceEnvironment.PATH; + } + if (typeof sourceEnvironment.GH_TOKEN === "string" && sourceEnvironment.GH_TOKEN.length > 0) { + childEnvironment.GH_TOKEN = sourceEnvironment.GH_TOKEN; + } + return childEnvironment; + } + + function runGh(args, { input } = {}) { + const completed = spawnSync("gh", args, { + encoding: "utf8", + env: createGhSubprocessEnvironment(), + input, + maxBuffer: MAX_GH_OUTPUT_BYTES, + shell: false, + }); + if (completed.error) { + throw new Error(`GitHub CLI could not start: ${bound(completed.error.message, MAX_ERROR_CHARS)}`); + } + if (completed.status !== 0) { + const detail = completed.stderr || completed.stdout || `exit ${completed.status}`; + throw new Error(`GitHub CLI failed: ${bound(detail, MAX_ERROR_CHARS)}`); + } + return completed.stdout.trim(); + } + + function runGhJson(args, options) { + const raw = runGh(args, options); + if (!raw) { + return null; + } + try { + return JSON.parse(raw); + } catch (error) { + throw new Error(`GitHub CLI returned invalid JSON: ${bound(error.message, MAX_ERROR_CHARS)}`); + } + } + + export function flattenArrayPages(pages) { + if (!Array.isArray(pages)) { + throw new TypeError("Paginated GitHub response must be an array of pages."); + } + return pages.flatMap((page) => { + if (!Array.isArray(page)) { + throw new TypeError("Each paginated GitHub array page must be an array."); + } + return page; + }); + } + + function checkRunTimestamp(check) { + return Math.max( + Date.parse(check?.completed_at || "") || 0, + Date.parse(check?.started_at || "") || 0, + ); + } + + function checkRunChronologicalOrder(left, right) { + const leftId = Number(left?.id); + const rightId = Number(right?.id); + if ( + Number.isSafeInteger(leftId) + && Number.isSafeInteger(rightId) + && leftId !== rightId + ) { + return leftId - rightId; + } + const timeDelta = checkRunTimestamp(left) - checkRunTimestamp(right); + if (timeDelta !== 0) { + return timeDelta; + } + return leftId - rightId; + } + + function checkRunSuiteKey(check) { + const checkId = Number
expect(script).not.toContain("env: process.env");
expect(script).toContain("redactSensitiveValue(detail, [childEnvironment.GH_TOKEN])");
expect(script).toContain('"--paginate", "--slurp"');
expect(script).toContain("pulls?state=open&per_page=100");
expect(script).toContain("check-runs?filter=all&per_page=100");
Expand All @@ -265,16 +312,16 @@
expect(script).toContain("live?.head?.repo?.full_name !== repository");
});

it("writes a bounded report and post-action queue outputs without exposing tokens", () => {
it("writes a bounded report and post-action queue outputs without ambient credential payloads", () => {
const script = readFileSync("scripts/hourly-commercial-readiness.mjs", "utf8");

expect(script).toContain("open_pull_request_count=");
expect(script).toContain("remaining_open_pull_request_count=");
expect(script).toContain("report_path=");
expect(script).toContain("remainingOpenPullRequestCount");
expect(script).toContain("MAX_ERROR_CHARS");
expect(script).not.toContain("GH_TOKEN");
expect(script).not.toContain("GITHUB_TOKEN");
expect(script).not.toContain("read-only-maintainer-token");
});

it("documents the operator contract and buyer-visible governance boundaries", () => {
Expand Down
Loading