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
35 changes: 35 additions & 0 deletions .github/workflows/maced-contract-canary.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Maced contract canary

on:
pull_request:
paths:
- 'apps/api/src/security-penetration-tests/**'
- 'apps/api/test/maced-contract.e2e-spec.ts'
- 'apps/api/package.json'
- '.github/workflows/maced-contract-canary.yml'
schedule:
- cron: '0 * * * *'
workflow_dispatch:

permissions:
contents: read

jobs:
maced-contract-canary:
runs-on: warp-ubuntu-latest-arm64-4x
timeout-minutes: 15
env:
MACED_API_KEY: ${{ secrets.MACED_API_KEY }}
MACED_CONTRACT_E2E_RUN_ID: ${{ secrets.MACED_CONTRACT_E2E_RUN_ID }}
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- name: Install Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Run Maced provider contract canary
working-directory: ./apps/api
run: bun run test:e2e:maced
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:e2e:maced": "MACED_CONTRACT_E2E=1 jest --config ./test/jest-e2e.json --runInBand ./maced-contract.e2e-spec.ts",
"test:watch": "jest --watch",
"typecheck": "tsc --noEmit"
}
Expand Down
143 changes: 143 additions & 0 deletions apps/api/src/email/templates/automation-bulk-failures.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import * as React from 'react';
import {
Body,
Button,
Container,
Font,
Heading,
Html,
Link,
Preview,
Section,
Tailwind,
Text,
} from '@react-email/components';
import { Footer } from '../components/footer';
import { Logo } from '../components/logo';
import { getUnsubscribeUrl } from '@trycompai/email';

interface FailedTaskItem {
title: string;
url: string;
failedCount: number;
totalCount: number;
}

interface Props {
toName: string;
toEmail: string;
organizationName: string;
tasksUrl: string;
tasks: FailedTaskItem[];
}

const MAX_DISPLAYED_TASKS = 15;

export const AutomationBulkFailuresEmail = ({
toName,
toEmail,
organizationName,
tasksUrl,
tasks,
}: Props) => {
const unsubscribeUrl = getUnsubscribeUrl(toEmail);
const taskCount = tasks.length;
const taskText = taskCount === 1 ? 'task' : 'tasks';
const displayedTasks = tasks.slice(0, MAX_DISPLAYED_TASKS);
const remainingCount = taskCount - displayedTasks.length;

return (
<Html>
<Tailwind>
<head>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={400}
fontStyle="normal"
/>
<Font
fontFamily="Geist"
fallbackFontFamily="Helvetica"
fontWeight={500}
fontStyle="normal"
/>
</head>
<Preview>
{`${taskCount} ${taskText} with automation failures`}
</Preview>

<Body className="mx-auto my-auto bg-[#fff] font-sans">
<Container
className="mx-auto my-[40px] max-w-[600px] border-transparent p-[20px] md:border-[#E8E7E1]"
style={{ borderStyle: 'solid', borderWidth: 1 }}
>
<Logo />
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-[#121212]">
Automation Failures Summary
</Heading>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Hello {toName},
</Text>

<Text className="text-[14px] leading-[24px] text-[#121212]">
Today's scheduled automations found failures in{' '}
<strong>{taskCount}</strong> {taskText} in{' '}
<strong>{organizationName}</strong>.
</Text>

<Section className="mt-[16px] mb-[16px]">
{displayedTasks.map((task, index) => (
<Text key={index} className="my-[4px] text-[14px] leading-[24px] text-[#121212]">
{'• '}
<Link href={task.url} className="text-[#121212] underline">
{task.title}
</Link>
{' '}({task.failedCount}/{task.totalCount} failed)
</Text>
))}
{remainingCount > 0 && (
<Text className="my-[4px] text-[14px] leading-[24px] text-[#666666]">
and {remainingCount} more...
</Text>
)}
</Section>

<Section className="mt-[32px] mb-[32px] text-center">
<Button
className="rounded-[3px] bg-[#121212] px-[20px] py-[12px] text-center text-[14px] font-semibold text-white no-underline"
href={tasksUrl}
>
View Tasks
</Button>
</Section>

<Text className="text-[14px] leading-[24px] text-[#121212]">
or copy and paste this URL into your browser:{' '}
<a href={tasksUrl} className="text-[#121212] underline">
{tasksUrl}
</a>
</Text>

<Section className="mt-[30px] mb-[20px]">
<Text className="text-[12px] leading-[20px] text-[#666666]">
Don't want to receive task assignment notifications?{' '}
<Link href={unsubscribeUrl} className="text-[#121212] underline">
Manage your email preferences
</Link>
.
</Text>
</Section>

<br />

<Footer />
</Container>
</Body>
</Tailwind>
</Html>
);
};

export default AutomationBulkFailuresEmail;
21 changes: 21 additions & 0 deletions apps/api/src/evidence-forms/evidence-forms.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AuthContext as AuthContextType } from '@/auth/types';
import {
Body,
Controller,
Delete,
Get,
Header,
Param,
Expand Down Expand Up @@ -127,6 +128,26 @@ export class EvidenceFormsController {
});
}

@Delete(':formType/submissions/:submissionId')
@ApiOperation({
summary: 'Delete a submission',
description:
'Remove an evidence form submission for the active organization. Requires owner, admin, or auditor role.',
})
async deleteSubmission(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
@Param('formType') formType: string,
@Param('submissionId') submissionId: string,
) {
return this.evidenceFormsService.deleteSubmission({
organizationId,
authContext,
formType,
submissionId,
});
}

@Post(':formType/submissions')
@ApiOperation({
summary: 'Submit evidence form entry',
Expand Down
47 changes: 47 additions & 0 deletions apps/api/src/evidence-forms/evidence-forms.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const reviewSchema = z.object({
});

const EVIDENCE_FORM_REVIEWER_ROLES = ['owner', 'admin', 'auditor'] as const;
const EVIDENCE_FORM_DELETE_ROLES = ['owner', 'admin'] as const;
const MAX_UPLOAD_FILE_SIZE_BYTES = 100 * 1024 * 1024;
const MAX_UPLOAD_BASE64_LENGTH = Math.ceil(MAX_UPLOAD_FILE_SIZE_BYTES / 3) * 4;

Expand Down Expand Up @@ -159,6 +160,20 @@ export class EvidenceFormsService {
return userId;
}

private requireEvidenceDeleteAccess(authContext: AuthContext): string {
const userId = this.requireJwtUser(authContext);
const roles = authContext.userRoles ?? [];
const canDelete = EVIDENCE_FORM_DELETE_ROLES.some((role) => roles.includes(role));

if (!canDelete) {
throw new UnauthorizedException(
`Delete denied. Required one of roles: ${EVIDENCE_FORM_DELETE_ROLES.join(', ')}`,
);
}

return userId;
}

private decodeBase64File(fileData: string): Buffer {
const normalized = fileData.trim();
if (normalized.length === 0 || normalized.length % 4 !== 0) {
Expand Down Expand Up @@ -315,6 +330,38 @@ export class EvidenceFormsService {
};
}

async deleteSubmission(params: {
organizationId: string;
authContext: AuthContext;
formType: string;
submissionId: string;
}) {
this.requireEvidenceDeleteAccess(params.authContext);

const parsedType = evidenceFormTypeSchema.safeParse(params.formType);
if (!parsedType.success) {
throw new BadRequestException('Unsupported form type');
}

const submission = await db.evidenceSubmission.findFirst({
where: {
id: params.submissionId,
organizationId: params.organizationId,
formType: toDbEvidenceFormType(parsedType.data),
},
});

if (!submission) {
throw new NotFoundException('Submission not found');
}

await db.evidenceSubmission.delete({
where: { id: params.submissionId },
});

return { success: true, id: params.submissionId };
}

async submitForm(params: {
organizationId: string;
formType: string;
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/security-penetration-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,14 @@ This module exposes Comp API endpoints under `/v1/security-penetration-tests` an

- Frontend should call Nest API only (no Next.js proxy routes for this feature).
- Provider callbacks to non-Comp webhook URLs are passed through and are not forced to include Comp-specific webhook tokens.

## Maced contract canary test (real provider)

Use this e2e canary to detect Maced API contract drift against the live provider without creating new paid runs.

- Test file: `apps/api/test/maced-contract.e2e-spec.ts`
- Command:
- `MACED_API_KEY=<key> bun run test:e2e:maced`
- Optional deep-check env:
- `MACED_CONTRACT_E2E_RUN_ID=<existing_provider_run_id>`
- When present, the test also calls `GET /v1/pentests/:id` and `GET /v1/pentests/:id/progress`.
Original file line number Diff line number Diff line change
Expand Up @@ -51,16 +51,6 @@ export class CreatePenetrationTestDto {
@IsString()
workspace?: string;

@ApiPropertyOptional({
description:
'Set false to reject non-mocked checkout flows for strict behavior',
required: false,
default: true,
})
@IsOptional()
@IsBoolean()
mockCheckout?: boolean;

@ApiPropertyOptional({
description: 'Optional webhook URL to notify when report generation completes',
required: false,
Expand Down
Loading
Loading