strapi-cms-audit-log 1.1.0 → 1.2.1

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +12 -12
  3. package/dist/admin/{App-BLt4lqEM.js → App-CmbG2k9E.js} +110 -100
  4. package/dist/admin/{App-ou5hy99f.mjs → App-jWvaMXIw.mjs} +113 -103
  5. package/dist/admin/{en-BMlJxq3g.js → en-Bs4tlklI.js} +11 -9
  6. package/dist/admin/{en-B0rPiE2W.mjs → en-C3Og1VyK.mjs} +11 -9
  7. package/dist/admin/{index-DdmY-p3Q.mjs → index-BIIMJgKU.mjs} +3 -5
  8. package/dist/admin/{index-BYi8OPTw.js → index-BsVOGSDf.js} +3 -5
  9. package/dist/admin/index.js +1 -1
  10. package/dist/admin/index.mjs +1 -1
  11. package/dist/admin/src/components/AuditLogTable.d.ts +1 -2
  12. package/dist/admin/src/components/IntegrityStatus.d.ts +13 -0
  13. package/dist/admin/src/hooks/useAuditLogs.d.ts +14 -3
  14. package/dist/admin/src/permissions.d.ts +0 -8
  15. package/dist/admin/src/types.d.ts +26 -2
  16. package/dist/server/index.js +321 -94
  17. package/dist/server/index.mjs +321 -94
  18. package/dist/server/src/config/index.d.ts +1 -0
  19. package/dist/server/src/constants.d.ts +24 -2
  20. package/dist/server/src/content-types/audit-log/index.d.ts +8 -0
  21. package/dist/server/src/content-types/audit-log/schema.d.ts +21 -0
  22. package/dist/server/src/content-types/index.d.ts +8 -0
  23. package/dist/server/src/controllers/audit-log.d.ts +5 -3
  24. package/dist/server/src/controllers/index.d.ts +4 -4
  25. package/dist/server/src/index.d.ts +36 -7
  26. package/dist/server/src/register.d.ts +7 -3
  27. package/dist/server/src/routes/admin.d.ts +8 -0
  28. package/dist/server/src/services/audit.d.ts +0 -1
  29. package/dist/server/src/services/correlation.d.ts +35 -0
  30. package/dist/server/src/services/immutability.d.ts +5 -5
  31. package/dist/server/src/services/index.d.ts +21 -1
  32. package/dist/server/src/services/integrity.d.ts +69 -0
  33. package/dist/server/src/types/index.d.ts +54 -4
  34. package/package.json +10 -6
@@ -39,6 +39,7 @@ declare const _default: {
39
39
  securityEvents: null;
40
40
  forwardToLogger: boolean;
41
41
  forwardLogLevel: string;
42
+ correlationId: boolean;
42
43
  };
43
44
  /**
44
45
  * Runs once at boot, before anything else touches the config. Throwing here
@@ -1,4 +1,4 @@
1
- import { AuditAction, AuditSecurityAction } from './types';
1
+ import { AuditAction, AuditMaintenanceAction, AuditSecurityAction } from './types';
2
2
  /** Plugin id. Must match `strapi.name` in package.json — it namespaces routes, permissions and config. */
3
3
  export declare const PLUGIN_ID = "audit-log";
4
4
  /** UID of the plugin's own collection type. */
@@ -7,7 +7,6 @@ export declare const ALL_ACTIONS: AuditAction[];
7
7
  /** RBAC action uids, as they appear once namespaced by the admin permission provider. */
8
8
  export declare const PERMISSIONS: {
9
9
  readonly read: "plugin::audit-log.read";
10
- readonly delete: "plugin::audit-log.delete";
11
10
  readonly settings: "plugin::audit-log.settings";
12
11
  };
13
12
  /**
@@ -47,6 +46,21 @@ export declare const DEFAULT_IGNORED_CHANGE_FIELDS: string[];
47
46
  * contain a field the editor never touched.
48
47
  */
49
48
  export declare const SKIPPED_ATTRIBUTES: Set<string>;
49
+ /**
50
+ * Response header the correlation middleware echoes the request id in.
51
+ *
52
+ * Echoed so the id a client (or a proxy access log) sees is the same one the
53
+ * audit record carries — otherwise correlation only works from the inside.
54
+ */
55
+ export declare const REQUEST_ID_RESPONSE_HEADER = "X-Request-Id";
56
+ /**
57
+ * Advisory-lock key for serialising hash-chain writes on PostgreSQL.
58
+ *
59
+ * Any constant works; this one is the CRC-32 of "strapi-audit-log-chain", chosen
60
+ * so it is stable across processes and unlikely to collide with a lock some
61
+ * other component picked by the same method.
62
+ */
63
+ export declare const CHAIN_LOCK_KEY = 1874206233;
50
64
  /** Headers checked, in order, for a correlation id. */
51
65
  export declare const REQUEST_ID_HEADERS: string[];
52
66
  /** Name under which the retention job is registered with `strapi.cron`. */
@@ -72,6 +86,12 @@ export declare const CONTENT_ACTIONS: AuditAction[];
72
86
  * available in Strapi **Community Edition** — none needs an Enterprise licence.
73
87
  */
74
88
  export declare const ALL_SECURITY_ACTIONS: AuditSecurityAction[];
89
+ /**
90
+ * Operations the plugin performs on its own table. Always recorded; there is no
91
+ * option to switch them off, because a retention purge that left no trace would
92
+ * be indistinguishable from someone deleting rows.
93
+ */
94
+ export declare const ALL_MAINTENANCE_ACTIONS: AuditMaintenanceAction[];
75
95
  /**
76
96
  * Pseudo content-type uids for events that are not about a content type.
77
97
  *
@@ -90,6 +110,8 @@ export declare const SUBJECTS: {
90
110
  readonly permission: "admin::permission";
91
111
  readonly file: "plugin::upload.file";
92
112
  readonly folder: "plugin::upload.folder";
113
+ /** The audit log itself, for records about its own maintenance. */
114
+ readonly auditLog: "admin::audit-log";
93
115
  };
94
116
  /** Human labels for {@link SUBJECTS}, snapshotted into `contentTypeDisplayName`. */
95
117
  export declare const SUBJECT_DISPLAY_NAMES: Record<string, string>;
@@ -76,6 +76,14 @@ declare const _default: {
76
76
  requestId: {
77
77
  type: string;
78
78
  };
79
+ hash: {
80
+ type: string;
81
+ maxLength: number;
82
+ };
83
+ prevHash: {
84
+ type: string;
85
+ maxLength: number;
86
+ };
79
87
  };
80
88
  indexes: {
81
89
  name: string;
@@ -121,6 +121,27 @@ declare const schema: {
121
121
  requestId: {
122
122
  type: string;
123
123
  };
124
+ /**
125
+ * Hash chain, for tamper-evidence (ASVS V7.3.3).
126
+ *
127
+ * `hash` is SHA-256 over this record's content fields plus `prevHash`, and
128
+ * `prevHash` is the `hash` of the row written immediately before. Editing any
129
+ * hashed field, or removing a row from the middle, breaks the link that the
130
+ * next row asserts — and `services/integrity.ts` can say exactly which row.
131
+ *
132
+ * Both are nullable: rows written before 1.2.0 have no hash and are never
133
+ * backfilled. Hashing them now would vouch for content whose integrity in
134
+ * the interval cannot be known, which is the opposite of what a hash is for.
135
+ * 64 lowercase hex characters when present.
136
+ */
137
+ hash: {
138
+ type: string;
139
+ maxLength: number;
140
+ };
141
+ prevHash: {
142
+ type: string;
143
+ maxLength: number;
144
+ };
124
145
  };
125
146
  /**
126
147
  * Secondary indexes, declared here so Strapi's schema sync owns them and they
@@ -77,6 +77,14 @@ declare const _default: {
77
77
  requestId: {
78
78
  type: string;
79
79
  };
80
+ hash: {
81
+ type: string;
82
+ maxLength: number;
83
+ };
84
+ prevHash: {
85
+ type: string;
86
+ maxLength: number;
87
+ };
80
88
  };
81
89
  indexes: {
82
90
  name: string;
@@ -14,18 +14,20 @@ type KoaContext = {
14
14
  * see `routes/admin.ts`. Nothing here re-implements authentication or
15
15
  * authorisation; the controller runs only once Strapi's own RBAC has said yes.
16
16
  *
17
- * There is deliberately no create or update handler. Audit records are written
18
- * by the plugin and read by humans; nothing in between.
17
+ * There is deliberately no create, update or delete handler. Audit records are
18
+ * written by the plugin and read by humans; nothing in between. Deletion was
19
+ * removed in 1.2.0 — see `routes/admin.ts` for why.
19
20
  */
20
21
  declare const auditLogController: ({ strapi }: {
21
22
  strapi: Core.Strapi;
22
23
  }) => {
23
24
  find(ctx: KoaContext): Promise<void>;
24
25
  findOne(ctx: KoaContext): Promise<unknown>;
25
- delete(ctx: KoaContext): Promise<unknown>;
26
26
  /** Filter dropdown options, derived from the rows that actually exist. */
27
27
  filters(ctx: KoaContext): Promise<void>;
28
28
  /** The effective configuration, so the UI can hide controls for disabled features. */
29
29
  config(ctx: KoaContext): Promise<void>;
30
+ /** Verifies the hash chain end to end. See `services/integrity.ts`. */
31
+ integrity(ctx: KoaContext): Promise<void>;
30
32
  };
31
33
  export default auditLogController;
@@ -16,21 +16,21 @@ declare const _default: {
16
16
  badRequest: (message: string) => unknown;
17
17
  notFound: (message?: string) => unknown;
18
18
  }): Promise<unknown>;
19
- delete(ctx: {
19
+ filters(ctx: {
20
20
  query: Record<string, unknown>;
21
21
  params: Record<string, string>;
22
22
  body: unknown;
23
23
  badRequest: (message: string) => unknown;
24
24
  notFound: (message?: string) => unknown;
25
- }): Promise<unknown>;
26
- filters(ctx: {
25
+ }): Promise<void>;
26
+ config(ctx: {
27
27
  query: Record<string, unknown>;
28
28
  params: Record<string, string>;
29
29
  body: unknown;
30
30
  badRequest: (message: string) => unknown;
31
31
  notFound: (message?: string) => unknown;
32
32
  }): Promise<void>;
33
- config(ctx: {
33
+ integrity(ctx: {
34
34
  query: Record<string, unknown>;
35
35
  params: Record<string, string>;
36
36
  body: unknown;
@@ -34,6 +34,7 @@ declare const _default: {
34
34
  securityEvents: null;
35
35
  forwardToLogger: boolean;
36
36
  forwardLogLevel: string;
37
+ correlationId: boolean;
37
38
  };
38
39
  validator(config: import('./types').AuditUserConfig & {
39
40
  enabled?: boolean;
@@ -118,6 +119,14 @@ declare const _default: {
118
119
  requestId: {
119
120
  type: string;
120
121
  };
122
+ hash: {
123
+ type: string;
124
+ maxLength: number;
125
+ };
126
+ prevHash: {
127
+ type: string;
128
+ maxLength: number;
129
+ };
121
130
  };
122
131
  indexes: {
123
132
  name: string;
@@ -144,21 +153,21 @@ declare const _default: {
144
153
  badRequest: (message: string) => unknown;
145
154
  notFound: (message?: string) => unknown;
146
155
  }): Promise<unknown>;
147
- delete(ctx: {
156
+ filters(ctx: {
148
157
  query: Record<string, unknown>;
149
158
  params: Record<string, string>;
150
159
  body: unknown;
151
160
  badRequest: (message: string) => unknown;
152
161
  notFound: (message?: string) => unknown;
153
- }): Promise<unknown>;
154
- filters(ctx: {
162
+ }): Promise<void>;
163
+ config(ctx: {
155
164
  query: Record<string, unknown>;
156
165
  params: Record<string, string>;
157
166
  body: unknown;
158
167
  badRequest: (message: string) => unknown;
159
168
  notFound: (message?: string) => unknown;
160
169
  }): Promise<void>;
161
- config(ctx: {
170
+ integrity(ctx: {
162
171
  query: Record<string, unknown>;
163
172
  params: Record<string, string>;
164
173
  body: unknown;
@@ -209,7 +218,6 @@ declare const _default: {
209
218
  flush: () => Promise<void>;
210
219
  find: (query?: import('./types').AuditLogQuery) => Promise<import('./types').AuditLogListResult>;
211
220
  findOne: (id: number) => Promise<import('./types').AuditLog | null>;
212
- deleteOne: (id: number) => Promise<import('./types').AuditLog | null>;
213
221
  deleteOlderThan: (date: Date) => Promise<number>;
214
222
  getFilterOptions: () => Promise<import('./types').AuditFilterOptions>;
215
223
  buildWhere: (query: import('./types').AuditLogQuery) => Record<string, unknown>;
@@ -233,6 +241,17 @@ declare const _default: {
233
241
  resolve: () => import('./types').AuditContext;
234
242
  runAs: <T>(override: import('./services/context').SourceOverride, callback: () => T) => T;
235
243
  };
244
+ correlation: ({ strapi }: {
245
+ strapi: import('@strapi/types/dist/core').Strapi;
246
+ }) => {
247
+ createMiddleware: () => (ctx: {
248
+ [x: string]: any;
249
+ }, next: () => Promise<unknown>) => Promise<void>;
250
+ register: () => void;
251
+ inboundId: (ctx: {
252
+ [x: string]: any;
253
+ }) => string | null;
254
+ };
236
255
  diff: ({ strapi }: {
237
256
  strapi: import('@strapi/types/dist/core').Strapi;
238
257
  }) => {
@@ -247,6 +266,16 @@ declare const _default: {
247
266
  }, next: () => Promise<unknown>) => Promise<unknown>;
248
267
  register: () => void;
249
268
  };
269
+ integrity: ({ strapi }: {
270
+ strapi: import('@strapi/types/dist/core').Strapi;
271
+ }) => {
272
+ computeHash: (record: {
273
+ [x: string]: any;
274
+ }) => string;
275
+ canonicalize: (value: unknown) => string;
276
+ verify: () => Promise<import('./types').IntegrityReport>;
277
+ HASHED_FIELDS: readonly ["action", "contentType", "contentTypeDisplayName", "contentDocumentId", "contentId", "locale", "userId", "userEmail", "userName", "source", "outcome", "ipAddress", "userAgent", "requestId", "metadata", "changes", "before", "after", "createdAt", "prevHash"];
278
+ };
250
279
  retention: ({ strapi }: {
251
280
  strapi: import('@strapi/types/dist/core').Strapi;
252
281
  }) => {
@@ -324,5 +353,5 @@ declare const _default: {
324
353
  };
325
354
  export default _default;
326
355
  /** Public type surface — see `server/src/types`. */
327
- export type { AuditAction, AuditActor, AuditAnyAction, AuditChange, AuditChangeSet, AuditConfig, AuditContext, AuditEntryInput, AuditFilterOptions, AuditLog, AuditLogListResult, AuditLogQuery, AuditLogLevel, AuditMetadata, AuditOutcome, AuditRequestContext, AuditSecurityAction, AuditSource, AuditUserConfig, AuditWriteMode, ContentTypeSelector, SecurityEventSelector, } from './types';
328
- export { PLUGIN_ID, AUDIT_LOG_UID, PERMISSIONS, DEFAULT_IGNORED_FIELDS, CONTENT_ACTIONS, ALL_SECURITY_ACTIONS, SECURITY_EVENT_MAP, SUBJECTS, } from './constants';
356
+ export type { AuditAction, AuditActor, AuditAnyAction, AuditChange, AuditChangeSet, AuditConfig, AuditContext, AuditEntryInput, AuditFilterOptions, AuditLog, AuditLogListResult, AuditLogQuery, AuditLogLevel, AuditMaintenanceAction, IntegrityReport, AuditMetadata, AuditOutcome, AuditRequestContext, AuditSecurityAction, AuditSource, AuditUserConfig, AuditWriteMode, ContentTypeSelector, SecurityEventSelector, } from './types';
357
+ export { PLUGIN_ID, AUDIT_LOG_UID, PERMISSIONS, DEFAULT_IGNORED_FIELDS, CONTENT_ACTIONS, ALL_SECURITY_ACTIONS, ALL_MAINTENANCE_ACTIONS, SECURITY_EVENT_MAP, SUBJECTS, } from './constants';
@@ -6,9 +6,13 @@ import { Core } from '@strapi/strapi';
6
6
  * the other plugins, and the provider namespaces each `uid` with the plugin name
7
7
  * — `read` becomes `plugin::audit-log.read`.
8
8
  *
9
- * Read and delete are separate on purpose: an auditor needs to see the trail, an
10
- * auditor must not be able to erase it, and collapsing the two into one
11
- * permission would make that distinction unexpressible.
9
+ * There is no `delete` action. Until 1.2.0 there was one, kept separate from
10
+ * `read` so that seeing the trail never implied being able to erase it; the
11
+ * assessment that followed pointed out, correctly, that a permission which
12
+ * *can* be granted will eventually be granted, and that a Super Admin holds
13
+ * every permission implicitly. The only way for no interactive role to be able
14
+ * to delete evidence is for the action not to exist. Strapi removes the stale
15
+ * grants from the database at the next boot.
12
16
  */
13
17
  declare const ACTIONS: {
14
18
  section: string;
@@ -6,6 +6,14 @@
6
6
  * and so on. There is no `type: 'content-api'` route file, and that is the
7
7
  * point: audit records are never reachable from the public content API, with or
8
8
  * without an API token, because no public route exists to reach them.
9
+ *
10
+ * There is no write route of any kind — no create, no update, and since 1.2.0
11
+ * no delete. An audit log that an administrator can prune is not evidence of
12
+ * what administrators did; ASVS V7.3.1 puts it plainly as "protected from
13
+ * unauthorized modification or deletion", and the surest way to have no
14
+ * unauthorised deletion is to have no deletion. The only thing that removes a
15
+ * row is the retention job, which runs off the request path, cannot be aimed
16
+ * at a specific record, and writes its own record of what it removed.
9
17
  */
10
18
  declare const _default: {
11
19
  type: string;
@@ -16,7 +16,6 @@ declare const auditService: ({ strapi }: {
16
16
  flush: () => Promise<void>;
17
17
  find: (query?: AuditLogQuery) => Promise<AuditLogListResult>;
18
18
  findOne: (id: number) => Promise<AuditLog | null>;
19
- deleteOne: (id: number) => Promise<AuditLog | null>;
20
19
  deleteOlderThan: (date: Date) => Promise<number>;
21
20
  getFilterOptions: () => Promise<AuditFilterOptions>;
22
21
  buildWhere: (query: AuditLogQuery) => Record<string, unknown>;
@@ -0,0 +1,35 @@
1
+ import { Core } from '@strapi/strapi';
2
+ type KoaContext = Record<string, any>;
3
+ /**
4
+ * Gives every request a correlation id.
5
+ *
6
+ * ASVS V7 wants each log event correlatable with the application and
7
+ * infrastructure logs around it. The plugin already recorded `requestId` when
8
+ * one arrived on a header, and the assessment found it null on ~90% of records —
9
+ * because the admin panel sends no such header, and neither does anything else
10
+ * unless a proxy in front of Strapi adds one. So nearly every record came in
11
+ * without.
12
+ *
13
+ * This middleware closes that: an inbound id is honoured (a proxy or a tracing
14
+ * system that already assigns one stays authoritative), and a missing one is
15
+ * minted. Either way it goes on `ctx.state.requestId`, which is what
16
+ * `context.ts`, `security.ts` and `access.ts` already read first, and it is
17
+ * echoed back as `X-Request-Id` so the client — and the proxy's access log —
18
+ * sees the same id the audit record carries. Correlation that only works from
19
+ * the inside is not correlation.
20
+ *
21
+ * Registered from the plugin's `register` lifecycle, ahead of the access
22
+ * middleware, for the same reason that one is: only there does it land in front
23
+ * of the router. See `access.ts` for the boot-order note.
24
+ *
25
+ * Records written outside a request — bootstrap, cron, a migration — still have
26
+ * no request id. There is no request to correlate them with.
27
+ */
28
+ declare const correlationService: ({ strapi }: {
29
+ strapi: Core.Strapi;
30
+ }) => {
31
+ createMiddleware: () => (ctx: KoaContext, next: () => Promise<unknown>) => Promise<void>;
32
+ register: () => void;
33
+ inboundId: (ctx: KoaContext) => string | null;
34
+ };
35
+ export default correlationService;
@@ -12,11 +12,11 @@ import { Core } from '@strapi/strapi';
12
12
  * `strapi.documents('plugin::audit-log.audit-log').update(...)` in a bootstrap
13
13
  * would otherwise succeed silently. This middleware makes that a loud failure.
14
14
  *
15
- * Deletion is not blocked here. It is a legitimate operation — retention runs on
16
- * it — and it is gated where it belongs: the only HTTP route that deletes
17
- * requires `plugin::audit-log.delete`. The plugin's own writes and deletes go
18
- * through `strapi.db.query`, below the Document Service, so this guard never
19
- * needs an escape hatch that an attacker could reach for.
15
+ * Deletion is blocked here as well since 1.2.0. The one legitimate deletion —
16
+ * retention — goes through `strapi.db.query`, below the Document Service, as do
17
+ * the plugin's own writes; so this guard never needs an escape hatch that an
18
+ * attacker could reach for, and a stray `strapi.documents(uid).delete()` in
19
+ * server code fails loudly instead of quietly removing evidence.
20
20
  */
21
21
  declare const immutabilityService: ({ strapi }: {
22
22
  strapi: Core.Strapi;
@@ -22,7 +22,6 @@ declare const _default: {
22
22
  flush: () => Promise<void>;
23
23
  find: (query?: import('..').AuditLogQuery) => Promise<import('..').AuditLogListResult>;
24
24
  findOne: (id: number) => Promise<import('..').AuditLog | null>;
25
- deleteOne: (id: number) => Promise<import('..').AuditLog | null>;
26
25
  deleteOlderThan: (date: Date) => Promise<number>;
27
26
  getFilterOptions: () => Promise<import('..').AuditFilterOptions>;
28
27
  buildWhere: (query: import('..').AuditLogQuery) => Record<string, unknown>;
@@ -46,6 +45,17 @@ declare const _default: {
46
45
  resolve: () => import('..').AuditContext;
47
46
  runAs: <T>(override: import('./context').SourceOverride, callback: () => T) => T;
48
47
  };
48
+ correlation: ({ strapi }: {
49
+ strapi: import('@strapi/types/dist/core').Strapi;
50
+ }) => {
51
+ createMiddleware: () => (ctx: {
52
+ [x: string]: any;
53
+ }, next: () => Promise<unknown>) => Promise<void>;
54
+ register: () => void;
55
+ inboundId: (ctx: {
56
+ [x: string]: any;
57
+ }) => string | null;
58
+ };
49
59
  diff: ({ strapi }: {
50
60
  strapi: import('@strapi/types/dist/core').Strapi;
51
61
  }) => {
@@ -60,6 +70,16 @@ declare const _default: {
60
70
  }, next: () => Promise<unknown>) => Promise<unknown>;
61
71
  register: () => void;
62
72
  };
73
+ integrity: ({ strapi }: {
74
+ strapi: import('@strapi/types/dist/core').Strapi;
75
+ }) => {
76
+ computeHash: (record: {
77
+ [x: string]: any;
78
+ }) => string;
79
+ canonicalize: (value: unknown) => string;
80
+ verify: () => Promise<import('..').IntegrityReport>;
81
+ HASHED_FIELDS: readonly ["action", "contentType", "contentTypeDisplayName", "contentDocumentId", "contentId", "locale", "userId", "userEmail", "userName", "source", "outcome", "ipAddress", "userAgent", "requestId", "metadata", "changes", "before", "after", "createdAt", "prevHash"];
82
+ };
63
83
  retention: ({ strapi }: {
64
84
  strapi: import('@strapi/types/dist/core').Strapi;
65
85
  }) => {
@@ -0,0 +1,69 @@
1
+ import { Core } from '@strapi/strapi';
2
+ import { IntegrityReport } from '../types';
3
+ type Row = Record<string, any>;
4
+ /**
5
+ * The fields a record's hash covers.
6
+ *
7
+ * Everything that carries meaning, and nothing that the database owns. `id` is
8
+ * left out because it is not known until after the insert and the hash has to
9
+ * be computed before; `updatedAt` because the row is never updated; `hash`
10
+ * itself for the obvious reason. `createdAt` *is* included — the plugin sets it
11
+ * explicitly for exactly this purpose — so that back-dating a record is as
12
+ * detectable as changing its content.
13
+ */
14
+ export declare const HASHED_FIELDS: readonly ["action", "contentType", "contentTypeDisplayName", "contentDocumentId", "contentId", "locale", "userId", "userEmail", "userName", "source", "outcome", "ipAddress", "userAgent", "requestId", "metadata", "changes", "before", "after", "createdAt", "prevHash"];
15
+ /**
16
+ * JSON with keys sorted at every depth.
17
+ *
18
+ * A JSON column does not promise to hand back keys in the order they were
19
+ * written — PostgreSQL's `jsonb` reorders them — so hashing `JSON.stringify` of
20
+ * a snapshot would produce a digest that changes between the write and the
21
+ * read-back with nothing having changed. Sorting makes the serialisation a
22
+ * function of the value alone. `Date` becomes its ISO string for the same
23
+ * reason: the driver returns one on read, the plugin holds the other on write.
24
+ * `undefined` and `null` collapse together, since a JSON round-trip cannot tell
25
+ * them apart either.
26
+ */
27
+ export declare const canonicalize: (value: unknown) => string;
28
+ /**
29
+ * Tamper-evidence for the audit table, as a hash chain.
30
+ *
31
+ * ## What it proves
32
+ *
33
+ * Each record stores `hash = sha256(canonical(fields) + prevHash)` where
34
+ * `prevHash` is the hash of the record before it. Three things follow:
35
+ *
36
+ * - **Edit** any hashed field of any record and its own digest no longer
37
+ * matches: caught at that row.
38
+ * - **Delete** a record from the middle and the next record's `prevHash` points
39
+ * at a hash that no longer exists: caught at the row after the gap.
40
+ * - **Insert** a record into the middle and the same thing happens, from the
41
+ * other direction.
42
+ *
43
+ * What it does not prove is that the *latest* record was not removed — a chain
44
+ * has no way to know its own intended length. That is what forwarding to an
45
+ * external collector is for; the two controls cover each other's blind spot.
46
+ *
47
+ * ## The one deletion that is allowed
48
+ *
49
+ * Retention removes rows from the *start* of the chain, which leaves the oldest
50
+ * surviving row pointing at a predecessor that is legitimately gone. The walk
51
+ * treats that row as the chain's start and does not attempt to check its
52
+ * `prevHash`; the purge itself writes a `retention.purge` record, so the trail
53
+ * says when and how much was removed.
54
+ *
55
+ * ## Rows from before the chain
56
+ *
57
+ * Records written before 1.2.0 have `hash: null`. They are counted, reported as
58
+ * `legacy`, and never linked — not backfilled, because a hash computed today
59
+ * over a row written last week vouches for nothing about last week.
60
+ */
61
+ declare const integrityService: ({ strapi }: {
62
+ strapi: Core.Strapi;
63
+ }) => {
64
+ computeHash: (record: Row) => string;
65
+ canonicalize: (value: unknown) => string;
66
+ verify: () => Promise<IntegrityReport>;
67
+ HASHED_FIELDS: readonly ["action", "contentType", "contentTypeDisplayName", "contentDocumentId", "contentId", "locale", "userId", "userEmail", "userName", "source", "outcome", "ipAddress", "userAgent", "requestId", "metadata", "changes", "before", "after", "createdAt", "prevHash"];
68
+ };
69
+ export default integrityService;
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Public type surface of the plugin.
3
3
  *
4
- * Re-exported from `strapi-plugin-audit-log/strapi-server`, so a consuming
4
+ * Re-exported from `strapi-cms-audit-log/strapi-server`, so a consuming
5
5
  * application can type its own code against the same shapes the plugin uses:
6
6
  *
7
7
  * ```ts
8
- * import type { AuditLog, AuditConfig } from 'strapi-plugin-audit-log/strapi-server';
8
+ * import type { AuditLog, AuditConfig } from 'strapi-cms-audit-log/strapi-server';
9
9
  * ```
10
10
  *
11
11
  * The admin panel keeps its own copies of the few shared unions in
@@ -25,8 +25,17 @@ export type AuditAction = 'create' | 'update' | 'delete' | 'publish' | 'unpublis
25
25
  * groups the authentication trail without a second column.
26
26
  */
27
27
  export type AuditSecurityAction = 'login.success' | 'login.failed' | 'logout' | 'access.denied' | 'admin.user.create' | 'admin.user.update' | 'admin.user.delete' | 'admin.role.create' | 'admin.role.update' | 'admin.role.delete' | 'admin.permission.create' | 'admin.permission.update' | 'admin.permission.delete' | 'media.create' | 'media.update' | 'media.delete' | 'media-folder.create' | 'media-folder.update' | 'media-folder.delete';
28
- /** Every action the plugin can write, across both families. */
29
- export type AuditAnyAction = AuditAction | AuditSecurityAction;
28
+ /**
29
+ * Operations the plugin performs on *itself*.
30
+ *
31
+ * There is exactly one: the retention job removing expired rows. It is recorded
32
+ * so that the only deletion the log permits leaves a trace of itself — how many
33
+ * rows, older than what, when — which is what turns "we have retention" into
34
+ * something an assessor can check against the row count.
35
+ */
36
+ export type AuditMaintenanceAction = 'retention.purge';
37
+ /** Every action the plugin can write, across all three families. */
38
+ export type AuditAnyAction = AuditAction | AuditSecurityAction | AuditMaintenanceAction;
30
39
  /**
31
40
  * Whether the recorded attempt succeeded.
32
41
  *
@@ -116,6 +125,15 @@ export interface AuditLog extends AuditContext {
116
125
  outcome: AuditOutcome | null;
117
126
  /** Action-specific detail — see {@link AuditMetadata}. */
118
127
  metadata: AuditMetadata | null;
128
+ /**
129
+ * SHA-256 over this record's hashed fields and `prevHash`, as lowercase hex.
130
+ *
131
+ * `null` on rows written before 1.2.0, which predate the chain. See
132
+ * `services/integrity.ts` for what is hashed and why.
133
+ */
134
+ hash: string | null;
135
+ /** The `hash` of the record written immediately before this one. */
136
+ prevHash: string | null;
119
137
  createdAt: string;
120
138
  updatedAt: string;
121
139
  }
@@ -151,6 +169,38 @@ export interface AuditConfig {
151
169
  forwardToLogger: boolean;
152
170
  /** Level the mirrored line is written at. */
153
171
  forwardLogLevel: AuditLogLevel;
172
+ /**
173
+ * Mint a request id for requests that arrive without one, and echo it back as
174
+ * `X-Request-Id`. Every request-borne record then carries a correlation id.
175
+ */
176
+ correlationId: boolean;
177
+ }
178
+ /** Result of walking the hash chain. */
179
+ export interface IntegrityReport {
180
+ /** True when every hashed record links to its predecessor and matches its own digest. */
181
+ ok: boolean;
182
+ /** Rows examined, hashed or not. */
183
+ checked: number;
184
+ /** Rows that carry a hash and were verified. */
185
+ hashed: number;
186
+ /** Rows written before the chain existed. Counted, never linked. */
187
+ legacy: number;
188
+ /** The most recent record, which is where the next write will link from. */
189
+ head: {
190
+ id: number;
191
+ hash: string;
192
+ } | null;
193
+ /** The first hashed record — its `prevHash` points at something retention may have removed. */
194
+ start: {
195
+ id: number;
196
+ } | null;
197
+ /** First record that failed verification, with the reason. Absent when `ok`. */
198
+ brokenAt?: {
199
+ id: number;
200
+ reason: string;
201
+ };
202
+ /** When the walk ran, so a report can be dated. */
203
+ verifiedAt: string;
154
204
  }
155
205
  /** Levels `strapi.log` exposes that make sense for an audit mirror. */
156
206
  export type AuditLogLevel = 'debug' | 'info' | 'warn' | 'error';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "strapi-cms-audit-log",
3
- "version": "1.1.0",
4
- "description": "Audit logging for Strapi v5. Content create/update/delete/publish/unpublish with field-level diffs and a per-widget before/after view of dynamic zones, plus logins, failed logins, logouts, denied requests and admin user/role/permission/media changes — all from Strapi Community Edition events. RBAC-protected admin UI, redaction, retention and structured-log forwarding.",
3
+ "version": "1.2.1",
4
+ "description": "Tamper-evident audit logging for Strapi v5. Content create/update/delete/publish/unpublish with field-level diffs and a per-widget before/after view, plus logins, failed logins, logouts, denied requests and admin user/role/permission/media changes — all from Community Edition events. Hash-chained, undeletable, correlation ids on every request, RBAC-protected admin UI, redaction, self-audited retention and structured-log forwarding. Built to pass an OWASP ASVS L2 (V7) review.",
5
5
  "keywords": [
6
6
  "strapi",
7
7
  "plugin",
@@ -16,7 +16,11 @@
16
16
  "security",
17
17
  "login",
18
18
  "siem",
19
- "vapt"
19
+ "vapt",
20
+ "asvs",
21
+ "tamper-evident",
22
+ "hash-chain",
23
+ "integrity"
20
24
  ],
21
25
  "license": "MIT",
22
26
  "author": "Latheefwac",
@@ -96,7 +100,7 @@
96
100
  "description": "Automatic audit logging with field-level diffs and an RBAC-protected admin UI."
97
101
  },
98
102
  "engines": {
99
- "node": ">=18.0.0 <=22.x.x"
103
+ "node": ">=18.0.0"
100
104
  },
101
105
  "publishConfig": {
102
106
  "access": "public"
@@ -106,7 +110,7 @@
106
110
  "url": "git+https://github.com/Latheefwac/strapi-cms-audit-logs.git"
107
111
  },
108
112
  "bugs": {
109
- "url": "https://github.com/Latheefwac/strapi-cms-audit-logs.git/issues"
113
+ "url": "https://github.com/Latheefwac/strapi-cms-audit-logs/issues"
110
114
  },
111
- "homepage": "https://github.com/Latheefwac/strapi-cms-audit-logs.git#readme"
115
+ "homepage": "https://github.com/Latheefwac/strapi-cms-audit-logs#readme"
112
116
  }