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.
- package/CHANGELOG.md +78 -0
- package/README.md +12 -12
- package/dist/admin/{App-BLt4lqEM.js → App-CmbG2k9E.js} +110 -100
- package/dist/admin/{App-ou5hy99f.mjs → App-jWvaMXIw.mjs} +113 -103
- package/dist/admin/{en-BMlJxq3g.js → en-Bs4tlklI.js} +11 -9
- package/dist/admin/{en-B0rPiE2W.mjs → en-C3Og1VyK.mjs} +11 -9
- package/dist/admin/{index-DdmY-p3Q.mjs → index-BIIMJgKU.mjs} +3 -5
- package/dist/admin/{index-BYi8OPTw.js → index-BsVOGSDf.js} +3 -5
- package/dist/admin/index.js +1 -1
- package/dist/admin/index.mjs +1 -1
- package/dist/admin/src/components/AuditLogTable.d.ts +1 -2
- package/dist/admin/src/components/IntegrityStatus.d.ts +13 -0
- package/dist/admin/src/hooks/useAuditLogs.d.ts +14 -3
- package/dist/admin/src/permissions.d.ts +0 -8
- package/dist/admin/src/types.d.ts +26 -2
- package/dist/server/index.js +321 -94
- package/dist/server/index.mjs +321 -94
- package/dist/server/src/config/index.d.ts +1 -0
- package/dist/server/src/constants.d.ts +24 -2
- package/dist/server/src/content-types/audit-log/index.d.ts +8 -0
- package/dist/server/src/content-types/audit-log/schema.d.ts +21 -0
- package/dist/server/src/content-types/index.d.ts +8 -0
- package/dist/server/src/controllers/audit-log.d.ts +5 -3
- package/dist/server/src/controllers/index.d.ts +4 -4
- package/dist/server/src/index.d.ts +36 -7
- package/dist/server/src/register.d.ts +7 -3
- package/dist/server/src/routes/admin.d.ts +8 -0
- package/dist/server/src/services/audit.d.ts +0 -1
- package/dist/server/src/services/correlation.d.ts +35 -0
- package/dist/server/src/services/immutability.d.ts +5 -5
- package/dist/server/src/services/index.d.ts +21 -1
- package/dist/server/src/services/integrity.d.ts +69 -0
- package/dist/server/src/types/index.d.ts +54 -4
- package/package.json +10 -6
|
@@ -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>;
|
|
@@ -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
|
|
@@ -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
|
|
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
|
-
|
|
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<
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
154
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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-
|
|
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-
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
|
4
|
-
"description": "
|
|
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
|
|
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
|
|
113
|
+
"url": "https://github.com/Latheefwac/strapi-cms-audit-logs/issues"
|
|
110
114
|
},
|
|
111
|
-
"homepage": "https://github.com/Latheefwac/strapi-cms-audit-logs
|
|
115
|
+
"homepage": "https://github.com/Latheefwac/strapi-cms-audit-logs#readme"
|
|
112
116
|
}
|