This code is a data transformation script designed to process audit event objects—likely from a system like Bitbucket—and flatten, clean, and normalize the data for easier downstream consumption (e.g., indexing in Elasticsearch via Filebeat). Here's a breakdown of what it does:
Summary
This script:
- Normalizes audit data for indexing.
- Flattens nested arrays into key-value pairs.
- Cleans up irrelevant metadata.
- Formats timestamps and adds human-readable summaries.
It’s tailored for use with Filebeat and feeds into an Graylog for audit logging and analysis.
FileBeat Cloud
# Example framework of how you might use Filebeat with Atlassian Guard (adjust as necessary!)
#
# Set these via Sidecar variables (or edit file to remove variables and hardcode values)
#
# ${ORG_ID} e.g. 1234abcd-...
# ${ORG_API_KEY} Atlassian Organization API key
# ${GRAYLOG_HOST} e.g. graylog.example.com
# ${BEATS_PORT} e.g. 5044
filebeat.inputs:
- type: httpjson
id: atlassian_guard_stream
interval: 60s
request:
url: https://api.atlassian.com/admin/v1/orgs/${ORG_ID}/events-stream
method: GET
rate_limit:
limit: 10
burst: 1
period: 1m
retry:
max_attempts: 5
wait_min: 2s
wait_max: 60s
transforms:
- set:
target: header.Authorization
value: 'Bearer ${ORG_API_KEY}' # Guard uses API key as Bearer
- set: { target: header.Accept, value: "application/json" }
- set: { target: url.params.limit, value: "50" }
- set: { target: url.params.cursor,value: "[[.cursor.next]]" }
response: # 1) split into one event per item in data[]
split:
target: body.data
type: array
keep_parent: true # keep meta.next on the event for debugging
ignore_empty_value: true # persist meta.next so next poll starts where we left off
cursor:
next:
value: '[[.last_response.body.meta.next]]'
ignore_empty_value: true # can be helpful while testing – writes request/response bodies to a file
# request.tracer.enabled: true
# request.tracer.filename: /tmp/guard_http_trace.json
processors:
- script:
lang: javascript
id: guard_parse_and_flatten
source: >-
(insert transformation script here - see below)
# if we didn't extract an action, drop the event
- drop_event:
when:
not:
has_fields: ["guard.action","message"]
# Send to Graylog Beats input
output.logstash:
hosts: ["${GRAYLOG_HOST}:${BEATS_PORT}"]
Sample Transformation script in JavaScript
function process(event) {
event.Put("event_received_time", new Date());
function toCamelCase(str) {
return str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, function(match, chr) {
return chr.toUpperCase();
});
}
(event.Get("audit.affectedObjects") || []).forEach(function(o) {
if (o.type && o.name) {
event.Put("audit.affectedObject." + toCamelCase(o.type), o.name);
}
});
event.Delete("audit.affectedObjects");
(event.Get("audit.extraAttributes") || []).forEach(function(a) {
if (a.name && a.value && a.name !== "Load balancer/proxy IP address") {
event.Put("audit.extraAttribute." + toCamelCase(a.name), a.value);
}
});
event.Delete("audit.extraAttributes");
(event.Get("audit.changedValues") || []).forEach(function(c) {
if (!c.key) {
return;
}
var k = "audit.changedValue." + toCamelCase(c.key);
var d = c.from && c.to ? (c.from + " → " + c.to) : (c.to ||
(c.from ? "from " + c.from : ""));
if (d) {
event.Put(k, d);
}
});
event.Delete("audit.changedValues");
var ms = Number(event.Get("audit.timestamp.epochSecond") || 0) * 1000 + Math.floor(Number(event.Get("audit.timestamp.nano") || 0) / 1e6);
event.Put("@timestamp", new Date(ms));
event.Delete("audit.timestamp");
var author = event.Get("audit.author.name") || "";
event.Put("user_name", author);
event.Put("message",
((event.Get("audit.auditType.action") || "") + (author && author !== "Anonymous" ? " by " + author : "")));
// Clean up i18n keys and other unwanted fields
event.Delete("audit.auditType.actionI18nKey");
event.Delete("audit.auditType.categoryI18nKey");
event.Delete("audit.author.uri");
return event;
}
// Run 'filebeat -c filebeat.yml test config' to make sure script code is functioning as expected
function test() {
var sampleEvent = {
"audit": {
"auditType": {
"action": "Pull request participants changed",
"actionI18nKey": "bitbucket.service.pullrequest.audit.action.pullrequestparticipantsupdatedevent",
"area": "END_USER_ACTIVITY",
"category": "Pull requests",
"categoryI18nKey": "bitbucket.service.audit.category.pullrequests",
"level": "BASE"
},
"affectedObjects": [{
"id": "1234",
"name": "PR#123",
"type": "PULL_REQUEST"
},
{
"id": "5678",
"name": "testrepo",
"type": "REPOSITORY"
},
{
"id": "3445",
"name": "PROJ",
"type": "PROJECT"
}
],
"changedValues": [{
"i18nKey": "bitbucket.service.pullrequest.audit.changedvalue.pullrequestparticipantsupdated.participants",
"key": "Participants",
"to": "bitbucket.system-user"
}],
"extraAttributes": [{
"name": "From ref",
"nameI18nKey": "bitbucket.service.pullrequest.audit.attribute.pullrequest.fromref",
"value": "test-branch"
},
{
"name": "To ref",
"nameI18nKey": "bitbucket.service.pullrequest.audit.attribute.pullrequest.toref",
"value": "main"
},
{
"name": "Load balancer/proxy IP address",
"value": "10.1.2.3"
}
],
"author": {
"id": "-1",
"name": "System",
"type": "system"
},
"timestamp": {
"epochSecond": 1755786989,
"nano": 582000000
}
}
};
var event = process(new Event(sampleEvent));
// Test that i18n keys are removed
if (event.Get("audit.auditType.actionI18nKey") !== null) {
throw "expected auditType.actionI18nKey to be removed";
}
if (event.Get("audit.auditType.categoryI18nKey") !== null) {
throw "expected auditType.categoryI18nKey to be removed";
}
// Test that flattened fields are created
if (event.Get("audit.affectedObject.pullRequest") !== "PR#123") {
throw "expected audit.affectedObject.pullRequest to be 'PR#123'";
}
if (event.Get("audit.affectedObject.repository") !== "testrepo") {
throw "expected audit.affectedObject.repository to be 'testrepo'";
}
if (event.Get("audit.affectedObject.project") !== "PROJ") {
throw "expected audit.affectedObject.project to be 'PROJ'";
}
if (event.Get("audit.affectedObject.project") !== "PROJ") {
throw "expected audit.affectedObject.project to be 'PROJ'";
}
if (event.Get("audit.changedValue.participants") !== "bitbucket.system-user") {
throw "expected audit.changedValue.participants to be 'bitbucket.system-user'";
}
if (event.Get("audit.extraAttribute.fromRef") !== "test-branch") {
throw "expected audit.extraAttribute.fromRef to be 'test-branch'";
}
if (event.Get("audit.extraAttribute.toRef") !== "main") {
throw "expected audit.extraAttribute.toRef to be 'main'";
}
// Test that Load balancer IP is excluded
if (event.Get("audit.extraAttribute.loadBalancerProxyIpAddress") !== null) {
throw "expected Load balancer IP to be excluded";
}
// Test that original arrays are deleted (we have individual fields now)
if (event.Get("audit.affectedObjects") !== null) {
throw "expected audit.affectedObjects to be deleted";
}
if (event.Get("audit.changedValues") !== null) {
throw "expected audit.changedValues to be deleted";
}
if (event.Get("audit.extraAttributes") !== null) {
throw "expected audit.extraAttributes to be deleted";
}
}
FileBeat Datacenter
This below part processes a structured audit event object—likely from a system like Bitbucket—and transforms it into a flattened, cleaned-up format suitable for ingestion by tools like Filebeat, which might forward it to Graylog for indexing and analysis.
This script is a data normalization and enrichment utility for audit logs. It:
- Flattens nested structures.
- Standardizes field names.
- Adds useful metadata.
- Cleans up irrelevant or sensitive info. It’s designed to make audit events searchable, readable, and index-friendly - especially in systems like Graylog or ELK (Elasticsearch, Logstash, Kibana).
filebeat.inputs:
- type: filestream
id: atlassian-audit
enabled: true
encoding: utf-8
paths:
- "/opt/bitbucket/atlassian/bitbucket_home/log/audit/*.audit.log"
ignore_older: 24h # don't even bother processing log files older than 24 hours
ignore_inactive: since_last_start # read from files that have been updated since starting filebeat
clean_inactive: 168h # for log files older than 7 days, clean from tracking registry
fields_under_root: true
fields:
type: audit # add 'type' record indicating this is an audit record for Graylog Data Routing
application_name: bitbucket
deployment: production # or 'test', 'staging', etc.
parsers:
- ndjson: # parse each log line as JSON, and put the keys in an "audit" object
target: "audit"
add_error_key: true
expand_keys: true
output.logstash:
# Set via Sidecar variables, or edit file and embed hardcoded values
hosts: ["${GRAYLOG_HOST}:${BEATS_PORT}"]
processors:
- script:
lang: javascript # Note! Filebeat uses a subset of JavaScript based on the Goja interpreter
id: concatenate_fields_and_remove_i18n_keys_and_empty_arrays
source: >
(insert transformation script here - see below)
- drop_fields:
ignore_missing: true
fields:
- "agent"
- "ecs"
- "log"
- "input"
- "host.name"
setup: # don't load the ingest pipelines for Elasticsearch since we aren't using it
ilm.enabled: false
template.enabled: false
logging:
level: warning # only log warnings and errors; change to 'info' for debugging
to_files: true
files:
path: /var/log/filebeat
name: filebeat
keepfiles: 3
permissions: 0640
metrics.enabled: false # don't log Filebeat metrics
Sample Transformation script in JavaScript
function process(event) {
event.Put('event_received_time', new Date());
function toCamelCase(str) {
return str.toLowerCase().replace(/[^a-zA-Z0-9]+(.)/g, (match, chr) => chr.toUpperCase());
}
(event.Get('audit.affectedObjects') || []).forEach((o) => {
if (o.type && o.name) {
event.Put(`audit.affectedObject.${toCamelCase(o.type)}`, o.name);
}
});
event.Delete('audit.affectedObjects');
(event.Get('audit.extraAttributes') || []).forEach((a) => {
if (a.name && a.value && a.name !== 'Load balancer/proxy IP address') {
event.Put(`audit.extraAttribute.${toCamelCase(a.name)}`, a.value);
}
});
event.Delete('audit.extraAttributes');
(event.Get('audit.changedValues') || []).forEach((c) => {
if (!c.key) {
return;
}
const k = `audit.changedValue.${toCamelCase(c.key)}`;
const d = c.from && c.to ? (`${c.from} → ${c.to}`) : (c.to ||
(c.from ? `from ${c.from}` : ''));
if (d) {
event.Put(k, d);
}
});
event.Delete('audit.changedValues');
const ms = Number(event.Get('audit.timestamp.epochSecond') ||
0) * 1000 + Math.floor(Number(event.Get('audit.timestamp.nano') || 0) /
1e6);
event.Put('@timestamp', new Date(ms));
event.Delete('audit.timestamp');
const author = event.Get('audit.author.name') || '';
event.Put('user_name', author);
event.Put('message',
((event.Get('audit.auditType.action') || '') + (author && author !== 'Anonymous' ? ` by ${author}` : '')));
// Clean up i18n keys and other unwanted fields
event.Delete('audit.auditType.actionI18nKey');
event.Delete('audit.auditType.categoryI18nKey');
event.Delete('audit.author.uri');
return event;
}
// Run 'filebeat -c filebeat.yml test config' to make sure script code is functioning as expected
function test() {
const sampleEvent = {
audit: {
auditType: {
action: 'Pull request participants changed',
actionI18nKey: 'bitbucket.service.pullrequest.audit.action.pullrequestparticipantsupdatedevent',
area: 'END_USER_ACTIVITY',
category: 'Pull requests',
categoryI18nKey: 'bitbucket.service.audit.category.pullrequests',
level: 'BASE',
},
affectedObjects: [{
id: '1234',
name: 'PR#123',
type: 'PULL_REQUEST'
},
{
id: '5678',
name: 'testrepo',
type: 'REPOSITORY'
},
{
id: '3445',
name: 'PROJ',
type: 'PROJECT'
},
],
changedValues: [{
i18nKey: 'bitbucket.service.pullrequest.audit.changedvalue.pullrequestparticipantsupdated.participants',
key: 'Participants',
to: 'bitbucket.system-user',
}, ],
extraAttributes: [{
name: 'From ref',
nameI18nKey: 'bitbucket.service.pullrequest.audit.attribute.pullrequest.fromref',
value: 'test-branch',
},
{
name: 'To ref',
nameI18nKey: 'bitbucket.service.pullrequest.audit.attribute.pullrequest.toref',
value: 'main',
},
{
name: 'Load balancer/proxy IP address',
value: '10.1.2.3',
},
],
author: {
id: '-1',
name: 'System',
type: 'system',
},
timestamp: {
epochSecond: 1755786989,
nano: 582000000
},
},
};
const event = process(new Event(sampleEvent));
// Test that i18n keys are removed
if (event.Get('audit.auditType.actionI18nKey') !== null) {
throw 'expected auditType.actionI18nKey to be removed';
}
if (event.Get('audit.auditType.categoryI18nKey') !== null) {
throw 'expected auditType.categoryI18nKey to be removed';
}
// Test that flattened fields are created
if (event.Get('audit.affectedObject.pullRequest') !== 'PR#123') {
throw "expected audit.affectedObject.pullRequest to be 'PR#123'";
}
if (event.Get('audit.affectedObject.repository') !== 'testrepo') {
throw "expected audit.affectedObject.repository to be 'testrepo'";
}
if (event.Get('audit.affectedObject.project') !== 'PROJ') {
throw "expected audit.affectedObject.project to be 'PROJ'";
}
if (event.Get('audit.affectedObject.project') !== 'PROJ') {
throw "expected audit.affectedObject.project to be 'PROJ'";
}
if (event.Get('audit.changedValue.participants') !== 'bitbucket.system-user') {
throw "expected audit.changedValue.participants to be 'bitbucket.system-user'";
}
if (event.Get('audit.extraAttribute.fromRef') !== 'test-branch') {
throw "expected audit.extraAttribute.fromRef to be 'test-branch'";
}
if (event.Get('audit.extraAttribute.toRef') !== 'main') {
throw "expected audit.extraAttribute.toRef to be 'main'";
}
// Test that Load balancer IP is excluded
if (event.Get('audit.extraAttribute.loadBalancerProxyIpAddress') !== null) {
throw 'expected Load balancer IP to be excluded';
}
// Test that original arrays are deleted (we have individual fields now)
if (event.Get('audit.affectedObjects') !== null) {
throw 'expected audit.affectedObjects to be deleted';
}
if (event.Get('audit.changedValues') !== null) {
throw 'expected audit.changedValues to be deleted';
}
if (event.Get('audit.extraAttributes') !== null) {
throw 'expected audit.extraAttributes to be deleted';
}
}
Ref: Graylog Go 2025 Event - https://graylog.org/graylog-go/
https://app.zuddl.com/graylog/l/event/0ed76ccc-e143-4dfe-a693-9195db341937/booth/3d4ec2d3-06d0-49c9-ab9b-d772154b7792?idx=0
Top comments (0)