Skip to content

Commit 6db5996

Browse files
mogeryclaude
andauthored
feat(api): threat protection — enterprise domain risk blocking (firecrawl#3949)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 75060fa commit 6db5996

58 files changed

Lines changed: 7075 additions & 25 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/knip.config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ const config: KnipConfig = {
1515
ignore: [
1616
"native/**",
1717
"src/scraper/scrapeURL/engines/fire-engine/branding-script/**",
18+
// Shared type contract co-owned by concurrent threat-protection branches;
19+
// the provider/verdict types are consumed by the core-lib branch.
20+
"src/lib/threat-protection/types.ts",
1821
],
1922
ignoreDependencies: ["undici-types", "stripe"],
2023
};
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import request from "supertest";
2+
import {
3+
describeIf,
4+
idmux,
5+
Identity,
6+
TEST_API_URL,
7+
TEST_PRODUCTION,
8+
} from "../lib";
9+
import { THREAT_PROTECTION_POLICY_DEFAULTS } from "../../../lib/threat-protection/types";
10+
11+
async function getConfigRaw(identity: Identity) {
12+
return await request(TEST_API_URL)
13+
.get("/v2/team/threat-protection")
14+
.set("Authorization", `Bearer ${identity.apiKey}`);
15+
}
16+
17+
async function putConfigRaw(body: unknown, identity: Identity) {
18+
return await request(TEST_API_URL)
19+
.put("/v2/team/threat-protection")
20+
.set("Authorization", `Bearer ${identity.apiKey}`)
21+
.set("Content-Type", "application/json")
22+
.send(body as object);
23+
}
24+
25+
// Requires DB authentication + idmux-provisioned team flags, which only exist
26+
// in the production test configuration.
27+
describeIf(TEST_PRODUCTION)("Team threat protection config API", () => {
28+
describe("without the team flag", () => {
29+
let identity: Identity;
30+
31+
beforeAll(async () => {
32+
identity = await idmux({
33+
name: "team-threat-protection/no-flag",
34+
});
35+
});
36+
37+
it("GET returns 403", async () => {
38+
const res = await getConfigRaw(identity);
39+
expect(res.statusCode).toBe(403);
40+
expect(res.body.success).toBe(false);
41+
expect(res.body.error).toContain("enterprise feature");
42+
});
43+
44+
it("PUT returns 403", async () => {
45+
const res = await putConfigRaw({ mode: "normal" }, identity);
46+
expect(res.statusCode).toBe(403);
47+
expect(res.body.success).toBe(false);
48+
expect(res.body.error).toContain("enterprise feature");
49+
});
50+
});
51+
52+
describe("with the team flag", () => {
53+
let identity: Identity;
54+
55+
beforeAll(async () => {
56+
identity = await idmux({
57+
name: "team-threat-protection/flagged",
58+
flags: {
59+
threatProtection: "allowed",
60+
},
61+
});
62+
});
63+
64+
it("GET returns the default (unconfigured) effective config", async () => {
65+
const res = await getConfigRaw(identity);
66+
expect(res.statusCode).toBe(200);
67+
expect(res.body.success).toBe(true);
68+
expect(res.body.data).toMatchObject({
69+
mode: "off",
70+
...THREAT_PROTECTION_POLICY_DEFAULTS,
71+
allowRequestOverrides: true,
72+
configured: false,
73+
});
74+
expect(res.body.data).not.toHaveProperty("siem");
75+
});
76+
77+
it("PUT round-trips a full config document", async () => {
78+
const doc = {
79+
mode: "normal",
80+
riskScoreThreshold: 60,
81+
blacklist: ["*.bad.example"],
82+
whitelist: ["firecrawl.dev"],
83+
blockedTlds: ["zip"],
84+
failurePolicy: "open",
85+
allowRequestOverrides: false,
86+
};
87+
88+
const put = await putConfigRaw(doc, identity);
89+
expect(put.statusCode).toBe(200);
90+
expect(put.body.success).toBe(true);
91+
expect(put.body.data).toMatchObject({
92+
mode: "normal",
93+
riskScoreThreshold: 60,
94+
blacklist: ["*.bad.example"],
95+
whitelist: ["firecrawl.dev"],
96+
blockedTlds: ["zip"],
97+
failurePolicy: "open",
98+
allowRequestOverrides: false,
99+
configured: true,
100+
});
101+
// Retired policy fields must not appear in the served document.
102+
expect(put.body.data).not.toHaveProperty("siem");
103+
expect(put.body.data).not.toHaveProperty("deniedCategories");
104+
expect(put.body.data).not.toHaveProperty("maxDomainAgeDays");
105+
expect(put.body.data).not.toHaveProperty("blockedCountries");
106+
107+
const get = await getConfigRaw(identity);
108+
expect(get.statusCode).toBe(200);
109+
expect(get.body.data).toMatchObject({
110+
mode: "normal",
111+
riskScoreThreshold: 60,
112+
configured: true,
113+
});
114+
expect(get.body.data).not.toHaveProperty("siem");
115+
expect(get.body.data).not.toHaveProperty("deniedCategories");
116+
expect(get.body.data).not.toHaveProperty("maxDomainAgeDays");
117+
expect(get.body.data).not.toHaveProperty("blockedCountries");
118+
});
119+
120+
it("PUT is a full-document update (unspecified fields reset to defaults)", async () => {
121+
const put = await putConfigRaw({ mode: "normal" }, identity);
122+
expect(put.statusCode).toBe(200);
123+
expect(put.body.data).toMatchObject({
124+
mode: "normal",
125+
...THREAT_PROTECTION_POLICY_DEFAULTS,
126+
allowRequestOverrides: true,
127+
configured: true,
128+
});
129+
expect(put.body.data).not.toHaveProperty("siem");
130+
});
131+
132+
it("PUT rejects an invalid document with 400", async () => {
133+
const res = await putConfigRaw(
134+
{
135+
mode: "normal",
136+
riskScoreThreshold: 500,
137+
blacklist: ["https://not-a-domain"],
138+
},
139+
identity,
140+
);
141+
expect(res.statusCode).toBe(400);
142+
expect(res.body.success).toBe(false);
143+
});
144+
145+
it('PUT rejects the retired "enhanced" mode with 400', async () => {
146+
const res = await putConfigRaw({ mode: "enhanced" }, identity);
147+
expect(res.statusCode).toBe(400);
148+
expect(res.body.success).toBe(false);
149+
});
150+
151+
it("PUT rejects retired policy fields with 400", async () => {
152+
for (const retired of [
153+
{ siem: { url: "https://siem.example.com/ingest" } },
154+
{ deniedCategories: ["Malicious"] },
155+
{ maxDomainAgeDays: 30 },
156+
{ blockedCountries: ["KP"] },
157+
]) {
158+
const res = await putConfigRaw(
159+
{ mode: "normal", ...retired },
160+
identity,
161+
);
162+
expect(res.statusCode).toBe(400);
163+
expect(res.body.success).toBe(false);
164+
}
165+
});
166+
});
167+
});

0 commit comments

Comments
 (0)