Skip to content

Commit 6b484dc

Browse files
committed
Better handling of submissions that fail virus scan
1 parent 8caa5d1 commit 6b484dc

10 files changed

Lines changed: 967 additions & 55 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ The ECS task still needs trusted outbound access to fetch challenge config, down
158158
- The parent launches a separate child JVM through `mm-runner-isolate` with a scrubbed environment, so submission processes do not inherit the bearer token or other runner env vars.
159159
- Generic submitted solution commands run through `mm-scorer-isolate` as the separate non-root `scorer` user. Downloaded tester JARs and serialized scorer config are kept runner-owned mode `0400`, so submitted code cannot read or modify them from `/tmp`.
160160
- Standard generic-runner seed execution asks `mm-scorer-isolate` to reset scorer-owned writable state before and after each test case. The cleanup helper only scans fixed writable roots such as `/tmp`, `/var/tmp`, `/dev/shm`, and the scorer home, and only removes entries owned by the scorer UID.
161-
- Generic submitted solution commands also run under a filesystem allowlist that permits runtime/toolchain reads and scorer temp writes but does not permit reading infrastructure paths such as `/etc/hostname`, `/etc/resolv.conf`, `/proc/self/cgroup`, `/proc/self/mounts`, or proc network tables.
161+
- Generic submitted solution commands also run under a filesystem allowlist that permits runtime/toolchain reads, `/proc/self/maps` for glibc/Mono stack introspection, and scorer temp writes but does not permit reading infrastructure paths such as `/etc/hostname`, `/etc/resolv.conf`, `/proc/self/cgroup`, `/proc/self/mounts`, or proc network tables.
162162
- Native wrappers block `io_uring` and creation of non-`AF_UNIX` sockets for submitted solution processes and their fork/exec children, so submissions cannot open live outbound network connections.
163163
- Standard Topcoder Marathon testers run through the generic runner flow, which creates the callback score payload from trusted runner code. Custom tester `runTester(...)` result maps remain supported for advanced cases.
164164

ecs-runner/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ This image is the runtime container for marathon match scoring tasks launched by
2222
- Generic submitted solution commands execute through the setuid-root `mm-scorer-isolate` bridge as uid/gid `10002` (`scorer`). The bridge drops its supervisor back to the invoking `runner` uid after it forks the solution child, retaining only `CAP_KILL` so tester timeouts can still terminate the lower-privilege solution process group.
2323
- Downloaded tester JARs are mode `0400` runner-owned files. The serialized scorer config starts as a mode `0400` runner-owned handoff file and the child JVM deletes it immediately after loading it, before tester or submitted solution code executes.
2424
- Generic Marathon seed execution resets scorer-owned writable state before and after every test case, so files written by one seed are not visible to later seeds in the same submission run. The Java child invokes the setuid `mm-scorer-isolate --cleanup-scorer-state` mode, which scans only fixed writable roots such as `/tmp`, `/var/tmp`, `/dev/shm`, and the scorer home, and removes only entries owned by uid `10002`.
25-
- Generic submitted solution commands run under a Landlock filesystem allowlist. Runtime and toolchain files are readable, `/tmp` and the scorer home are writable, and infrastructure-revealing paths such as `/etc/hostname`, `/etc/resolv.conf`, `/proc/self/cgroup`, `/proc/self/mounts`, and proc network tables are not readable by submitted code.
25+
- Generic submitted solution commands run under a Landlock filesystem allowlist. Runtime and toolchain files are readable, `/proc/self/maps` is readable for glibc/Mono stack introspection, `/tmp` and the scorer home are writable, and infrastructure-revealing paths such as `/etc/hostname`, `/etc/resolv.conf`, `/proc/self/cgroup`, `/proc/self/mounts`, and proc network tables are not readable by submitted code.
2626
- Artifact previews and artifact zip uploads include only non-symlink regular files from the runner artifact directories. Submitted symlinks are ignored instead of being dereferenced by the trusted parent runner.
2727
- Submitted solution processes and their fork/exec children cannot use `io_uring` and can create only `AF_UNIX` sockets. These restrictions are kernel seccomp filters inherited across fork/exec, so clearing `LD_PRELOAD` in a spawned child process does not restore INET or INET6 socket access. Outbound network access from the submission itself is therefore blocked even though the parent runner still has the trusted egress it needs.
2828
- The child JVM runs standard Topcoder Marathon testers through the generic runner flow. Custom tester `runTester(...)` result maps remain supported for advanced cases, but standard testers do not need ECS-specific code.

ecs-runner/scripts/mm-net-isolate.c

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -647,9 +647,11 @@ static int add_landlock_paths(
647647

648648
/**
649649
* Restricts submitted solution filesystem access to runtime/toolchain files and
650-
* scorer-owned writable locations. Infrastructure-revealing files such as
651-
* /etc/hostname, /etc/resolv.conf, /proc/self/cgroup, /proc/self/mounts, and
652-
* proc network tables are intentionally omitted from the allowlist.
650+
* scorer-owned writable locations. /proc/self/maps is narrowly readable
651+
* because glibc pthread_getattr_np opens it while Mono discovers the main
652+
* thread stack bounds. Infrastructure-revealing files such as /etc/hostname,
653+
* /etc/resolv.conf, /proc/self/cgroup, /proc/self/mounts, and proc network
654+
* tables are intentionally omitted from the allowlist.
653655
*/
654656
static int install_filesystem_filter(void) {
655657
static const char *const read_execute_paths[] = {
@@ -670,7 +672,8 @@ static int install_filesystem_filter(void) {
670672
"/etc/passwd",
671673
"/etc/protocols",
672674
"/etc/services",
673-
"/etc/ssl/certs"
675+
"/etc/ssl/certs",
676+
"/proc/self/maps"
674677
};
675678
static const char *const read_write_paths[] = {
676679
"/dev/null",

src/api/kafka/marathon-match-submission.handler.spec.ts

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,14 @@ describe('MarathonMatchSubmissionHandler', () => {
6464
});
6565

6666
it('launches example and provisional scorer tasks when both map to the same open phase', async () => {
67-
const { handler, prisma, ecsService } = createHandler();
67+
const { handler, prisma, m2mService, httpService, ecsService } =
68+
createHandler();
6869

6970
prisma.marathonMatchConfig.findUnique.mockResolvedValue({
7071
id: 'config-1',
7172
challengeId: 'challenge-1',
7273
active: true,
74+
submissionApiUrl: 'https://submissions.example.com/v6',
7375
testerId: 'tester-1',
7476
taskDefinitionName: 'mm-ecs-runner',
7577
taskDefinitionVersion: '7',
@@ -106,6 +108,15 @@ describe('MarathonMatchSubmissionHandler', () => {
106108
phaseIdentifiers: ['submission-phase'],
107109
});
108110

111+
m2mService.getM2MToken.mockResolvedValue('m2m-token');
112+
httpService.get.mockReturnValue(
113+
of({
114+
data: {
115+
id: 'submission-1',
116+
virusScan: true,
117+
},
118+
}),
119+
);
109120
ecsService.launchScorerTask
110121
.mockResolvedValueOnce({
111122
taskArn: 'arn:aws:ecs:task/example',
@@ -254,4 +265,122 @@ describe('MarathonMatchSubmissionHandler', () => {
254265
}),
255266
);
256267
});
268+
269+
it('marks each matching phase failed without launching when submission has not passed virus scan', async () => {
270+
const { handler, prisma, m2mService, httpService, ecsService } =
271+
createHandler();
272+
273+
prisma.marathonMatchConfig.findUnique.mockResolvedValue({
274+
id: 'config-1',
275+
challengeId: 'challenge-1',
276+
active: true,
277+
submissionApiUrl: 'https://submissions.example.com/v6',
278+
testerId: 'tester-1',
279+
reviewScorecardId: 'scorecard-1',
280+
taskDefinitionName: 'mm-ecs-runner',
281+
taskDefinitionVersion: '7',
282+
tester: {
283+
compilationStatus: CompilationStatus.SUCCESS,
284+
},
285+
phaseConfigs: [
286+
{
287+
id: 'phase-example',
288+
configType: 'EXAMPLE',
289+
phaseId: 'submission-phase',
290+
startSeed: BigInt(1),
291+
numberOfTests: 10,
292+
},
293+
{
294+
id: 'phase-provisional',
295+
configType: 'PROVISIONAL',
296+
phaseId: 'submission-phase',
297+
startSeed: BigInt(500),
298+
numberOfTests: 20,
299+
},
300+
],
301+
});
302+
303+
(handler as any).getOpenPhaseResolution = jest.fn().mockResolvedValue({
304+
phaseIds: ['submission-phase'],
305+
phaseIdentifiers: ['submission-phase'],
306+
});
307+
m2mService.getM2MToken.mockResolvedValue('m2m-token');
308+
httpService.get
309+
.mockReturnValueOnce(
310+
of({
311+
data: {
312+
id: 'submission-1',
313+
virusScan: false,
314+
},
315+
}),
316+
)
317+
.mockReturnValue(
318+
of({
319+
data: {
320+
data: [],
321+
},
322+
}),
323+
);
324+
httpService.post.mockReturnValue(of({ data: { id: 'summation-1' } }));
325+
326+
await handler.handle({
327+
submissionId: 'submission-1',
328+
challengeId: 'challenge-1',
329+
submissionUrl: 'https://example.com/submission.zip',
330+
memberHandle: 'tester',
331+
memberId: 'member-1',
332+
submittedDate: '2026-03-26T01:27:22.829Z',
333+
});
334+
335+
expect(ecsService.launchScorerTask).not.toHaveBeenCalled();
336+
expect(m2mService.getM2MToken).toHaveBeenCalledTimes(1);
337+
expect(httpService.post).toHaveBeenCalledTimes(2);
338+
expect(httpService.post).toHaveBeenNthCalledWith(
339+
1,
340+
'https://api.topcoder-dev.com/v6/reviewSummations',
341+
expect.objectContaining({
342+
aggregateScore: -1,
343+
isExample: true,
344+
isPassing: false,
345+
scorecardId: 'scorecard-1',
346+
submissionId: 'submission-1',
347+
metadata: expect.objectContaining({
348+
challengeId: 'challenge-1',
349+
marathonMatchScoringSkipped: true,
350+
testProgress: 1,
351+
testStatus: 'FAILED',
352+
testType: 'example',
353+
}),
354+
}),
355+
expect.objectContaining({
356+
headers: {
357+
Authorization: 'Bearer m2m-token',
358+
},
359+
}),
360+
);
361+
expect(httpService.post).toHaveBeenNthCalledWith(
362+
2,
363+
'https://api.topcoder-dev.com/v6/reviewSummations',
364+
expect.objectContaining({
365+
aggregateScore: -1,
366+
isPassing: false,
367+
isProvisional: true,
368+
scorecardId: 'scorecard-1',
369+
submissionId: 'submission-1',
370+
metadata: expect.objectContaining({
371+
challengeId: 'challenge-1',
372+
marathonMatchScoringSkipped: true,
373+
testProcess: 'provisional',
374+
testProgress: 1,
375+
testStatus: 'FAILED',
376+
testType: 'provisional',
377+
}),
378+
}),
379+
expect.objectContaining({
380+
headers: {
381+
Authorization: 'Bearer m2m-token',
382+
},
383+
}),
384+
);
385+
});
257386
});

0 commit comments

Comments
 (0)