@@ -2,56 +2,131 @@ import { test } from "node:test";
22import assert from "node:assert" ;
33import { tmpdir } from "node:os" ;
44import { join } from "node:path" ;
5- import { mkdtempSync , readFileSync , rmSync } from "node:fs" ;
5+ import { mkdtempSync , readFileSync , rmSync , writeFileSync , chmodSync } from "node:fs" ;
66import {
77 Sentry ,
88 toolExec ,
99 egress ,
1010 dns ,
1111 fileAccess ,
12+ sslContent ,
13+ securityAction ,
1214} from "../index.js" ;
1315
14- // ACL config: a custom Dns rule on top of the built-in defaults. (Note the doubled backslash for the
15- // ACL string literal, doubled again here for the JS template literal.)
16+ // Custom rules covering every severity, on top of the built-in defaults. (Inline-object fields are
17+ // comma-separated; the regex backslash is doubled for ACL then again for the JS template literal.)
1618const CFG = `
1719deny { egress = "" }
1820rules = [
19- { name = "block-evil-dns", on = "Dns", match = "evil\\\\.test",
20- verdict = "block", severity = "high", reason = "custom rule" },
21+ { name = "high-dns", on = "Dns", match = "high\\\\.test", verdict = "block", severity = "high", reason = "high rule" },
22+ { name = "low-dns", on = "Dns", match = "low\\\\.test", verdict = "block", severity = "low", reason = "low rule" },
23+ { name = "med-dns", on = "Dns", match = "med\\\\.test", verdict = "block", severity = "medium", reason = "med rule" },
24+ { name = "crit-dns", on = "Dns", match = "crit\\\\.test", verdict = "block", severity = "critical", reason = "crit rule" },
2125]
2226` ;
2327
24- test ( "create + built-in cloud-metadata SSRF blocks" , ( ) => {
28+ test ( "create + built-in cloud-metadata SSRF blocks (DenyEgress) " , ( ) => {
2529 const s = Sentry . create ( CFG ) ;
2630 const d = s . evaluate ( egress ( 1 , "169.254.169.254" , 80 ) ) ;
2731 assert . equal ( d . verdict , "block" ) ;
28- assert . equal ( d . action . kind , "DenyEgress" ) ;
29- assert . equal ( d . action . target , "169.254.169.254" ) ;
32+ assert . deepEqual ( d . action , { kind : "DenyEgress" , target : "169.254.169.254" } ) ;
3033} ) ;
3134
32- test ( "SDK-authored ACL rule fires through the embedded engine" , ( ) => {
33- const s = Sentry . create ( CFG ) ;
34- const d = s . evaluate ( dns ( 1 , "evil.test" ) ) ;
35+ test ( "SDK-authored ACL rule fires at tier=Rules" , ( ) => {
36+ const d = Sentry . create ( CFG ) . evaluate ( dns ( 1 , "high.test" ) ) ;
3537 assert . equal ( d . verdict , "block" ) ;
3638 assert . equal ( d . tier , "Rules" ) ;
37- assert . match ( d . reason , / c u s t o m r u l e / ) ;
39+ assert . match ( d . reason , / h i g h r u l e / ) ;
40+ } ) ;
41+
42+ test ( "severity arms: info / low / medium / high / critical" , ( ) => {
43+ const s = Sentry . create ( CFG ) ;
44+ assert . equal ( s . evaluate ( toolExec ( 1 , [ "ls" , "-la" ] ) ) . severity , "info" ) ; // benign allow
45+ assert . equal ( s . evaluate ( dns ( 1 , "low.test" ) ) . severity , "low" ) ;
46+ assert . equal ( s . evaluate ( dns ( 1 , "med.test" ) ) . severity , "medium" ) ;
47+ assert . equal ( s . evaluate ( dns ( 1 , "high.test" ) ) . severity , "high" ) ;
48+ assert . equal ( s . evaluate ( dns ( 1 , "crit.test" ) ) . severity , "critical" ) ;
3849} ) ;
3950
4051test ( "benign allowed; unparseable → null" , ( ) => {
4152 const s = Sentry . create ( CFG ) ;
42- assert . equal ( s . evaluate ( toolExec ( 1 , [ "ls" , "-la" ] ) ) . verdict , "allow" ) ;
53+ assert . equal ( s . evaluate ( toolExec ( 1 , [ "ls" ] ) ) . verdict , "allow" ) ;
4354 assert . equal ( s . evaluate ( "not a json event" ) , null ) ;
55+ assert . equal ( s . evaluateAndEnforce ( "still not json" ) , null ) ;
4456} ) ;
4557
46- test ( "evaluate_and_enforce writes the deny-file" , ( ) => {
58+ test ( "all six event builders produce judgeable events" , ( ) => {
59+ const s = Sentry . create ( CFG ) ;
60+ for ( const ev of [
61+ toolExec ( 1 , [ "echo" , "hi" ] ) ,
62+ egress ( 1 , "8.8.8.8" , 443 ) ,
63+ fileAccess ( 1 , "/tmp/x" , false ) ,
64+ dns ( 1 , "example.com" ) ,
65+ sslContent ( 1 , "hello" , true ) ,
66+ securityAction ( 1 , "setuid-root" , 0 ) ,
67+ ] ) {
68+ assert . ok ( s . evaluate ( ev ) !== null , `builder produced an unparseable event: ${ ev } ` ) ;
69+ }
70+ } ) ;
71+
72+ test ( "evaluate_and_enforce: DenyExec / DenyFile / non-block" , ( ) => {
4773 const dir = mkdtempSync ( join ( tmpdir ( ) , "sentry-node-" ) ) ;
4874 try {
49- const s = Sentry . create ( `deny { exec = "${ join ( dir , "exec.txt" ) } " }` ) ;
50- const r = s . evaluateAndEnforce ( toolExec ( 1 , [ "/usr/bin/ncat" , "x" , "4444" ] ) ) ;
51- if ( r . decision . verdict === "block" ) {
52- assert . equal ( r . enforced , join ( dir , "exec.txt" ) ) ;
53- assert . match ( readFileSync ( r . enforced , "utf8" ) , / \/ u s r \/ b i n \/ n c a t / ) ;
54- }
75+ const s = Sentry . create ( `
76+ deny {
77+ exec = "${ join ( dir , "exec.txt" ) } "
78+ file = "${ join ( dir , "file.txt" ) } "
79+ }
80+ rules = [
81+ { name = "x-exec", on = "ToolExec", match = "danger", verdict = "block", severity = "high", reason = "x", action = "deny-exec" },
82+ { name = "x-file", on = "FileAccess", match = "/etc/shadow", verdict = "block", severity = "high", reason = "x", action = "deny-file" },
83+ ]
84+ ` ) ;
85+ // DenyExec
86+ const ex = s . evaluateAndEnforce ( toolExec ( 1 , [ "/usr/bin/danger" , "x" ] ) ) ;
87+ assert . equal ( ex . decision . action . kind , "DenyExec" ) ;
88+ assert . equal ( ex . enforced , join ( dir , "exec.txt" ) ) ;
89+ assert . match ( readFileSync ( ex . enforced , "utf8" ) , / d a n g e r / ) ;
90+ // DenyFile
91+ const fa = s . evaluateAndEnforce ( fileAccess ( 1 , "/etc/shadow" , true ) ) ;
92+ assert . equal ( fa . decision . action . kind , "DenyFile" ) ;
93+ assert . equal ( fa . enforced , join ( dir , "file.txt" ) ) ;
94+ // non-block — benign event → no deny-file written
95+ const ok = s . evaluateAndEnforce ( toolExec ( 1 , [ "echo" , "ok" ] ) ) ;
96+ assert . equal ( ok . decision . verdict , "allow" ) ;
97+ assert . equal ( ok . enforced , undefined ) ;
98+ } finally {
99+ rmSync ( dir , { recursive : true , force : true } ) ;
100+ }
101+ } ) ;
102+
103+ test ( "L2 tier: an escalating event reaches L2 (unreachable URL → escalate, tier=Llm)" , ( ) => {
104+ // No mock server needed: a closed port refuses fast, L2 errors → escalate, and with no L3 the
105+ // unresolved escalation keeps tier=Llm. Exercises the Llm arm of the decision conversion.
106+ const s = Sentry . create ( `llm { url = "http://127.0.0.1:1/v1" }` ) ;
107+ const d = s . evaluate ( fileAccess ( 1 , "/home/u/.aws/credentials" , false ) ) ; // built-in: escalate
108+ assert . equal ( d . tier , "Llm" ) ;
109+ } ) ;
110+
111+ test ( "create() throws on a bad ACL config" , ( ) => {
112+ assert . throws ( ( ) => Sentry . create ( "this is not valid acl {{{" ) , / p a r s i n g s e n t r y A C L c o n f i g / ) ;
113+ } ) ;
114+
115+ test ( "L3 agent tier: escalating event → mock agent → tier=Agent" , ( ) => {
116+ const dir = mkdtempSync ( join ( tmpdir ( ) , "sentry-l3-" ) ) ;
117+ try {
118+ const bin = join ( dir , "mock-agent.sh" ) ;
119+ writeFileSync (
120+ bin ,
121+ '#!/bin/sh\necho \'{"verdict":"block","severity":"critical","reason":"mock L3"}\'\n' ,
122+ ) ;
123+ chmodSync ( bin , 0o755 ) ;
124+ // agent{} but no llm{} → an L1 escalate goes straight to L3 (the mock).
125+ const s = Sentry . create ( `agent { bin = "${ bin } " }` ) ;
126+ const d = s . evaluate ( fileAccess ( 1 , "/home/u/.aws/credentials" , false ) ) ; // built-in: escalate
127+ assert . equal ( d . tier , "Agent" ) ;
128+ assert . equal ( d . verdict , "block" ) ;
129+ assert . match ( d . reason , / m o c k L 3 / ) ;
55130 } finally {
56131 rmSync ( dir , { recursive : true , force : true } ) ;
57132 }
0 commit comments