From e16ef3c2950b9d6258aaea1bb18c1d422fb1ea6e Mon Sep 17 00:00:00 2001 From: Mayur Das Date: Mon, 19 Oct 2026 11:19:31 +0530 Subject: [PATCH] feat(audit): program and connection events in the audit trail The audit trail now carries every program a sandbox runs and every connection it opens, not only the calls made to it. They arrive as OCSF Process Activity (class 1007) and Network Activity (class 4001) events beside the existing API Activity ones, so api and src_endpoint are now absent on some events, and process, exit_code, dst_endpoint and actor.process are new. Regenerated from the aiagent spec. The examples and README no longer assume every event has api, and sandbox update documents execution_trace, which turns program recording off. The vendored spec keeps snapshot trigger optional, so responses from older API versions still parse. Signed-off-by: Mayur Das --- README.md | 19 ++++-- docs/api-inventory.md | 31 ++++++--- examples/agent_ports_audit.py | 8 ++- examples/audit_trail.py | 30 ++++++--- specs/aiagent.yaml | 104 +++++++++++++++++++++++------- src/neevai/generated/aiagent.py | 88 +++++++++++++++++++++---- src/neevai/resources/sandboxes.py | 12 ++-- tests/test_audit.py | 44 ++++++++++++- tests/test_sandboxes.py | 13 ++++ 9 files changed, 286 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 5eceee3..e186df4 100644 --- a/README.md +++ b/README.md @@ -297,16 +297,25 @@ See [`sandbox_metrics.py`](examples/sandbox_metrics.py) · [reference](docs/api- ### Audit trail -Read what was run inside a sandbox, newest first: commands, terminal and SSH sessions, -and file and process operations. Each event says who made the call and how it ended. -Programs are recorded by name only, never with their arguments. +Read what was run inside a sandbox, newest first, as OCSF events: API calls (commands, +terminal and SSH sessions, file and process operations) with who made them, plus every +program the sandbox ran and every connection it opened, whether or not anything asked for +them. Programs are recorded by name only, never with their arguments. ```python -trail = sandbox.audit(operation="exec", status="failure") +trail = sandbox.audit(status="failure") for e in trail.data: - print(e.time, e.api.operation, e.actor.user.uid, e.status) + if e.api is not None: + what = e.api.operation + else: + proc = e.process or e.actor.process + what = f"{e.class_name}: {proc.name if proc else '?'}" + print(e.time, what, e.status) ``` +Program recording is on by default; turn it off with +`sandbox.update({"execution_trace": False})`. + Filter with `operation`, `status`, `actor_type`, `actor_uid` or `credential_uid`, and page back with `cursor=trail.next_cursor`. Events take a few seconds to appear after a call. diff --git a/docs/api-inventory.md b/docs/api-inventory.md index 8f0613d..d515ef1 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -435,10 +435,11 @@ for series in metrics.series: ### `client.sandboxes.audit(id, *, from_=None, to=None, cursor=None, limit=None, operation=None, status=None, actor_type=None, actor_uid=None, credential_uid=None, org_id=None, project_id=None)` -Reads one page of the sandbox's audit trail, newest first: terminal commands, SSH, -process and file operations, each with who made the call and how it ended. Only the -program name is recorded, never its arguments, and input typed at a hidden password -prompt is not captured. +Reads one page of the sandbox's audit trail, newest first, as OCSF events: terminal +commands, SSH, process and file operations, each with who made the call and how it +ended, plus every program the sandbox ran and every connection it opened, unless its +`execution_trace` is off. Only the program name is recorded, never its arguments, and +input typed at a hidden password prompt is not captured. `from_` / `to` (RFC3339) default to the last 24 hours. `limit` is clamped server-side. The response's `window_truncated` is `True` when `from_` reaches past the @@ -459,7 +460,8 @@ Filters, all optional: trail = sandbox.audit(limit=50) while True: for e in trail.data: - print(e.time, e.api.operation, e.actor.user.name or e.actor.user.uid, e.status) + what = e.api.operation if e.api is not None else e.class_name + print(e.time, what, e.actor.user.name or e.actor.user.uid, e.status) if not trail.next_cursor: break trail = sandbox.audit(limit=50, cursor=trail.next_cursor) @@ -1911,15 +1913,28 @@ for `egress_add` / `egress_remove`. | `next_cursor` | `str \| None` | no (pass as `cursor` for the next, older page) | | `data` | `list[AuditEvent]` | yes (newest first) | -`AuditEvent` is one call, in the OCSF API Activity format. The fields most callers read: +`AuditEvent` is one OCSF 1.9.0 event. `class_uid` says which kind, and which fields +are present: + +| `class_uid` | Kind | Carries | +| ----------- | ---- | ------- | +| 6003 | API Activity: a call to the sandbox | `api`, `src_endpoint`, `actor.user` | +| 1007 | Process Activity: a program started (`activity_id` 1, Launch), ended (2, Terminate) or the sandbox started (99) | `process`, `exit_code` on Terminate (or `status_detail` `signal N` if killed) | +| 4001 | Network Activity: a program opened a connection (`activity_id` 1, Open) | `dst_endpoint`, `actor.process` | + +The fields most callers read: | Field | Type | Required | | ----- | ---- | -------- | | `time` | `int` | yes (milliseconds since the Unix epoch) | -| `api.operation` | `str` | yes (e.g. `pty_command`, `ssh`, `exec`, `process.start`, `fs.read`) | +| `api.operation` | `str` | API Activity only (e.g. `pty_command`, `ssh`, `exec`, `process.start`, `fs.read`) | +| `process` | `AuditProcess \| None` | Process Activity only (`pid`, `name` without arguments, `cwd`, `file.path`, `parent_process.pid`) | +| `exit_code` | `int \| None` | no (what a program returned; on Terminate unless a signal stopped it) | +| `dst_endpoint` | `AuditDstEndpoint \| None` | Network Activity only (`ip` and `port`, or `name` as `unix:`) | +| `actor.process` | `AuditProcess \| None` | Network Activity only (the program that connected) | | `status` | `str` | yes (`Success` or `Failure`) | | `status_code` | `str \| None` | no (`ok`, `permission_denied`, `internal`) | -| `actor.user.type` | `user` \| `api_key` \| `system` \| `unknown` | yes | +| `actor.user.type` | `user` \| `api_key` \| `system` \| `unknown` | yes (`unknown` on program and connection events) | | `actor.user.uid` | `str` | yes (the user's or API key's id) | | `actor.user.name` | `str \| None` | no | | `actor.user.credential_uid` | `str \| None` | no (the API key used; absent for a user) | diff --git a/examples/agent_ports_audit.py b/examples/agent_ports_audit.py index dc9c471..5b66e77 100644 --- a/examples/agent_ports_audit.py +++ b/examples/agent_ports_audit.py @@ -62,8 +62,12 @@ def main() -> None: trail = agent.audit(limit=20) for e in trail.data: at = datetime.fromtimestamp(e.time / 1000, tz=timezone.utc) - who = e.actor.user.name or e.actor.user.uid - print(f"{at:%H:%M:%S} {e.api.operation:<14} {who} {e.status}") + if e.api is not None: + what = f"{e.api.operation} by {e.actor.user.name or e.actor.user.uid}" + else: + proc = e.process or e.actor.process + what = f"{e.class_name}: {proc.name if proc else '?'}" + print(f"{at:%H:%M:%S} {what} {e.status}") agent.revoke_port(PORT) except NeevAIError as e: diff --git a/examples/audit_trail.py b/examples/audit_trail.py index f28b129..8dbf8ac 100644 --- a/examples/audit_trail.py +++ b/examples/audit_trail.py @@ -1,10 +1,11 @@ """ Run a few commands in a sandbox, then read back its audit trail. -Each event is one call made inside the sandbox — terminal commands, SSH, process -and file operations — newest first, with who made it and how it ended. Only the -program name is recorded, never its arguments. Filter by ``operation``, -``status``, ``actor_type``, ``actor_uid`` or ``credential_uid``. +Events are OCSF, newest first: each call made to the sandbox — terminal commands, +SSH, process and file operations — with who made it, plus every program the +sandbox ran and every connection it opened, whether or not anything asked for +them. Only the program name is recorded, never its arguments. Filter by +``operation``, ``status``, ``actor_type``, ``actor_uid`` or ``credential_uid``. Pages are read with ``cursor``: pass the previous page's ``next_cursor`` until it is empty. @@ -28,10 +29,23 @@ TEMPLATE = os.environ.get("NEEV_SANDBOX_TEMPLATE_ID", "sb-ubuntu-26-04-dev") +def describe(e: AuditEvent) -> str: + """One line per event: the API call, or the program and what it did.""" + if e.api is not None: + return f"{e.api.operation} by {e.actor.user.name or e.actor.user.uid}" + if e.dst_endpoint is not None: + d = e.dst_endpoint + dst = d.name or f"{d.ip}:{d.port}" + proc = e.actor.process.name if e.actor.process else "?" + return f"{proc} connect {dst}" + name = e.process.name if e.process else "?" + exit_code = f" exit {e.exit_code}" if e.exit_code is not None else "" + return f"{name} {e.activity_name.lower()}{exit_code}" + + def show(e: AuditEvent) -> None: at = datetime.fromtimestamp(e.time / 1000, tz=timezone.utc) - who = e.actor.user.name or e.actor.user.uid - print(f"{at:%H:%M:%S} {e.api.operation:<14} {who:<20} {e.status}") + print(f"{at:%H:%M:%S} {describe(e):<40} {e.status}") def main() -> None: @@ -63,8 +77,8 @@ def main() -> None: break trail = sandbox.audit(limit=20, cursor=trail.next_cursor) - # Only the commands that were run. - print("exec calls:") + # Exec calls and program launches. + print("exec:") for e in sandbox.audit(operation="exec").data: show(e) except NeevAIError as e: diff --git a/specs/aiagent.yaml b/specs/aiagent.yaml index 760a40a..4edc42e 100644 --- a/specs/aiagent.yaml +++ b/specs/aiagent.yaml @@ -1904,7 +1904,7 @@ components: in: query required: false description: >- - Only these calls (api.operation), one or several separated by commas, such as `exec,pty_command`. + Only these calls (api.operation), one or several separated by commas, such as `exec,pty_command`. A recorded program matches `exec` when it starts, `exit` when it ends and `connect` when it opens a connection. schema: {type: string} AuditStatus: name: status @@ -2075,6 +2075,12 @@ components: type: object required: [id, org_id, project_id, name, region, image, phase, created_at, updated_at] properties: + execution_trace: + type: boolean + description: | + Whether this sandbox records every program that runs inside it, which + is on unless it was turned off. False means it records only the + operations you request. id: {type: string, format: uuid} org_id: {type: string} project_id: {type: string} @@ -2285,9 +2291,9 @@ components: type: object additionalProperties: false description: | - Request to update a running sandbox's CPU/memory and/or egress rules. - At least one of `resources`, `egress`, `egress_add` or `egress_remove` - must be provided. `egress` replaces the policy in full; `egress_add` and + Request to update a running sandbox's CPU/memory, egress rules, or whether + it records what runs inside it. At least one of `resources`, `egress`, + `egress_add`, `egress_remove` or `execution_trace` must be provided. `egress` replaces the policy in full; `egress_add` and `egress_remove` edit the allow-list in place and cannot be combined with it. properties: resources: @@ -2304,6 +2310,14 @@ components: New egress policy for the sandbox. Replaces the existing policy in full and takes effect immediately for new connections — no sandbox restart is required. + execution_trace: + type: boolean + description: | + Record every program that runs inside this sandbox, not only the + operations you request. On by default; false turns it off, and true + turns it back on. Takes effect on the running sandbox without a + restart. Recording costs a little processor time on each program + started. egress_add: allOf: - $ref: '#/components/schemas/SandboxEgressRules' @@ -2587,33 +2601,34 @@ components: AuditEvent: type: object description: >- - One call to the sandbox, as an OCSF 1.9.0 API Activity event (class_uid 6003). Program names are recorded without their arguments, and input typed at a hidden password prompt is never recorded. - required: [class_uid, class_name, category_uid, category_name, activity_id, activity_name, type_uid, type_name, time, severity_id, severity, status_id, status, actor, api, cloud, src_endpoint, osint, metadata] + One thing that happened in the sandbox, as an OCSF 1.9.0 event: an API Activity event (class_uid 6003) for a call to the sandbox. Unless its `execution_trace` is turned off, a sandbox also records every program that runs inside it, whether or not anything requested it: a Process Activity event (1007) for a program starting or ending, and a Network Activity event (4001) for a connection a program opened. Program names are recorded without their arguments, and input typed at a hidden password prompt is never recorded. + required: [class_uid, class_name, category_uid, category_name, activity_id, activity_name, type_uid, type_name, time, severity_id, severity, status_id, status, actor, cloud, osint, metadata] properties: class_uid: type: integer - enum: [6003] - x-enum-varnames: [AuditClassAPIActivity] - description: OCSF class, API Activity. - class_name: {type: string, description: Always API Activity.} + enum: [6003, 1007, 4001] + x-enum-varnames: [AuditClassAPIActivity, AuditClassProcessActivity, AuditClassNetworkActivity] + description: OCSF class, API Activity, Process Activity or Network Activity. + class_name: {type: string, description: "The name of class_uid, such as API Activity."} category_uid: type: integer - enum: [6] - x-enum-varnames: [AuditCategoryApplicationActivity] - description: OCSF category, Application Activity. - category_name: {type: string, description: Always Application Activity.} + enum: [6, 1, 4] + x-enum-varnames: [AuditCategoryApplicationActivity, AuditCategorySystemActivity, AuditCategoryNetworkActivity] + description: >- + OCSF category: Application Activity for an API call, System Activity for a program, Network Activity for a connection. + category_name: {type: string, description: "The name of category_uid, such as Application Activity."} activity_id: type: integer enum: [1, 2, 3, 4, 99] x-enum-varnames: [AuditActivityCreate, AuditActivityRead, AuditActivityUpdate, AuditActivityDelete, AuditActivityOther] description: >- - 1 Create (a write, a started process, a terminal), 2 Read, 3 Update (a move), 4 Delete (a removed file, a killed process), 99 Other. - activity_name: {type: string, description: "The name of activity_id, such as Create."} + Read with class_uid. For an API call: 1 Create (a write, a started process, a terminal), 2 Read, 3 Update (a move), 4 Delete (a removed file, a killed process), 99 Other. For a program: 1 Launch, 2 Terminate, 99 Other (the sandbox starting). For a connection: 1 Open. + activity_name: {type: string, description: "The name of activity_id within its class, such as Create or Launch."} type_uid: type: integer format: int64 - description: class_uid * 100 + activity_id, such as 600301. - type_name: {type: string, description: "Such as API Activity: Create."} + description: class_uid * 100 + activity_id, such as 600301 or 100701. + type_name: {type: string, description: "Such as API Activity: Create or Process Activity: Launch."} time: type: integer format: int64 @@ -2641,9 +2656,15 @@ components: status_detail: type: string description: >- - How a code-interpreter cell ended: `ok`, `error`, `deadline_exceeded`, `kernel_restarted`, `memory_exceeded`, `unavailable`, `invalid_response`, or `client_gone` when the caller disconnected first. + How a code-interpreter cell ended: `ok`, `error`, `deadline_exceeded`, `kernel_restarted`, `memory_exceeded`, `unavailable`, `invalid_response`, or `client_gone` when the caller disconnected first. On a program that was stopped rather than ending on its own, such as one out of memory or told to stop, `signal ` names the signal that stopped it. actor: {$ref: '#/components/schemas/AuditActor'} api: {$ref: '#/components/schemas/AuditAPI'} + process: {$ref: '#/components/schemas/AuditProcess'} + exit_code: + type: integer + description: >- + What a program returned when it ended. Zero means it succeeded. Present on a Process Activity Terminate event unless a signal stopped the program. + dst_endpoint: {$ref: '#/components/schemas/AuditDstEndpoint'} resources: type: array description: >- @@ -2653,12 +2674,48 @@ components: src_endpoint: {$ref: '#/components/schemas/AuditEndpoint'} osint: type: array - description: Threat intelligence about the call. Always empty. + description: Threat intelligence about the event. Always empty. items: {type: object} metadata: {$ref: '#/components/schemas/AuditMetadata'} + AuditProcess: + type: object + description: >- + A program that ran inside the sandbox, as an OCSF process. On a Process Activity event it is the program that started or ended; on a Network Activity event it is actor.process, the program that opened the connection. + properties: + pid: {type: integer, description: Identifies the program within the sandbox.} + name: + type: string + description: >- + The program, without its arguments. A program can name itself freely, so file.path is the stronger field. + cwd: {type: string, description: The working directory the program ran in.} + file: {$ref: '#/components/schemas/AuditFile'} + parent_process: {$ref: '#/components/schemas/AuditParentProcess'} + AuditParentProcess: + type: object + description: >- + The process that started this one, so a run can be traced back to what caused it. Its own Launch event names it. + properties: + pid: {type: integer, description: Identifies the parent within the sandbox.} + AuditFile: + type: object + required: [path] + properties: + path: + type: string + description: The file that actually executed, after symlinks and interpreters were resolved. + AuditDstEndpoint: + type: object + description: >- + Where a program tried to connect, as OCSF dst_endpoint. It records the attempt and where it was aimed, not what was sent. + properties: + ip: {type: string, description: The address reached for.} + port: {type: integer, description: The port reached for.} + name: + type: string + description: A local socket, as `unix:`, in place of ip and port. AuditAPI: type: object - description: The call made to the sandbox, as OCSF api. + description: The call made to the sandbox, as OCSF api. Present on API Activity only. required: [operation] properties: operation: @@ -2700,7 +2757,7 @@ components: AuditEndpoint: type: object description: >- - Where the call came from. The caller's address is not recorded yet, so name is Platform for the platform's own calls and unknown otherwise. + Where the call came from. The caller's address is not recorded yet, so name is Platform for the platform's own calls and unknown otherwise. Present on API Activity only. required: [name] properties: name: {type: string} @@ -2724,11 +2781,12 @@ components: AuditActor: type: object description: >- - Who made the call, as an OCSF actor. A console record from before actors were recorded names only its session, so its user is unknown. + Who made the call, as an OCSF actor. A console record from before actors were recorded names only its session, so its user is unknown. A program's event records no user, so its user is unknown too. required: [user] properties: user: {$ref: '#/components/schemas/AuditActorUser'} session: {$ref: '#/components/schemas/AuditActorSession'} + process: {$ref: '#/components/schemas/AuditProcess'} AuditActorUser: type: object description: The principal that acted, as OCSF actor.user. diff --git a/src/neevai/generated/aiagent.py b/src/neevai/generated/aiagent.py index b446f1a..3e302c9 100644 --- a/src/neevai/generated/aiagent.py +++ b/src/neevai/generated/aiagent.py @@ -353,10 +353,14 @@ class ConnectTokenResponse(BaseModel): class ClassUid(IntEnum): AuditClassAPIActivity = 6003 + AuditClassProcessActivity = 1007 + AuditClassNetworkActivity = 4001 class CategoryUid(IntEnum): AuditCategoryApplicationActivity = 6 + AuditCategorySystemActivity = 1 + AuditCategoryNetworkActivity = 4 class ActivityId(IntEnum): @@ -376,6 +380,25 @@ class StatusId(IntEnum): AuditStatusFailure = 2 +class AuditParentProcess(BaseModel): + pid: int | None = Field(None, description="Identifies the parent within the sandbox.") + + +class AuditFile(BaseModel): + path: str = Field( + ..., + description="The file that actually executed, after symlinks and interpreters were resolved.", + ) + + +class AuditDstEndpoint(BaseModel): + ip: str | None = Field(None, description="The address reached for.") + port: int | None = Field(None, description="The port reached for.") + name: str | None = Field( + None, description="A local socket, as `unix:`, in place of ip and port." + ) + + class AuditAPIRequest(BaseModel): uid: str = Field(..., description="The request that caused the call, for correlation.") @@ -532,6 +555,17 @@ class SandboxEgressConfig(BaseModel): ) +class AuditProcess(BaseModel): + pid: int | None = Field(None, description="Identifies the program within the sandbox.") + name: str | None = Field( + None, + description="The program, without its arguments. A program can name itself freely, so file.path is the stronger field.", + ) + cwd: str | None = Field(None, description="The working directory the program ran in.") + file: AuditFile | None = None + parent_process: AuditParentProcess | None = None + + class AuditAPI(BaseModel): operation: str = Field( ..., @@ -560,6 +594,7 @@ class AuditMetadata(BaseModel): class AuditActor(BaseModel): user: AuditActorUser session: AuditActorSession | None = None + process: AuditProcess | None = None class Agent(BaseModel): @@ -708,6 +743,10 @@ class AgentTemplateListResponse(BaseModel): class Sandbox(BaseModel): + execution_trace: bool | None = Field( + None, + description="Whether this sandbox records every program that runs inside it, which\nis on unless it was turned off. False means it records only the\noperations you request.\n", + ) id: UUID org_id: str project_id: str @@ -1038,6 +1077,10 @@ class UpdateSandboxRequest(BaseModel): None, description="New egress policy for the sandbox. Replaces the existing policy\nin full and takes effect immediately for new connections — no sandbox\nrestart is required.\n", ) + execution_trace: bool | None = Field( + None, + description="Record every program that runs inside this sandbox, not only the\noperations you request. On by default; false turns it off, and true\nturns it back on. Takes effect on the running sandbox without a\nrestart. Recording costs a little processor time on each program\nstarted.\n", + ) egress_add: SandboxEgressRules | None = Field( None, description="Destinations to add to the existing allow-list, leaving every other\nrule in place. Adding a host already allowed replaces its ports and\nprotocol. Rejected with `egress`, and rejected when the current mode\nis deny_all — switch the mode with `egress` first.\n", @@ -1056,17 +1099,32 @@ class SandboxListResponse(BaseModel): class AuditEvent(BaseModel): - class_uid: ClassUid = Field(..., description="OCSF class, API Activity.") - class_name: str = Field(..., description="Always API Activity.") - category_uid: CategoryUid = Field(..., description="OCSF category, Application Activity.") - category_name: str = Field(..., description="Always Application Activity.") + class_uid: ClassUid = Field( + ..., + description="OCSF class, API Activity, Process Activity or Network Activity.", + ) + class_name: str = Field(..., description="The name of class_uid, such as API Activity.") + category_uid: CategoryUid = Field( + ..., + description="OCSF category: Application Activity for an API call, System Activity for a program, Network Activity for a connection.", + ) + category_name: str = Field( + ..., description="The name of category_uid, such as Application Activity." + ) activity_id: ActivityId = Field( ..., - description="1 Create (a write, a started process, a terminal), 2 Read, 3 Update (a move), 4 Delete (a removed file, a killed process), 99 Other.", + description="Read with class_uid. For an API call: 1 Create (a write, a started process, a terminal), 2 Read, 3 Update (a move), 4 Delete (a removed file, a killed process), 99 Other. For a program: 1 Launch, 2 Terminate, 99 Other (the sandbox starting). For a connection: 1 Open.", + ) + activity_name: str = Field( + ..., + description="The name of activity_id within its class, such as Create or Launch.", + ) + type_uid: int = Field( + ..., description="class_uid * 100 + activity_id, such as 600301 or 100701." + ) + type_name: str = Field( + ..., description="Such as API Activity: Create or Process Activity: Launch." ) - activity_name: str = Field(..., description="The name of activity_id, such as Create.") - type_uid: int = Field(..., description="class_uid * 100 + activity_id, such as 600301.") - type_name: str = Field(..., description="Such as API Activity: Create.") time: int = Field( ..., description="When the call was recorded, in milliseconds since the Unix epoch.", @@ -1085,18 +1143,24 @@ class AuditEvent(BaseModel): ) status_detail: str | None = Field( None, - description="How a code-interpreter cell ended: `ok`, `error`, `deadline_exceeded`, `kernel_restarted`, `memory_exceeded`, `unavailable`, `invalid_response`, or `client_gone` when the caller disconnected first.", + description="How a code-interpreter cell ended: `ok`, `error`, `deadline_exceeded`, `kernel_restarted`, `memory_exceeded`, `unavailable`, `invalid_response`, or `client_gone` when the caller disconnected first. On a program that was stopped rather than ending on its own, such as one out of memory or told to stop, `signal ` names the signal that stopped it.", ) actor: AuditActor - api: AuditAPI + api: AuditAPI | None = None + process: AuditProcess | None = None + exit_code: int | None = Field( + None, + description="What a program returned when it ended. Zero means it succeeded. Present on a Process Activity Terminate event unless a signal stopped the program.", + ) + dst_endpoint: AuditDstEndpoint | None = None resources: list[AuditResource] | None = Field( None, description="What the call touched: the sandbox, then any file or process it named, the program it ran, and the terminal it ran in.", ) cloud: AuditCloud - src_endpoint: AuditEndpoint + src_endpoint: AuditEndpoint | None = None osint: list[dict[str, Any]] = Field( - ..., description="Threat intelligence about the call. Always empty." + ..., description="Threat intelligence about the event. Always empty." ) metadata: AuditMetadata diff --git a/src/neevai/resources/sandboxes.py b/src/neevai/resources/sandboxes.py index 8f7d4b0..5b9cf5b 100644 --- a/src/neevai/resources/sandboxes.py +++ b/src/neevai/resources/sandboxes.py @@ -356,8 +356,10 @@ def update( explicit ``egress`` in ``params`` takes precedence. ``egress_add`` / ``egress_remove`` edit the existing allow-list in place instead, leaving other rules untouched (removals apply first, so one call can swap a host); they cannot - be combined with ``egress`` or the convenience flags. At least one of - ``resources``, ``egress``, ``egress_add`` or ``egress_remove`` must result. + be combined with ``egress`` or the convenience flags. ``execution_trace=False`` + stops recording every program the sandbox runs (on by default); ``True`` turns + it back on. At least one of ``resources``, ``egress``, ``egress_add``, + ``egress_remove`` or ``execution_trace`` must result. """ from neevai.handles.sandbox import Sandbox @@ -840,8 +842,10 @@ async def update( explicit ``egress`` in ``params`` takes precedence. ``egress_add`` / ``egress_remove`` edit the existing allow-list in place instead, leaving other rules untouched (removals apply first, so one call can swap a host); they cannot - be combined with ``egress`` or the convenience flags. At least one of - ``resources``, ``egress``, ``egress_add`` or ``egress_remove`` must result. + be combined with ``egress`` or the convenience flags. ``execution_trace=False`` + stops recording every program the sandbox runs (on by default); ``True`` turns + it back on. At least one of ``resources``, ``egress``, ``egress_add``, + ``egress_remove`` or ``execution_trace`` must result. """ from neevai.handles.sandbox import AsyncSandbox diff --git a/tests/test_audit.py b/tests/test_audit.py index b121e3f..ec65c48 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -121,7 +121,7 @@ def _assert_trail(trail: AuditTrail) -> None: assert trail.next_cursor == "c2" assert [e.metadata.uid for e in trail.data] == ["e1", "e2"] first, second = trail.data - assert first.api.operation == "exec" + assert first.api is not None and first.api.operation == "exec" assert first.status == "Success" assert first.time == 1767268800000 assert first.actor.user.type == "api_key" @@ -305,3 +305,45 @@ async def test_async_handles_pass_filters(): assert dict(captured[0].url.params) == {"status": "success"} assert dict(captured[1].url.params) == {"credential_uid": "key-1"} await client.aclose() + + +def test_program_and_connection_events_validate() -> None: + unknown = {"type": "unknown", "type_id": 0, "uid": "unknown"} + launch = { + **_event("p1", "exec", 1, {"user": unknown}), + "class_uid": 1007, + "class_name": "Process Activity", + "category_uid": 1, + "category_name": "System Activity", + "activity_name": "Launch", + "type_uid": 100701, + "type_name": "Process Activity: Launch", + "process": { + "pid": 31, + "name": "curl", + "cwd": "/workspace", + "file": {"path": "/usr/bin/curl"}, + "parent_process": {"pid": 12}, + }, + } + connect = { + **_event("p2", "connect", 1, {"user": unknown, "process": {"pid": 31, "name": "curl"}}), + "class_uid": 4001, + "class_name": "Network Activity", + "category_uid": 4, + "category_name": "Network Activity", + "activity_name": "Open", + "type_uid": 400101, + "type_name": "Network Activity: Open", + "dst_endpoint": {"ip": "169.254.25.10", "port": 53}, + } + for e in (launch, connect): + del e["api"], e["src_endpoint"] + + trail = AuditTrail.model_validate({**TRAIL, "data": [launch, connect]}) + first, second = trail.data + assert first.api is None + assert first.process is not None and first.process.parent_process is not None + assert first.process.parent_process.pid == 12 + assert second.actor.process is not None and second.actor.process.name == "curl" + assert second.dst_endpoint is not None and second.dst_endpoint.port == 53 diff --git a/tests/test_sandboxes.py b/tests/test_sandboxes.py index a106caf..98af9f9 100644 --- a/tests/test_sandboxes.py +++ b/tests/test_sandboxes.py @@ -521,6 +521,19 @@ def test_sandboxes_update_resources_and_egress_single_patch(mock_transport): client.close() +def test_sandboxes_update_execution_trace_alone(mock_transport): + client = _make_client(mock_transport) + sb = client.sandboxes.create({"name": "s1", "sandbox_template_id": "sb-x"}) + captured = _capture_requests(client) + + client.sandboxes.update(sb.id, {"execution_trace": False}) + + method, _path, body = captured[-1] + assert method == "PATCH" + assert body == {"execution_trace": False} + client.close() + + def test_sandboxes_update_empty_raises_without_http(mock_transport): client = _make_client(mock_transport) sb = client.sandboxes.create({"name": "s1", "sandbox_template_id": "sb-x"})