Skip to content

Commit 37ef8c3

Browse files
authored
Merge pull request #76 from koji-1009/security-hardening
Security hardening
2 parents e09c776 + 3d77423 commit 37ef8c3

12 files changed

Lines changed: 9423 additions & 957 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ jobs:
1313
runs-on: ubuntu-latest
1414
steps:
1515
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
16+
with:
17+
persist-credentials: false
1618
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
1719
with:
1820
node-version: 24.x

‎.github/workflows/e2e.yml‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ jobs:
2121
runs-on: ${{ matrix.os }}
2222
steps:
2323
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
24+
with:
25+
persist-credentials: false
2426

2527
- name: Setup Flutter (stable latest)
2628
uses: ./
@@ -40,6 +42,8 @@ jobs:
4042
runs-on: ubuntu-latest
4143
steps:
4244
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
45+
with:
46+
persist-credentials: false
4347

4448
- name: Setup Flutter (exact version)
4549
uses: ./
@@ -55,6 +59,8 @@ jobs:
5559
runs-on: ubuntu-latest
5660
steps:
5761
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
62+
with:
63+
persist-credentials: false
5864

5965
- name: Setup Flutter (dry-run)
6066
id: flutter
@@ -63,7 +69,10 @@ jobs:
6369
dry-run: true
6470

6571
- name: Verify outputs
72+
env:
73+
FLUTTER_VERSION: ${{ steps.flutter.outputs.flutter-version }}
74+
CHANNEL: ${{ steps.flutter.outputs.channel }}
6675
run: |
67-
echo "Flutter version: ${{ steps.flutter.outputs.flutter-version }}"
68-
echo "Channel: ${{ steps.flutter.outputs.channel }}"
69-
test -n "${{ steps.flutter.outputs.flutter-version }}"
76+
echo "Flutter version: $FLUTTER_VERSION"
77+
echo "Channel: $CHANNEL"
78+
test -n "$FLUTTER_VERSION"

‎README.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,16 @@ The action sets the following outputs:
168168
| `cache-pub-hit` | Whether pub cache was restored |
169169
| `architecture` | Resolved CPU architecture |
170170

171+
## Security
172+
173+
The intro bullets above are backed by a consistent design: every artifact this action installs is verified, and no input ever reaches a shell.
174+
175+
* **Archive verification** — Release archives are downloaded over HTTPS and verified against the SHA-256 checksum in the official release manifest before extraction. A mismatch fails the run.
176+
* **Git mode verification** — Versions and refs are resolved to a commit hash first (via the release manifest or `git ls-remote`), and after cloning the checkout is verified to match that commit. SDK caches are keyed by the commit hash and the repository URL.
177+
* **No shell interpolation** — Inputs and version-file contents are passed to subprocesses as argument arrays. Nothing is ever interpolated into a shell command.
178+
* **Supply chain** — All runtime dependencies are bundled into `dist/`, and CI verifies the bundle is reproducible from source on every change. The repository's own workflows are SHA-pinned, run with minimal permissions, and use `npm ci --ignore-scripts` wherever a write token is present.
179+
* **Cache integrity** — Caches are restored by exact key match only. There is no partial-match fallback that could restore unexpected content.
180+
171181
## Blog Post
172182

173183
[Why I Built a New GitHub Action for Flutter](https://koji-1009.medium.com/why-i-built-a-new-github-action-for-flutter-592c24e96a55) — design decisions and motivation behind this action.

‎__tests__/git-source.test.ts‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,9 +538,22 @@ describe("resolveGit (dispatch)", () => {
538538
});
539539

540540
describe("installFromGit", () => {
541+
const FULL_HASH = "abc123def4567890abc123def4567890abc123de";
542+
543+
// Echo FULL_HASH for `git rev-parse HEAD` so the post-clone verification
544+
// passes by default; tests override this to simulate a mismatch.
545+
function mockExecWithHead(head: string) {
546+
vi.mocked(exec).mockImplementation(async (_cmd, args, options) => {
547+
if (args?.includes("rev-parse")) {
548+
options?.listeners?.stdout?.(Buffer.from(`${head}\n`));
549+
}
550+
return 0;
551+
});
552+
}
553+
541554
beforeEach(() => {
542555
vi.clearAllMocks();
543-
vi.mocked(exec).mockResolvedValue(0);
556+
mockExecWithHead(FULL_HASH);
544557
});
545558

546559
const gitEnvMatcher = expect.objectContaining({
@@ -614,6 +627,46 @@ describe("installFromGit", () => {
614627
);
615628
});
616629

630+
it("verifies cloned HEAD against the resolved commit for branch ref", async () => {
631+
await installFromGit(
632+
"https://github.com/flutter/flutter.git",
633+
"stable",
634+
"/opt/flutter",
635+
FULL_HASH,
636+
);
637+
expect(exec).toHaveBeenCalledWith(
638+
"git",
639+
["-C", "/opt/flutter", "rev-parse", "HEAD"],
640+
expect.anything(),
641+
);
642+
});
643+
644+
it("throws when cloned HEAD does not match the resolved commit", async () => {
645+
mockExecWithHead("1111111111111111111111111111111111111111");
646+
647+
await expect(
648+
installFromGit(
649+
"https://github.com/flutter/flutter.git",
650+
"stable",
651+
"/opt/flutter",
652+
FULL_HASH,
653+
),
654+
).rejects.toThrow("does not match resolved commit");
655+
});
656+
657+
it("skips HEAD verification for short hash commitHash", async () => {
658+
await installFromGit(
659+
"https://github.com/flutter/flutter.git",
660+
"stable",
661+
"/opt/flutter",
662+
"abc1234",
663+
);
664+
const revParseCall = vi
665+
.mocked(exec)
666+
.mock.calls.find((c) => (c[1] as string[])?.includes("rev-parse"));
667+
expect(revParseCall).toBeUndefined();
668+
});
669+
617670
it("throws when command times out", async () => {
618671
vi.useFakeTimers();
619672
vi.mocked(exec).mockReturnValue(new Promise(() => {}));

‎__tests__/main.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,19 @@ describe("main run()", () => {
241241
);
242242
});
243243

244+
it("calls setFailed for an invalid git-source value", async () => {
245+
const { inputs } = setupDefaultMocks();
246+
inputs["git-source"] = "releases";
247+
248+
await run();
249+
250+
expect(setFailed).toHaveBeenCalledWith(
251+
expect.stringContaining("Invalid git-source"),
252+
);
253+
expect(fetchManifest).not.toHaveBeenCalled();
254+
expect(installFromArchive).not.toHaveBeenCalled();
255+
});
256+
244257
it("uses git mode with ref spec", async () => {
245258
const { inputs } = setupDefaultMocks();
246259
inputs["git-source"] = "git";

‎__tests__/version.test.ts‎

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ import {
1919
} from "../src/version";
2020

2121
vi.mock("@actions/http-client");
22+
vi.mock("@actions/core");
23+
vi.mock("node:timers/promises", () => ({
24+
setTimeout: vi.fn().mockResolvedValue(undefined),
25+
}));
2226

2327
const linuxFixture: FlutterManifest = JSON.parse(
2428
readFileSync(join(__dirname, "fixtures", "releases_linux.json"), "utf8"),
@@ -133,6 +137,18 @@ describe("parseVersionSpec", () => {
133137
});
134138
});
135139

140+
it("throws for ambiguous major.minor form 3.4", () => {
141+
expect(() => parseVersionSpec("3.4")).toThrow(
142+
"Ambiguous version '3.4': use '3.4.x'",
143+
);
144+
});
145+
146+
it("throws for ambiguous bare major form 3", () => {
147+
expect(() => parseVersionSpec("3")).toThrow(
148+
"Ambiguous version '3': use '3.x'",
149+
);
150+
});
151+
136152
it("returns ref for branch name", () => {
137153
expect(parseVersionSpec("my-feature-branch")).toEqual({
138154
type: "ref",
@@ -601,14 +617,63 @@ describe("fetchManifest", () => {
601617
});
602618

603619
it("throws when result is null", async () => {
620+
const getJson = vi.fn().mockResolvedValue({ result: null });
604621
HttpClient.mockImplementation(
605622
class {
606-
getJson = vi.fn().mockResolvedValue({ result: null });
623+
getJson = getJson;
607624
},
608625
);
609626

610627
await expect(fetchManifest("linux")).rejects.toThrow(
611628
"Failed to fetch manifest",
612629
);
630+
// A 404 (null result) must not be retried.
631+
expect(getJson).toHaveBeenCalledTimes(1);
632+
});
633+
634+
it("retries on HTTP 500 and succeeds on next attempt", async () => {
635+
const getJson = vi
636+
.fn()
637+
.mockRejectedValueOnce(
638+
Object.assign(new Error("Failed request: (500)"), { statusCode: 500 }),
639+
)
640+
.mockResolvedValue({ result: JSON.parse(JSON.stringify(linuxFixture)) });
641+
HttpClient.mockImplementation(
642+
class {
643+
getJson = getJson;
644+
},
645+
);
646+
647+
const result = await fetchManifest("linux");
648+
expect(result.releases.length).toBeGreaterThan(0);
649+
expect(getJson).toHaveBeenCalledTimes(2);
650+
});
651+
652+
it("retries on network error and throws after exhausting attempts", async () => {
653+
const getJson = vi.fn().mockRejectedValue(new Error("ECONNRESET"));
654+
HttpClient.mockImplementation(
655+
class {
656+
getJson = getJson;
657+
},
658+
);
659+
660+
await expect(fetchManifest("linux")).rejects.toThrow("ECONNRESET");
661+
expect(getJson).toHaveBeenCalledTimes(3);
662+
});
663+
664+
it("does not retry on HTTP 403", async () => {
665+
const getJson = vi
666+
.fn()
667+
.mockRejectedValue(
668+
Object.assign(new Error("Failed request: (403)"), { statusCode: 403 }),
669+
);
670+
HttpClient.mockImplementation(
671+
class {
672+
getJson = getJson;
673+
},
674+
);
675+
676+
await expect(fetchManifest("linux")).rejects.toThrow("(403)");
677+
expect(getJson).toHaveBeenCalledTimes(1);
613678
});
614679
});

0 commit comments

Comments
 (0)