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
33 changes: 33 additions & 0 deletions .github/workflows/published-release-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,36 @@ jobs:
test "$RESOLVED_VERSION" = "$expected_version"
[[ "$INSTALLED_SHA256" =~ ^[0-9a-f]{64}$ ]]
test "$(rootform version)" = "rootform $expected_version"

- name: Check out the published fixture revision
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: rootform-dev/rootform
ref: v${{ steps.rootform.outputs.version }}
path: fixtures
persist-credentials: false

- name: Install exact Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.4.0

- name: Install locked verification dependencies
run: bun install --frozen-lockfile

- name: Exercise published CLI business paths
env:
ROOTFORM_TEST_FIXTURES: ${{ github.workspace }}/fixtures
ROOTFORM_TEST_VERSION: ${{ steps.rootform.outputs.version }}
ROOTFORM_TEST_TOKEN: ""
ROOTFORM_TEST_EVIDENCE: ${{ runner.temp }}/published-cli-evidence.json
run: bun run test:integration

- name: Preserve business integration evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: published-cli-${{ steps.rootform.outputs.version }}-${{ github.sha }}
path: ${{ runner.temp }}/published-cli-evidence.json
if-no-files-found: warn
retention-days: 90
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ Make `plan.json` and its matching saved plan `plan.tfplan` available first:
- uses: rootform-dev/action@v1
id: rootform
with:
version: 0.1.0
version: <rootform-version>
input: plan.json
plan-file: plan.tfplan
```

Replace `<rootform-version>` with an exact published CLI version. The Action
major tag `v1` is independent of the CLI version.

The root Action infers analysis or comparison. Set `check: true` to evaluate
the project's locked Policy selections, or supply an explicit Policy selector
or Policy Pack. Nothing is selected implicitly.
Expand Down
4 changes: 3 additions & 1 deletion analyze/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ steps:
- uses: rootform-dev/action/analyze@v1
id: analysis
with:
version: 0.1.0
version: <rootform-version>
input: ${{ runner.temp }}/plan.json
plan-file: ${{ runner.temp }}/plan.tfplan
```
Expand All @@ -25,3 +25,5 @@ contains only the Form and derived reports. Summary and upload are enabled by
default. Set `upload-artifact: false` when the evidence should stay in the job.

See the complete [analyze input and output reference](https://docs.rootform.dev/integrations/github-actions/analyze/) or return to the [Action quickstart](../README.md).

Replace `<rootform-version>` with an exact published CLI version.
4 changes: 3 additions & 1 deletion check/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ steps:
- uses: rootform-dev/action/check@v1
id: policy-check
with:
version: 0.1.0
version: <rootform-version>
input: ${{ runner.temp }}/plan.json
plan-file: ${{ runner.temp }}/plan.tfplan
policy-pack: ./policies/team
Expand All @@ -29,3 +29,5 @@ and Summary are enabled by default. Successful upload also exposes
`artifact-id` and `artifact-url`.

See the complete [check input and output reference](https://docs.rootform.dev/integrations/github-actions/check/) or return to the [Action quickstart](../README.md).

Replace `<rootform-version>` with an exact published CLI version.
4 changes: 3 additions & 1 deletion compare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ steps:
- uses: rootform-dev/action/compare@v1
id: comparison
with:
version: 0.1.0
version: <rootform-version>
before: ${{ runner.temp }}/before/plan.json
before-plan-file: ${{ runner.temp }}/before/plan.tfplan
after: ${{ runner.temp }}/after/plan.json
Expand All @@ -26,3 +26,5 @@ used as a compare operand. Successful artifact upload exposes `artifact-id`
and `artifact-url`; upload and Summary are enabled by default.

See the complete [compare input and output reference](https://docs.rootform.dev/integrations/github-actions/compare/) or return to the [Action quickstart](../README.md).

Replace `<rootform-version>` with an exact published CLI version.
228 changes: 114 additions & 114 deletions dist/shared/index.js

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion init/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ standalone init is optional.
steps:
- uses: rootform-dev/action/init@v1
with:
version: 0.1.0
version: <rootform-version>
project: infra
locked: true
```
Expand All @@ -26,3 +26,5 @@ only output is `version`, the exact verified CLI version. The job-local
`ROOTFORM_HOME` is shared with later business actions.

See the complete [init input and output reference](https://docs.rootform.dev/integrations/github-actions/init/) or return to the [Action quickstart](../README.md).

Replace `<rootform-version>` with an exact published CLI version.
4 changes: 3 additions & 1 deletion scripts/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { type BusinessKind, type RunOptions, runBusiness } from "../src/run.ts";
const fixtures = process.env.ROOTFORM_TEST_FIXTURES;
if (!fixtures)
throw new Error("ROOTFORM_TEST_FIXTURES must name the public Rootform fixture checkout");
const version = process.env.ROOTFORM_TEST_VERSION;
if (!version) throw new Error("ROOTFORM_TEST_VERSION must name an exact published CLI version");
const workspace = resolve(fixtures);
const temporary = mkdtempSync(join(tmpdir(), "rootform-action-integration-"));
process.env.RUNNER_TOOL_CACHE ||= join(temporary, "tools");
Expand All @@ -18,7 +20,7 @@ mkdirSync(process.env.ROOTFORM_HOME);
process.env.GITHUB_PATH ||= join(temporary, "github-path");
writeFileSync(process.env.GITHUB_PATH, "");
const installation = await installRootform({
version: process.env.ROOTFORM_TEST_VERSION || "0.1.0-pr.117.1",
version,
token: process.env.ROOTFORM_TEST_TOKEN || "",
});
const baseProject = join(workspace, "examples/playground/shared-data-platform/base");
Expand Down
4 changes: 3 additions & 1 deletion setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ steps:
- uses: rootform-dev/action/setup@v1
id: rootform
with:
version: 0.1.0
version: <rootform-version>
- name: Confirm the installed CLI
run: rootform version
```
Expand All @@ -25,3 +25,5 @@ installation needs no credential; the optional `github-token` is for GitHub
API rate limits and is not sent to Rootform.

See the complete [setup input and output reference](https://docs.rootform.dev/integrations/github-actions/setup/) or return to the [Action quickstart](../README.md).

Replace `<rootform-version>` with an exact published CLI version.
47 changes: 47 additions & 0 deletions src/publication-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,50 @@ test("consumer reports and maintainer messages both refuse synthetic credentials
expect(() => assertPublicMessage(value)).toThrow("credential");
}
});

test("encoded credentials are refused before consumer reporting without changing path policy", () => {
const secret = "Bearer " + "SYNTHETIC".repeat(8);
for (const value of [
encodeURIComponent(encodeURIComponent(secret)),
{ encoding: "base64", content: btoa(secret) },
]) {
expect(() => assertReportMessage(value, "example/project")).toThrow("credential");
expect(() => assertPublicMessage(value)).toThrow("credential");
}
const path = "/" + "Users/fictional/output";
expect(() => assertReportMessage(encodeURIComponent(path), "example/project")).not.toThrow();
expect(() => assertReportMessage(encodeURIComponent(path), "rootform-dev/action")).toThrow(
"personal-path",
);
});

test("named credentials are checked recursively while exact synthetic literals remain valid", () => {
const credential = "SYNTHETIC".repeat(8);
for (const payload of [
{ API_TOKEN: credential },
{ encoding: "base64", content: btoa(JSON.stringify({ api_key: credential })) },
{
encoding: "base64",
content: btoa(
JSON.stringify({ encoding: "base64", content: btoa("github_" + "pat_" + credential) }),
),
},
])
expect(() => assertPublicMessage(payload)).toThrow("credential");
for (const value of [
"ROOTFORM_DATADOG_CLOUDFLARE_KEY_SENTINEL",
"ROOTFORM_DATADOG_FASTLY_KEY_SENTINEL",
"ROOTFORM_HCP_DATADOG_API_SENTINEL",
"ROOTFORM_ATLAS_OBSERVABILITY_SECRET",
]) {
expect(() => assertPublicMessage({ api_key: value })).not.toThrow();
expect(() => assertPublicMessage({ api_key: value + "_CHANGED" })).toThrow("credential");
}
const path = "/" + "Users/fictional/session";
expect(() =>
assertPublicMessage({
encoding: "base64",
content: btoa(JSON.stringify({ value: path }).replaceAll("/", "\\u002f")),
}),
).toThrow("personal-path");
});
127 changes: 107 additions & 20 deletions src/publication-safety.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
export type PublicationIssue = { rule: string; line: number };

// Exact non-credential literals used by public synthetic provider fixtures.
const syntheticCredentialValues = new Set([
"ROOTFORM_DATADOG_CLOUDFLARE_KEY_SENTINEL",
"ROOTFORM_DATADOG_FASTLY_KEY_SENTINEL",
"ROOTFORM_HCP_DATADOG_API_SENTINEL",
"ROOTFORM_ATLAS_OBSERVABILITY_SECRET",
]);

const rules: Array<[string, RegExp]> = [
[
"personal-path",
Expand All @@ -22,24 +30,99 @@ const rules: Array<[string, RegExp]> = [

/** Return only rule and line; never retain or display the matched value. */
export function publicationIssues(text: string): PublicationIssue[] {
return rules.flatMap(([rule, pattern]) =>
[...text.matchAll(pattern)].map((match) => ({
rule,
line: text.slice(0, match.index).split("\n").length,
})),
);
const found: PublicationIssue[] = [];
const scan = (source: string) => {
for (let depth = 0; depth < 3; depth++) {
const decoded = source.replace(/(?:%[0-9a-f]{2})+/giu, (encoded) => {
try {
return decodeURIComponent(encoded);
} catch {
return encoded;
}
});
if (decoded === source) break;
source = decoded;
}
for (const [rule, pattern] of rules)
for (const match of source.matchAll(pattern))
found.push({ rule, line: source.slice(0, match.index).split("\n").length });
};
const inspect = (value: unknown, depth: number) => {
if (depth > 128) {
found.push({ rule: "uninspectable-content", line: 1 });
return;
}
if (typeof value === "string") scan(value);
else if (Array.isArray(value)) for (const item of value) inspect(item, depth + 1);
else if (value !== null && typeof value === "object") {
const payload = value as Record<string, unknown>;
if (
typeof payload.content === "string" &&
(payload.encoding === "base64" || ("message" in payload && payload.encoding === undefined))
) {
try {
if (
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(
payload.content,
)
)
throw new Error("Invalid encoding");
const decoded = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(
Uint8Array.from(atob(payload.content), (character) => character.charCodeAt(0)),
);
scan(decoded);
let structured: unknown;
try {
structured = JSON.parse(decoded);
} catch {
structured = undefined;
}
if (structured !== undefined) inspect(structured, depth + 1);
} catch {
found.push({ rule: "uninspectable-content", line: 1 });
}
}
for (const [key, item] of Object.entries(payload)) {
if (
/^(?:CLOUDFLARE_API_TOKEN|AWS_SECRET_ACCESS_KEY|OPENAI_API_KEY|API_TOKEN|API_KEY)$/iu.test(
key,
) &&
typeof item === "string" &&
!syntheticCredentialValues.has(item) &&
/^[A-Za-z0-9_+./~=-]{24,}$/u.test(item)
)
found.push({ rule: "credential", line: 1 });
scan(key);
inspect(item, depth + 1);
}
}
};
scan(text);
// Parsing valid JSON reveals escapes without interpreting regex or source-code literals.
let structured: unknown;
try {
structured = JSON.parse(text);
} catch {
return found;
}
inspect(structured, 0);
return found;
}

/** Validate all outbound strings before a bot makes its first write. */
export function assertPublicMessage(value: unknown): void {
if (typeof value === "string") {
const issue = publicationIssues(value)[0];
if (issue) throw new Error(`Public message refused: ${issue.rule}`);
} else if (Array.isArray(value)) {
for (const item of value) assertPublicMessage(item);
} else if (value !== null && typeof value === "object") {
for (const item of Object.values(value)) assertPublicMessage(item);
if (value === undefined) return;
let text: string;
try {
const serialized = typeof value === "string" ? value : JSON.stringify(value);
if (typeof serialized !== "string") throw new Error("Invalid payload");
text = serialized;
} catch {
throw new Error("Public message refused: uninspectable-content");
}
const issues = publicationIssues(text);
const issue = issues[0];
if (issue) throw new Error(`Public message refused: ${issue.rule}`);
}

/** Consumer reports may contain user-authored model data and local paths.
Expand All @@ -54,12 +137,16 @@ export function assertReportMessage(
assertPublicMessage(value);
return;
}
if (typeof value === "string") {
if (publicationIssues(value).some((issue) => issue.rule === "credential"))
throw new Error("Public report refused: credential");
} else if (Array.isArray(value)) {
for (const item of value) assertReportMessage(item, repository);
} else if (value !== null && typeof value === "object") {
for (const item of Object.values(value)) assertReportMessage(item, repository);
let text: string;
try {
const serialized = typeof value === "string" ? value : JSON.stringify(value);
if (typeof serialized !== "string") throw new Error("Invalid payload");
text = serialized;
} catch {
throw new Error("Public report refused: uninspectable-content");
}
const issue = publicationIssues(text).find(
(issue) => issue.rule === "credential" || issue.rule === "uninspectable-content",
);
if (issue) throw new Error(`Public report refused: ${issue.rule}`);
}
Loading