@cdot65/prisma-airs 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/prisma-airs-audit/handler.ts +6 -3
- package/hooks/prisma-airs-context/handler.ts +52 -3
- package/hooks/prisma-airs-guard/handler.ts +5 -0
- package/hooks/prisma-airs-outbound/handler.test.ts +60 -17
- package/hooks/prisma-airs-outbound/handler.ts +20 -1
- package/hooks/prisma-airs-tools/handler.ts +65 -62
- package/index.ts +5 -4
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/scan-cache.test.ts +6 -2
- package/src/scanner.test.ts +407 -0
- package/src/scanner.ts +345 -14
package/src/scanner.test.ts
CHANGED
|
@@ -256,6 +256,413 @@ describe("scanner", () => {
|
|
|
256
256
|
expect(result.promptDetected.urlCats).toBe(true);
|
|
257
257
|
});
|
|
258
258
|
|
|
259
|
+
it("detects toxic content in prompt", async () => {
|
|
260
|
+
mockFetch.mockResolvedValueOnce({
|
|
261
|
+
ok: true,
|
|
262
|
+
json: async () => ({
|
|
263
|
+
scan_id: "toxic-123",
|
|
264
|
+
report_id: "Rtoxic-123",
|
|
265
|
+
category: "malicious",
|
|
266
|
+
action: "block",
|
|
267
|
+
prompt_detected: { toxic_content: true },
|
|
268
|
+
response_detected: {},
|
|
269
|
+
}),
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const result = await scan({ prompt: "toxic message" });
|
|
273
|
+
|
|
274
|
+
expect(result.action).toBe("block");
|
|
275
|
+
expect(result.categories).toContain("toxic_content_prompt");
|
|
276
|
+
expect(result.promptDetected.toxicContent).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("detects malicious code in prompt", async () => {
|
|
280
|
+
mockFetch.mockResolvedValueOnce({
|
|
281
|
+
ok: true,
|
|
282
|
+
json: async () => ({
|
|
283
|
+
scan_id: "malcode-123",
|
|
284
|
+
report_id: "Rmalcode-123",
|
|
285
|
+
category: "malicious",
|
|
286
|
+
action: "block",
|
|
287
|
+
prompt_detected: { malicious_code: true },
|
|
288
|
+
response_detected: {},
|
|
289
|
+
}),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
const result = await scan({ prompt: "exec malware" });
|
|
293
|
+
|
|
294
|
+
expect(result.categories).toContain("malicious_code_prompt");
|
|
295
|
+
expect(result.promptDetected.maliciousCode).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("detects agent threat in prompt", async () => {
|
|
299
|
+
mockFetch.mockResolvedValueOnce({
|
|
300
|
+
ok: true,
|
|
301
|
+
json: async () => ({
|
|
302
|
+
scan_id: "agent-123",
|
|
303
|
+
report_id: "Ragent-123",
|
|
304
|
+
category: "malicious",
|
|
305
|
+
action: "block",
|
|
306
|
+
prompt_detected: { agent: true },
|
|
307
|
+
response_detected: {},
|
|
308
|
+
}),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const result = await scan({ prompt: "manipulate agent" });
|
|
312
|
+
|
|
313
|
+
expect(result.categories).toContain("agent_threat_prompt");
|
|
314
|
+
expect(result.promptDetected.agent).toBe(true);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it("detects topic violation in prompt", async () => {
|
|
318
|
+
mockFetch.mockResolvedValueOnce({
|
|
319
|
+
ok: true,
|
|
320
|
+
json: async () => ({
|
|
321
|
+
scan_id: "topic-123",
|
|
322
|
+
report_id: "Rtopic-123",
|
|
323
|
+
category: "suspicious",
|
|
324
|
+
action: "alert",
|
|
325
|
+
prompt_detected: { topic_violation: true },
|
|
326
|
+
response_detected: {},
|
|
327
|
+
}),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
const result = await scan({ prompt: "restricted topic" });
|
|
331
|
+
|
|
332
|
+
expect(result.categories).toContain("topic_violation_prompt");
|
|
333
|
+
expect(result.promptDetected.topicViolation).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("detects db_security in response", async () => {
|
|
337
|
+
mockFetch.mockResolvedValueOnce({
|
|
338
|
+
ok: true,
|
|
339
|
+
json: async () => ({
|
|
340
|
+
scan_id: "db-123",
|
|
341
|
+
report_id: "Rdb-123",
|
|
342
|
+
category: "malicious",
|
|
343
|
+
action: "block",
|
|
344
|
+
prompt_detected: {},
|
|
345
|
+
response_detected: { db_security: true },
|
|
346
|
+
}),
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
const result = await scan({ prompt: "query", response: "DROP TABLE" });
|
|
350
|
+
|
|
351
|
+
expect(result.categories).toContain("db_security_response");
|
|
352
|
+
expect(result.responseDetected.dbSecurity).toBe(true);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("detects ungrounded response", async () => {
|
|
356
|
+
mockFetch.mockResolvedValueOnce({
|
|
357
|
+
ok: true,
|
|
358
|
+
json: async () => ({
|
|
359
|
+
scan_id: "ung-123",
|
|
360
|
+
report_id: "Rung-123",
|
|
361
|
+
category: "suspicious",
|
|
362
|
+
action: "alert",
|
|
363
|
+
prompt_detected: {},
|
|
364
|
+
response_detected: { ungrounded: true },
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const result = await scan({ prompt: "question", response: "fabricated answer" });
|
|
369
|
+
|
|
370
|
+
expect(result.categories).toContain("ungrounded_response");
|
|
371
|
+
expect(result.responseDetected.ungrounded).toBe(true);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("detects toxic content in response", async () => {
|
|
375
|
+
mockFetch.mockResolvedValueOnce({
|
|
376
|
+
ok: true,
|
|
377
|
+
json: async () => ({
|
|
378
|
+
scan_id: "rtoxic-123",
|
|
379
|
+
report_id: "Rrtoxic-123",
|
|
380
|
+
category: "malicious",
|
|
381
|
+
action: "block",
|
|
382
|
+
prompt_detected: {},
|
|
383
|
+
response_detected: { toxic_content: true },
|
|
384
|
+
}),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const result = await scan({ prompt: "q", response: "toxic response" });
|
|
388
|
+
|
|
389
|
+
expect(result.categories).toContain("toxic_content_response");
|
|
390
|
+
expect(result.responseDetected.toxicContent).toBe(true);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("parses topic guardrails detection details", async () => {
|
|
394
|
+
mockFetch.mockResolvedValueOnce({
|
|
395
|
+
ok: true,
|
|
396
|
+
json: async () => ({
|
|
397
|
+
scan_id: "det-123",
|
|
398
|
+
report_id: "Rdet-123",
|
|
399
|
+
category: "suspicious",
|
|
400
|
+
action: "alert",
|
|
401
|
+
prompt_detected: { topic_violation: true },
|
|
402
|
+
response_detected: {},
|
|
403
|
+
prompt_detection_details: {
|
|
404
|
+
topic_guardrails_details: {
|
|
405
|
+
allowed_topics: ["general"],
|
|
406
|
+
blocked_topics: ["weapons"],
|
|
407
|
+
},
|
|
408
|
+
},
|
|
409
|
+
}),
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
const result = await scan({ prompt: "restricted topic" });
|
|
413
|
+
|
|
414
|
+
expect(result.promptDetectionDetails?.topicGuardrailsDetails).toEqual({
|
|
415
|
+
allowedTopics: ["general"],
|
|
416
|
+
blockedTopics: ["weapons"],
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("parses masked data with pattern detections", async () => {
|
|
421
|
+
mockFetch.mockResolvedValueOnce({
|
|
422
|
+
ok: true,
|
|
423
|
+
json: async () => ({
|
|
424
|
+
scan_id: "mask-123",
|
|
425
|
+
report_id: "Rmask-123",
|
|
426
|
+
category: "suspicious",
|
|
427
|
+
action: "alert",
|
|
428
|
+
prompt_detected: { dlp: true },
|
|
429
|
+
response_detected: {},
|
|
430
|
+
prompt_masked_data: {
|
|
431
|
+
data: "My SSN is [REDACTED]",
|
|
432
|
+
pattern_detections: [
|
|
433
|
+
{
|
|
434
|
+
pattern: "ssn",
|
|
435
|
+
locations: [[10, 21]],
|
|
436
|
+
},
|
|
437
|
+
],
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const result = await scan({ prompt: "My SSN is 123-45-6789" });
|
|
443
|
+
|
|
444
|
+
expect(result.promptMaskedData?.data).toBe("My SSN is [REDACTED]");
|
|
445
|
+
expect(result.promptMaskedData?.patternDetections).toHaveLength(1);
|
|
446
|
+
expect(result.promptMaskedData?.patternDetections[0].pattern).toBe("ssn");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it("omits detection details and masked data when absent", async () => {
|
|
450
|
+
mockFetch.mockResolvedValueOnce({
|
|
451
|
+
ok: true,
|
|
452
|
+
json: async () => ({
|
|
453
|
+
scan_id: "clean-123",
|
|
454
|
+
report_id: "Rclean-123",
|
|
455
|
+
category: "benign",
|
|
456
|
+
action: "allow",
|
|
457
|
+
prompt_detected: {},
|
|
458
|
+
response_detected: {},
|
|
459
|
+
}),
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const result = await scan({ prompt: "hello" });
|
|
463
|
+
|
|
464
|
+
expect(result.promptDetectionDetails).toBeUndefined();
|
|
465
|
+
expect(result.responseDetectionDetails).toBeUndefined();
|
|
466
|
+
expect(result.promptMaskedData).toBeUndefined();
|
|
467
|
+
expect(result.responseMaskedData).toBeUndefined();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it("sets timeout and partial_scan category when timeout is true", async () => {
|
|
471
|
+
mockFetch.mockResolvedValueOnce({
|
|
472
|
+
ok: true,
|
|
473
|
+
json: async () => ({
|
|
474
|
+
scan_id: "to-123",
|
|
475
|
+
report_id: "Rto-123",
|
|
476
|
+
category: "benign",
|
|
477
|
+
action: "allow",
|
|
478
|
+
prompt_detected: {},
|
|
479
|
+
response_detected: {},
|
|
480
|
+
timeout: true,
|
|
481
|
+
}),
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const result = await scan({ prompt: "test" });
|
|
485
|
+
|
|
486
|
+
expect(result.timeout).toBe(true);
|
|
487
|
+
expect(result.categories).toContain("partial_scan");
|
|
488
|
+
expect(result.severity).toBe("SAFE"); // no severity escalation
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("sets hasError and contentErrors from API errors", async () => {
|
|
492
|
+
mockFetch.mockResolvedValueOnce({
|
|
493
|
+
ok: true,
|
|
494
|
+
json: async () => ({
|
|
495
|
+
scan_id: "err-123",
|
|
496
|
+
report_id: "Rerr-123",
|
|
497
|
+
category: "benign",
|
|
498
|
+
action: "allow",
|
|
499
|
+
prompt_detected: {},
|
|
500
|
+
response_detected: {},
|
|
501
|
+
error: true,
|
|
502
|
+
errors: [
|
|
503
|
+
{ content_type: "prompt", feature: "dlp", status: "error" },
|
|
504
|
+
{ content_type: "response", feature: "toxic_content", status: "timeout" },
|
|
505
|
+
],
|
|
506
|
+
}),
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
const result = await scan({ prompt: "test", response: "resp" });
|
|
510
|
+
|
|
511
|
+
expect(result.hasError).toBe(true);
|
|
512
|
+
expect(result.contentErrors).toHaveLength(2);
|
|
513
|
+
expect(result.contentErrors[0]).toEqual({
|
|
514
|
+
contentType: "prompt",
|
|
515
|
+
feature: "dlp",
|
|
516
|
+
status: "error",
|
|
517
|
+
});
|
|
518
|
+
expect(result.contentErrors[1]).toEqual({
|
|
519
|
+
contentType: "response",
|
|
520
|
+
feature: "toxic_content",
|
|
521
|
+
status: "timeout",
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("defaults timeout/hasError/contentErrors when absent", async () => {
|
|
526
|
+
mockFetch.mockResolvedValueOnce({
|
|
527
|
+
ok: true,
|
|
528
|
+
json: async () => ({
|
|
529
|
+
scan_id: "def-err",
|
|
530
|
+
report_id: "Rdef-err",
|
|
531
|
+
category: "benign",
|
|
532
|
+
action: "allow",
|
|
533
|
+
prompt_detected: {},
|
|
534
|
+
response_detected: {},
|
|
535
|
+
}),
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const result = await scan({ prompt: "test" });
|
|
539
|
+
|
|
540
|
+
expect(result.timeout).toBe(false);
|
|
541
|
+
expect(result.hasError).toBe(false);
|
|
542
|
+
expect(result.contentErrors).toEqual([]);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("parses tool_detected from API response", async () => {
|
|
546
|
+
mockFetch.mockResolvedValueOnce({
|
|
547
|
+
ok: true,
|
|
548
|
+
json: async () => ({
|
|
549
|
+
scan_id: "tool-123",
|
|
550
|
+
report_id: "Rtool-123",
|
|
551
|
+
category: "malicious",
|
|
552
|
+
action: "block",
|
|
553
|
+
prompt_detected: {},
|
|
554
|
+
response_detected: {},
|
|
555
|
+
tool_detected: {
|
|
556
|
+
verdict: "malicious",
|
|
557
|
+
metadata: {
|
|
558
|
+
ecosystem: "mcp",
|
|
559
|
+
method: "tool_call",
|
|
560
|
+
server_name: "test-server",
|
|
561
|
+
tool_invoked: "exec",
|
|
562
|
+
},
|
|
563
|
+
summary: "Malicious tool usage detected",
|
|
564
|
+
input_detected: { injection: true, malicious_code: true },
|
|
565
|
+
output_detected: { dlp: true },
|
|
566
|
+
},
|
|
567
|
+
}),
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
const result = await scan({ prompt: "test" });
|
|
571
|
+
|
|
572
|
+
expect(result.toolDetected).toBeDefined();
|
|
573
|
+
expect(result.toolDetected?.verdict).toBe("malicious");
|
|
574
|
+
expect(result.toolDetected?.metadata.ecosystem).toBe("mcp");
|
|
575
|
+
expect(result.toolDetected?.metadata.serverName).toBe("test-server");
|
|
576
|
+
expect(result.toolDetected?.metadata.toolInvoked).toBe("exec");
|
|
577
|
+
expect(result.toolDetected?.summary).toBe("Malicious tool usage detected");
|
|
578
|
+
expect(result.toolDetected?.inputDetected?.injection).toBe(true);
|
|
579
|
+
expect(result.toolDetected?.inputDetected?.maliciousCode).toBe(true);
|
|
580
|
+
expect(result.toolDetected?.outputDetected?.dlp).toBe(true);
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it("sends toolEvents in request body", async () => {
|
|
584
|
+
mockFetch.mockResolvedValueOnce({
|
|
585
|
+
ok: true,
|
|
586
|
+
json: async () => ({
|
|
587
|
+
scan_id: "tevt-123",
|
|
588
|
+
report_id: "Rtevt-123",
|
|
589
|
+
category: "benign",
|
|
590
|
+
action: "allow",
|
|
591
|
+
prompt_detected: {},
|
|
592
|
+
response_detected: {},
|
|
593
|
+
}),
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
await scan({
|
|
597
|
+
prompt: "test",
|
|
598
|
+
toolEvents: [
|
|
599
|
+
{
|
|
600
|
+
metadata: {
|
|
601
|
+
ecosystem: "mcp",
|
|
602
|
+
method: "tool_call",
|
|
603
|
+
serverName: "my-server",
|
|
604
|
+
toolInvoked: "read_file",
|
|
605
|
+
},
|
|
606
|
+
input: '{"path":"/etc/passwd"}',
|
|
607
|
+
output: "root:x:0:0:root:/root:/bin/bash",
|
|
608
|
+
},
|
|
609
|
+
],
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const body = JSON.parse(mockFetch.mock.calls[0][1].body);
|
|
613
|
+
expect(body.contents[0].tool_calls).toHaveLength(1);
|
|
614
|
+
expect(body.contents[0].tool_calls[0].metadata.ecosystem).toBe("mcp");
|
|
615
|
+
expect(body.contents[0].tool_calls[0].metadata.server_name).toBe("my-server");
|
|
616
|
+
expect(body.contents[0].tool_calls[0].metadata.tool_invoked).toBe("read_file");
|
|
617
|
+
expect(body.contents[0].tool_calls[0].input).toBe('{"path":"/etc/passwd"}');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("parses timestamps and metadata when present", async () => {
|
|
621
|
+
mockFetch.mockResolvedValueOnce({
|
|
622
|
+
ok: true,
|
|
623
|
+
json: async () => ({
|
|
624
|
+
scan_id: "ts-123",
|
|
625
|
+
report_id: "Rts-123",
|
|
626
|
+
category: "benign",
|
|
627
|
+
action: "allow",
|
|
628
|
+
prompt_detected: {},
|
|
629
|
+
response_detected: {},
|
|
630
|
+
source: "airs-v2",
|
|
631
|
+
profile_id: "prof-abc",
|
|
632
|
+
created_at: "2025-01-15T10:30:00Z",
|
|
633
|
+
completed_at: "2025-01-15T10:30:01Z",
|
|
634
|
+
}),
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
const result = await scan({ prompt: "test" });
|
|
638
|
+
|
|
639
|
+
expect(result.source).toBe("airs-v2");
|
|
640
|
+
expect(result.profileId).toBe("prof-abc");
|
|
641
|
+
expect(result.createdAt).toBe("2025-01-15T10:30:00Z");
|
|
642
|
+
expect(result.completedAt).toBe("2025-01-15T10:30:01Z");
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("omits timestamps when absent", async () => {
|
|
646
|
+
mockFetch.mockResolvedValueOnce({
|
|
647
|
+
ok: true,
|
|
648
|
+
json: async () => ({
|
|
649
|
+
scan_id: "nots-123",
|
|
650
|
+
report_id: "Rnots-123",
|
|
651
|
+
category: "benign",
|
|
652
|
+
action: "allow",
|
|
653
|
+
prompt_detected: {},
|
|
654
|
+
response_detected: {},
|
|
655
|
+
}),
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
const result = await scan({ prompt: "test" });
|
|
659
|
+
|
|
660
|
+
expect(result.source).toBeUndefined();
|
|
661
|
+
expect(result.profileId).toBeUndefined();
|
|
662
|
+
expect(result.createdAt).toBeUndefined();
|
|
663
|
+
expect(result.completedAt).toBeUndefined();
|
|
664
|
+
});
|
|
665
|
+
|
|
259
666
|
it("tracks latency correctly", async () => {
|
|
260
667
|
mockFetch.mockImplementationOnce(async () => {
|
|
261
668
|
await new Promise((r) => setTimeout(r, 50)); // 50ms delay
|