@absolutejs/vulnerabilities-postgres 0.5.3 → 0.6.0
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/README.md +7 -2
- package/dist/alerts.d.ts +146 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +335 -4
- package/dist/manifest.js +2 -2
- package/dist/manifest.json +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -11,15 +11,20 @@ const persistence = createPostgresVulnerabilityStore({
|
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
const osvSnapshots = persistence.snapshots();
|
|
14
|
+
const alertPolicies = persistence.alertPolicies;
|
|
15
|
+
const alertIncidents = persistence.alertIncidents;
|
|
14
16
|
```
|
|
15
17
|
|
|
16
18
|
The package stores complete provider snapshots and records, appendable sync
|
|
17
19
|
history, tenant-scoped managed findings, correlation observations, VEX decisions
|
|
18
|
-
and applications, remediation plans and evidence, risk assessments,
|
|
20
|
+
and applications, remediation plans and evidence, risk assessments, immutable
|
|
21
|
+
alert-policy versions, incident timelines, and leased notification deliveries.
|
|
22
|
+
Snapshot replacement is
|
|
19
23
|
one Postgres statement, so readers never see a half-replaced record set. Schema
|
|
20
24
|
creation is lazy and idempotent, or can be disabled when application migrations
|
|
21
25
|
own the tables.
|
|
22
26
|
|
|
23
27
|
The SQL surface is compatible with postgres.js and Neon-style tagged-template
|
|
24
28
|
clients. Table prefixes are strictly validated before identifiers are included
|
|
25
|
-
in SQL.
|
|
29
|
+
in SQL. Alert lifecycle mutations require a client with a `begin` transaction
|
|
30
|
+
method, such as postgres.js or Bun SQL.
|
package/dist/alerts.d.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { type VulnerabilityAlert, type VulnerabilityAlertConfiguration } from "@absolutejs/vulnerabilities";
|
|
2
|
+
export type AlertPostgresTag = {
|
|
3
|
+
<T = unknown>(strings: TemplateStringsArray, ...values: unknown[]): PromiseLike<T[]>;
|
|
4
|
+
begin?: <T>(callback: (sql: AlertPostgresTag) => Promise<T>) => Promise<T>;
|
|
5
|
+
unsafe: (sql: string, ...args: never[]) => PromiseLike<unknown[]>;
|
|
6
|
+
};
|
|
7
|
+
export type VulnerabilityAlertPolicyVersion = {
|
|
8
|
+
activated_at: Date;
|
|
9
|
+
activated_by: string;
|
|
10
|
+
configuration: VulnerabilityAlertConfiguration;
|
|
11
|
+
reason: string;
|
|
12
|
+
status: "active" | "superseded";
|
|
13
|
+
tenant_id: string;
|
|
14
|
+
version: number;
|
|
15
|
+
};
|
|
16
|
+
export type ActiveVulnerabilityAlertPolicy = {
|
|
17
|
+
configuration: VulnerabilityAlertConfiguration;
|
|
18
|
+
tenantId: string;
|
|
19
|
+
version: number;
|
|
20
|
+
};
|
|
21
|
+
export type VulnerabilityAlertIncidentRow = {
|
|
22
|
+
acknowledged_at: Date | null;
|
|
23
|
+
acknowledged_by: string | null;
|
|
24
|
+
alert_id: string;
|
|
25
|
+
asset_id: string | null;
|
|
26
|
+
body: string;
|
|
27
|
+
due_at: Date | null;
|
|
28
|
+
finding_id: string | null;
|
|
29
|
+
first_observed_at: Date;
|
|
30
|
+
kind: VulnerabilityAlert["kind"];
|
|
31
|
+
last_observed_at: Date;
|
|
32
|
+
next_escalation_at: Date | null;
|
|
33
|
+
observation_count: number;
|
|
34
|
+
occurrence_count: number;
|
|
35
|
+
plan_id: string | null;
|
|
36
|
+
policy_version: number | null;
|
|
37
|
+
resolved_at: Date | null;
|
|
38
|
+
severity: VulnerabilityAlert["severity"];
|
|
39
|
+
source_id: string | null;
|
|
40
|
+
status: "acknowledged" | "open" | "resolved";
|
|
41
|
+
tenant_id: string;
|
|
42
|
+
title: string;
|
|
43
|
+
updated_at: Date;
|
|
44
|
+
value: VulnerabilityAlert;
|
|
45
|
+
};
|
|
46
|
+
export type VulnerabilityAlertEventRow = {
|
|
47
|
+
actor_id: string | null;
|
|
48
|
+
alert_id: string;
|
|
49
|
+
created_at: Date;
|
|
50
|
+
id: number;
|
|
51
|
+
kind: "acknowledged" | "alert_dismissed" | "alert_retried" | "delivery_failed" | "delivered" | "escalated" | "observed" | "opened" | "resolved";
|
|
52
|
+
metadata: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
export type VulnerabilityAlertDeliveryRow = {
|
|
55
|
+
alert_id: string;
|
|
56
|
+
attempt_count: number;
|
|
57
|
+
audience: "admin" | "owner";
|
|
58
|
+
created_at: Date;
|
|
59
|
+
delivered_at: Date | null;
|
|
60
|
+
id: string;
|
|
61
|
+
idempotency_key: string;
|
|
62
|
+
kind: "escalated" | "opened" | "resolved";
|
|
63
|
+
last_error: string | null;
|
|
64
|
+
lease_expires_at: Date | null;
|
|
65
|
+
next_attempt_at: Date;
|
|
66
|
+
state: "delivered" | "delivering" | "dismissed" | "failed" | "pending";
|
|
67
|
+
updated_at: Date;
|
|
68
|
+
};
|
|
69
|
+
export type ClaimedVulnerabilityAlertDelivery = VulnerabilityAlertDeliveryRow & {
|
|
70
|
+
asset_id: string | null;
|
|
71
|
+
body: string;
|
|
72
|
+
severity: VulnerabilityAlert["severity"];
|
|
73
|
+
tenant_id: string;
|
|
74
|
+
title: string;
|
|
75
|
+
};
|
|
76
|
+
export declare class VulnerabilityAlertConflictError extends Error {
|
|
77
|
+
}
|
|
78
|
+
export declare class VulnerabilityAlertNotFoundError extends Error {
|
|
79
|
+
}
|
|
80
|
+
export type VulnerabilityAlertPolicyStore = {
|
|
81
|
+
activate: (input: {
|
|
82
|
+
activatedAt: string;
|
|
83
|
+
activatedBy: string;
|
|
84
|
+
configuration: VulnerabilityAlertConfiguration;
|
|
85
|
+
reason: string;
|
|
86
|
+
tenantId: string;
|
|
87
|
+
}) => Promise<ActiveVulnerabilityAlertPolicy & {
|
|
88
|
+
activatedAt: string;
|
|
89
|
+
reason: string;
|
|
90
|
+
}>;
|
|
91
|
+
active: () => Promise<ActiveVulnerabilityAlertPolicy[]>;
|
|
92
|
+
list: (limit?: number) => Promise<VulnerabilityAlertPolicyVersion[]>;
|
|
93
|
+
};
|
|
94
|
+
export type VulnerabilityAlertIncidentStore = {
|
|
95
|
+
acknowledge: (input: {
|
|
96
|
+
acknowledgedAt: string;
|
|
97
|
+
acknowledgedBy: string;
|
|
98
|
+
alertId: string;
|
|
99
|
+
}) => Promise<boolean>;
|
|
100
|
+
claimDeliveries: (input?: {
|
|
101
|
+
leaseMs?: number;
|
|
102
|
+
limit?: number;
|
|
103
|
+
tenantIds?: readonly string[];
|
|
104
|
+
}) => Promise<ClaimedVulnerabilityAlertDelivery[]>;
|
|
105
|
+
completeDelivery: (input: {
|
|
106
|
+
alertId: string;
|
|
107
|
+
deliveryId: string;
|
|
108
|
+
audience: "admin" | "owner";
|
|
109
|
+
kind: "escalated" | "opened" | "resolved";
|
|
110
|
+
}) => Promise<boolean>;
|
|
111
|
+
deliveries: (limit?: number) => Promise<VulnerabilityAlertDeliveryRow[]>;
|
|
112
|
+
events: (limit?: number) => Promise<VulnerabilityAlertEventRow[]>;
|
|
113
|
+
failDelivery: (input: {
|
|
114
|
+
alertId: string;
|
|
115
|
+
deliveryId: string;
|
|
116
|
+
error: string;
|
|
117
|
+
kind: "escalated" | "opened" | "resolved";
|
|
118
|
+
retryAt: string;
|
|
119
|
+
}) => Promise<boolean>;
|
|
120
|
+
incidents: (limit?: number) => Promise<VulnerabilityAlertIncidentRow[]>;
|
|
121
|
+
observe: (input: {
|
|
122
|
+
alert: VulnerabilityAlert;
|
|
123
|
+
policy: ActiveVulnerabilityAlertPolicy;
|
|
124
|
+
observedAt?: string;
|
|
125
|
+
}) => Promise<"observed" | "opened" | "reopened">;
|
|
126
|
+
processEscalations: (input: {
|
|
127
|
+
policies: ReadonlyMap<string, ActiveVulnerabilityAlertPolicy>;
|
|
128
|
+
tenantIds?: readonly string[];
|
|
129
|
+
}) => Promise<number>;
|
|
130
|
+
resolveInactive: (input: {
|
|
131
|
+
activeAlertIds: ReadonlySet<string>;
|
|
132
|
+
policies: ReadonlyMap<string, ActiveVulnerabilityAlertPolicy>;
|
|
133
|
+
resolvedAt?: string;
|
|
134
|
+
tenantIds?: readonly string[];
|
|
135
|
+
}) => Promise<number>;
|
|
136
|
+
};
|
|
137
|
+
export declare const vulnerabilityAlertPostgresSchemaSql: (tablePrefix?: string) => string;
|
|
138
|
+
export declare const createPostgresVulnerabilityAlertStores: (options: {
|
|
139
|
+
ensureSchema?: boolean;
|
|
140
|
+
sql: AlertPostgresTag;
|
|
141
|
+
tablePrefix?: string;
|
|
142
|
+
}) => {
|
|
143
|
+
alertIncidents: VulnerabilityAlertIncidentStore;
|
|
144
|
+
alertPolicies: VulnerabilityAlertPolicyStore;
|
|
145
|
+
ensureAlertSchema: () => Promise<void>;
|
|
146
|
+
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { type FeedSnapshotStore, type FeedSyncRunStore, type ManagedFindingStore, type RemediationExecutionStore, type RemediationPlanStore, type RemediationVerificationStore, type VulnerabilityObservationStore, type VulnerabilityRiskAssessmentStore, type VexDecisionStore, type VexFindingApplicationStore } from "@absolutejs/vulnerabilities";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
};
|
|
2
|
+
import { type AlertPostgresTag, type VulnerabilityAlertIncidentStore, type VulnerabilityAlertPolicyStore } from "./alerts";
|
|
3
|
+
export * from "./alerts";
|
|
4
|
+
export type PostgresTag = AlertPostgresTag;
|
|
6
5
|
export type FeedLeaseRequest = {
|
|
7
6
|
feedId: string;
|
|
8
7
|
now?: Date;
|
|
@@ -14,6 +13,8 @@ export type FeedLeaseStore = {
|
|
|
14
13
|
release: (feedId: string, ownerId: string) => Promise<boolean>;
|
|
15
14
|
};
|
|
16
15
|
export type PostgresVulnerabilityStore = {
|
|
16
|
+
alertIncidents: VulnerabilityAlertIncidentStore;
|
|
17
|
+
alertPolicies: VulnerabilityAlertPolicyStore;
|
|
17
18
|
ensureSchema: () => Promise<void>;
|
|
18
19
|
findings: ManagedFindingStore;
|
|
19
20
|
leases: FeedLeaseStore;
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,327 @@ import {
|
|
|
11
11
|
VexFindingApplicationSchema
|
|
12
12
|
} from "@absolutejs/vulnerabilities";
|
|
13
13
|
import { Value } from "@sinclair/typebox/value";
|
|
14
|
+
|
|
15
|
+
// src/alerts.ts
|
|
16
|
+
import {
|
|
17
|
+
resolveVulnerabilityAlertAudiences,
|
|
18
|
+
validateVulnerabilityAlertConfiguration
|
|
19
|
+
} from "@absolutejs/vulnerabilities";
|
|
20
|
+
|
|
21
|
+
class VulnerabilityAlertConflictError extends Error {
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
class VulnerabilityAlertNotFoundError extends Error {
|
|
25
|
+
}
|
|
14
26
|
var IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
27
|
+
var tables = (prefix) => ({
|
|
28
|
+
deliveries: `${prefix}_alert_deliveries`,
|
|
29
|
+
events: `${prefix}_alert_events`,
|
|
30
|
+
incidents: `${prefix}_alert_incidents`,
|
|
31
|
+
policies: `${prefix}_alert_policy_versions`
|
|
32
|
+
});
|
|
33
|
+
var boundedLimit = (value = 1000) => {
|
|
34
|
+
if (!Number.isInteger(value) || value < 1 || value > 1000)
|
|
35
|
+
throw new Error("Alert query limit must be an integer from 1 through 1000");
|
|
36
|
+
return value;
|
|
37
|
+
};
|
|
38
|
+
var transaction = async (sql, callback) => {
|
|
39
|
+
if (!sql.begin)
|
|
40
|
+
throw new Error("Vulnerability alert persistence requires a transaction-capable Postgres tag");
|
|
41
|
+
return sql.begin(callback);
|
|
42
|
+
};
|
|
43
|
+
var tenantPredicate = (tenantIds) => tenantIds && tenantIds.length > 0 ? JSON.stringify(tenantIds) : null;
|
|
44
|
+
var vulnerabilityAlertPostgresSchemaSql = (tablePrefix = "vulnerability") => {
|
|
45
|
+
if (!IDENTIFIER.test(tablePrefix))
|
|
46
|
+
throw new Error(`[vulnerabilities-postgres] invalid tablePrefix "${tablePrefix}"; must match ${IDENTIFIER.source}`);
|
|
47
|
+
const table = tables(tablePrefix);
|
|
48
|
+
return `
|
|
49
|
+
CREATE TABLE IF NOT EXISTS ${table.policies} (
|
|
50
|
+
activated_at timestamptz NOT NULL,
|
|
51
|
+
activated_by uuid NOT NULL,
|
|
52
|
+
configuration jsonb NOT NULL,
|
|
53
|
+
reason text NOT NULL,
|
|
54
|
+
status text NOT NULL CHECK (status IN ('active', 'superseded')),
|
|
55
|
+
tenant_id text NOT NULL,
|
|
56
|
+
version integer NOT NULL,
|
|
57
|
+
PRIMARY KEY (tenant_id, version)
|
|
58
|
+
);
|
|
59
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${table.policies}_active_idx ON ${table.policies} (tenant_id) WHERE status = 'active';
|
|
60
|
+
CREATE INDEX IF NOT EXISTS ${table.policies}_history_idx ON ${table.policies} (tenant_id, activated_at);
|
|
61
|
+
CREATE TABLE IF NOT EXISTS ${table.incidents} (
|
|
62
|
+
acknowledged_at timestamptz,
|
|
63
|
+
acknowledged_by uuid,
|
|
64
|
+
alert_id text PRIMARY KEY,
|
|
65
|
+
asset_id text,
|
|
66
|
+
body text NOT NULL,
|
|
67
|
+
due_at timestamptz,
|
|
68
|
+
finding_id text,
|
|
69
|
+
first_observed_at timestamptz NOT NULL,
|
|
70
|
+
kind text NOT NULL,
|
|
71
|
+
last_observed_at timestamptz NOT NULL,
|
|
72
|
+
next_escalation_at timestamptz,
|
|
73
|
+
observation_count integer NOT NULL DEFAULT 1,
|
|
74
|
+
occurrence_count integer NOT NULL DEFAULT 1,
|
|
75
|
+
plan_id text,
|
|
76
|
+
policy_version integer,
|
|
77
|
+
resolved_at timestamptz,
|
|
78
|
+
severity text NOT NULL,
|
|
79
|
+
source_id text,
|
|
80
|
+
status text NOT NULL DEFAULT 'open' CHECK (status IN ('acknowledged', 'open', 'resolved')),
|
|
81
|
+
tenant_id text NOT NULL,
|
|
82
|
+
title text NOT NULL,
|
|
83
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
84
|
+
value jsonb NOT NULL
|
|
85
|
+
);
|
|
86
|
+
CREATE INDEX IF NOT EXISTS ${table.incidents}_status_idx ON ${table.incidents} (status, last_observed_at);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS ${table.incidents}_tenant_idx ON ${table.incidents} (tenant_id, status);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS ${table.incidents}_escalation_idx ON ${table.incidents} (status, next_escalation_at);
|
|
89
|
+
CREATE TABLE IF NOT EXISTS ${table.events} (
|
|
90
|
+
actor_id uuid,
|
|
91
|
+
alert_id text NOT NULL REFERENCES ${table.incidents}(alert_id) ON DELETE CASCADE,
|
|
92
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
93
|
+
id bigserial PRIMARY KEY,
|
|
94
|
+
kind text NOT NULL,
|
|
95
|
+
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
|
|
96
|
+
);
|
|
97
|
+
CREATE INDEX IF NOT EXISTS ${table.events}_alert_idx ON ${table.events} (alert_id, created_at);
|
|
98
|
+
CREATE TABLE IF NOT EXISTS ${table.deliveries} (
|
|
99
|
+
alert_id text NOT NULL REFERENCES ${table.incidents}(alert_id) ON DELETE CASCADE,
|
|
100
|
+
attempt_count integer NOT NULL DEFAULT 0,
|
|
101
|
+
audience text NOT NULL CHECK (audience IN ('admin', 'owner')),
|
|
102
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
103
|
+
delivered_at timestamptz,
|
|
104
|
+
id uuid PRIMARY KEY,
|
|
105
|
+
idempotency_key text NOT NULL,
|
|
106
|
+
kind text NOT NULL CHECK (kind IN ('escalated', 'opened', 'resolved')),
|
|
107
|
+
last_error text,
|
|
108
|
+
lease_expires_at timestamptz,
|
|
109
|
+
next_attempt_at timestamptz NOT NULL DEFAULT now(),
|
|
110
|
+
state text NOT NULL DEFAULT 'pending' CHECK (state IN ('delivered', 'delivering', 'dismissed', 'failed', 'pending')),
|
|
111
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
112
|
+
);
|
|
113
|
+
CREATE UNIQUE INDEX IF NOT EXISTS ${table.deliveries}_key_idx ON ${table.deliveries} (idempotency_key);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS ${table.deliveries}_state_idx ON ${table.deliveries} (state, next_attempt_at);
|
|
115
|
+
`;
|
|
116
|
+
};
|
|
117
|
+
var createPostgresVulnerabilityAlertStores = (options) => {
|
|
118
|
+
const tablePrefix = options.tablePrefix ?? "vulnerability";
|
|
119
|
+
const table = tables(tablePrefix);
|
|
120
|
+
const ddl = vulnerabilityAlertPostgresSchemaSql(tablePrefix);
|
|
121
|
+
const sql = options.sql;
|
|
122
|
+
let schema;
|
|
123
|
+
const ensureAlertSchema = async () => {
|
|
124
|
+
schema ??= Promise.resolve(sql.unsafe(ddl)).then(() => {
|
|
125
|
+
return;
|
|
126
|
+
});
|
|
127
|
+
await schema;
|
|
128
|
+
};
|
|
129
|
+
const ready = async () => {
|
|
130
|
+
if (options.ensureSchema !== false)
|
|
131
|
+
await ensureAlertSchema();
|
|
132
|
+
};
|
|
133
|
+
const queue = async (tx, alert, configuration, kind, occurrence) => {
|
|
134
|
+
const audiences = resolveVulnerabilityAlertAudiences({
|
|
135
|
+
configuration,
|
|
136
|
+
hasOwner: alert.assetId !== null,
|
|
137
|
+
kind,
|
|
138
|
+
severity: alert.severity
|
|
139
|
+
});
|
|
140
|
+
for (const audience of audiences)
|
|
141
|
+
await tx`
|
|
142
|
+
INSERT INTO ${tx.unsafe(table.deliveries)} (
|
|
143
|
+
id, alert_id, audience, idempotency_key, kind
|
|
144
|
+
) VALUES (
|
|
145
|
+
${crypto.randomUUID()}, ${alert.id}, ${audience},
|
|
146
|
+
${`${alert.id}:${audience}:${kind}:${occurrence}`}, ${kind}
|
|
147
|
+
) ON CONFLICT (idempotency_key) DO NOTHING
|
|
148
|
+
`;
|
|
149
|
+
};
|
|
150
|
+
const alertPolicies = {
|
|
151
|
+
activate: async (input) => {
|
|
152
|
+
await ready();
|
|
153
|
+
const configuration = validateVulnerabilityAlertConfiguration(input.configuration);
|
|
154
|
+
const reason = input.reason.trim();
|
|
155
|
+
if (!reason)
|
|
156
|
+
throw new Error("Policy activation reason is required");
|
|
157
|
+
return transaction(sql, async (tx) => {
|
|
158
|
+
await tx`SELECT pg_advisory_xact_lock(hashtext(${`vulnerability-alert-policy:${input.tenantId}`}))`;
|
|
159
|
+
const [latest] = await tx`SELECT COALESCE(MAX(version), 0)::integer AS version FROM ${tx.unsafe(table.policies)} WHERE tenant_id = ${input.tenantId}`;
|
|
160
|
+
const version = (latest?.version ?? 0) + 1;
|
|
161
|
+
await tx`UPDATE ${tx.unsafe(table.policies)} SET status = 'superseded' WHERE tenant_id = ${input.tenantId} AND status = 'active'`;
|
|
162
|
+
await tx`INSERT INTO ${tx.unsafe(table.policies)} (activated_at, activated_by, configuration, reason, status, tenant_id, version) VALUES (${input.activatedAt}, ${input.activatedBy}, ${JSON.stringify(configuration)}::jsonb, ${reason}, 'active', ${input.tenantId}, ${version})`;
|
|
163
|
+
return {
|
|
164
|
+
activatedAt: input.activatedAt,
|
|
165
|
+
configuration,
|
|
166
|
+
reason,
|
|
167
|
+
tenantId: input.tenantId,
|
|
168
|
+
version
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
},
|
|
172
|
+
active: async () => {
|
|
173
|
+
await ready();
|
|
174
|
+
const rows = await sql`SELECT configuration, tenant_id, version FROM ${sql.unsafe(table.policies)} WHERE status = 'active'`;
|
|
175
|
+
return rows.map((row) => ({
|
|
176
|
+
configuration: validateVulnerabilityAlertConfiguration(row.configuration),
|
|
177
|
+
tenantId: row.tenant_id,
|
|
178
|
+
version: row.version
|
|
179
|
+
}));
|
|
180
|
+
},
|
|
181
|
+
list: async (requestedLimit) => {
|
|
182
|
+
await ready();
|
|
183
|
+
return sql`SELECT * FROM ${sql.unsafe(table.policies)} ORDER BY activated_at DESC LIMIT ${boundedLimit(requestedLimit)}`;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const observe = async ({
|
|
187
|
+
alert,
|
|
188
|
+
policy,
|
|
189
|
+
observedAt
|
|
190
|
+
}) => {
|
|
191
|
+
await ready();
|
|
192
|
+
const at = observedAt ?? new Date().toISOString();
|
|
193
|
+
const configuration = validateVulnerabilityAlertConfiguration(policy.configuration);
|
|
194
|
+
return transaction(sql, async (tx) => {
|
|
195
|
+
const [existing] = await tx`SELECT occurrence_count, status FROM ${tx.unsafe(table.incidents)} WHERE alert_id = ${alert.id} FOR UPDATE`;
|
|
196
|
+
const reopened = existing?.status === "resolved";
|
|
197
|
+
const nextEscalationAt = new Date(new Date(at).getTime() + configuration.escalationAfterMs[alert.severity]).toISOString();
|
|
198
|
+
await tx`INSERT INTO ${tx.unsafe(table.incidents)} (alert_id, asset_id, body, due_at, finding_id, first_observed_at, kind, last_observed_at, next_escalation_at, plan_id, policy_version, severity, source_id, status, tenant_id, title, value) VALUES (${alert.id}, ${alert.assetId}, ${alert.body}, ${alert.dueAt}, ${alert.findingId}, ${alert.observedAt}, ${alert.kind}, ${alert.observedAt}, ${nextEscalationAt}, ${alert.planId}, ${policy.version}, ${alert.severity}, ${alert.sourceId}, 'open', ${alert.tenantId}, ${alert.title}, ${JSON.stringify(alert)}::jsonb) ON CONFLICT (alert_id) DO UPDATE SET asset_id = EXCLUDED.asset_id, body = EXCLUDED.body, due_at = EXCLUDED.due_at, finding_id = EXCLUDED.finding_id, last_observed_at = EXCLUDED.last_observed_at, observation_count = ${tx.unsafe(table.incidents)}.observation_count + 1, occurrence_count = CASE WHEN ${tx.unsafe(table.incidents)}.status = 'resolved' THEN ${tx.unsafe(table.incidents)}.occurrence_count + 1 ELSE ${tx.unsafe(table.incidents)}.occurrence_count END, plan_id = EXCLUDED.plan_id, policy_version = EXCLUDED.policy_version, severity = EXCLUDED.severity, source_id = EXCLUDED.source_id, tenant_id = EXCLUDED.tenant_id, title = EXCLUDED.title, updated_at = now(), value = EXCLUDED.value, status = CASE WHEN ${tx.unsafe(table.incidents)}.status = 'resolved' THEN 'open' ELSE ${tx.unsafe(table.incidents)}.status END, resolved_at = CASE WHEN ${tx.unsafe(table.incidents)}.status = 'resolved' THEN null ELSE ${tx.unsafe(table.incidents)}.resolved_at END, next_escalation_at = CASE WHEN ${tx.unsafe(table.incidents)}.status = 'resolved' THEN EXCLUDED.next_escalation_at WHEN ${tx.unsafe(table.incidents)}.status = 'open' AND ${tx.unsafe(table.incidents)}.policy_version IS DISTINCT FROM EXCLUDED.policy_version THEN EXCLUDED.next_escalation_at ELSE ${tx.unsafe(table.incidents)}.next_escalation_at END`;
|
|
199
|
+
if (!existing || reopened) {
|
|
200
|
+
await tx`INSERT INTO ${tx.unsafe(table.events)} (alert_id, kind) VALUES (${alert.id}, 'opened')`;
|
|
201
|
+
await queue(tx, alert, configuration, "opened", existing ? existing.occurrence_count + 1 : 1);
|
|
202
|
+
}
|
|
203
|
+
return !existing ? "opened" : reopened ? "reopened" : "observed";
|
|
204
|
+
});
|
|
205
|
+
};
|
|
206
|
+
const alertIncidents = {
|
|
207
|
+
acknowledge: async (input) => {
|
|
208
|
+
await ready();
|
|
209
|
+
return transaction(sql, async (tx) => {
|
|
210
|
+
const [present] = await tx`SELECT status FROM ${tx.unsafe(table.incidents)} WHERE alert_id = ${input.alertId} FOR UPDATE`;
|
|
211
|
+
if (!present)
|
|
212
|
+
throw new VulnerabilityAlertNotFoundError("Vulnerability alert not found");
|
|
213
|
+
if (present.status === "resolved")
|
|
214
|
+
throw new VulnerabilityAlertConflictError("Resolved vulnerability alerts cannot be acknowledged");
|
|
215
|
+
if (present.status === "acknowledged")
|
|
216
|
+
return false;
|
|
217
|
+
await tx`UPDATE ${tx.unsafe(table.incidents)} SET acknowledged_at = ${input.acknowledgedAt}, acknowledged_by = ${input.acknowledgedBy}, next_escalation_at = null, status = 'acknowledged', updated_at = ${input.acknowledgedAt} WHERE alert_id = ${input.alertId}`;
|
|
218
|
+
await tx`INSERT INTO ${tx.unsafe(table.events)} (actor_id, alert_id, kind) VALUES (${input.acknowledgedBy}, ${input.alertId}, 'acknowledged')`;
|
|
219
|
+
return true;
|
|
220
|
+
});
|
|
221
|
+
},
|
|
222
|
+
claimDeliveries: async (input = {}) => {
|
|
223
|
+
await ready();
|
|
224
|
+
const leaseMs = input.leaseMs ?? 300000;
|
|
225
|
+
const queryLimit = boundedLimit(input.limit ?? 100);
|
|
226
|
+
const tenants = tenantPredicate(input.tenantIds);
|
|
227
|
+
return transaction(sql, async (tx) => {
|
|
228
|
+
const rows = await tx`SELECT d.id FROM ${tx.unsafe(table.deliveries)} d JOIN ${tx.unsafe(table.incidents)} i ON i.alert_id = d.alert_id WHERE (((d.state IN ('pending', 'failed') AND d.next_attempt_at <= now()) OR (d.state = 'delivering' AND d.lease_expires_at <= now()))) AND (${tenants}::jsonb IS NULL OR i.tenant_id IN (SELECT jsonb_array_elements_text(${tenants}::jsonb))) ORDER BY d.created_at FOR UPDATE OF d SKIP LOCKED LIMIT ${queryLimit}`;
|
|
229
|
+
const ids = rows.map(({ id }) => id);
|
|
230
|
+
if (ids.length === 0)
|
|
231
|
+
return [];
|
|
232
|
+
const encoded = JSON.stringify(ids);
|
|
233
|
+
return tx`UPDATE ${tx.unsafe(table.deliveries)} d SET attempt_count = attempt_count + 1, lease_expires_at = now() + ${leaseMs} * INTERVAL '1 millisecond', state = 'delivering', updated_at = now() FROM ${tx.unsafe(table.incidents)} i WHERE d.alert_id = i.alert_id AND d.id::text IN (SELECT jsonb_array_elements_text(${encoded}::jsonb)) RETURNING d.*, i.asset_id, i.body, i.severity, i.tenant_id, i.title`;
|
|
234
|
+
});
|
|
235
|
+
},
|
|
236
|
+
completeDelivery: async (input) => {
|
|
237
|
+
await ready();
|
|
238
|
+
return transaction(sql, async (tx) => {
|
|
239
|
+
const updated = await tx`UPDATE ${tx.unsafe(table.deliveries)} SET delivered_at = now(), last_error = null, lease_expires_at = null, state = 'delivered', updated_at = now() WHERE id = ${input.deliveryId} AND state = 'delivering' RETURNING id`;
|
|
240
|
+
if (updated.length === 0)
|
|
241
|
+
return false;
|
|
242
|
+
await tx`INSERT INTO ${tx.unsafe(table.events)} (alert_id, kind, metadata) VALUES (${input.alertId}, 'delivered', ${JSON.stringify({ audience: input.audience, kind: input.kind })}::jsonb)`;
|
|
243
|
+
return true;
|
|
244
|
+
});
|
|
245
|
+
},
|
|
246
|
+
deliveries: async (requestedLimit) => {
|
|
247
|
+
await ready();
|
|
248
|
+
return sql`SELECT * FROM ${sql.unsafe(table.deliveries)} ORDER BY created_at DESC LIMIT ${boundedLimit(requestedLimit)}`;
|
|
249
|
+
},
|
|
250
|
+
events: async (requestedLimit) => {
|
|
251
|
+
await ready();
|
|
252
|
+
return sql`SELECT * FROM ${sql.unsafe(table.events)} ORDER BY created_at DESC LIMIT ${boundedLimit(requestedLimit)}`;
|
|
253
|
+
},
|
|
254
|
+
failDelivery: async (input) => {
|
|
255
|
+
await ready();
|
|
256
|
+
return transaction(sql, async (tx) => {
|
|
257
|
+
const updated = await tx`UPDATE ${tx.unsafe(table.deliveries)} SET last_error = ${input.error}, lease_expires_at = null, next_attempt_at = ${input.retryAt}, state = 'failed', updated_at = now() WHERE id = ${input.deliveryId} AND state = 'delivering' RETURNING id`;
|
|
258
|
+
if (updated.length === 0)
|
|
259
|
+
return false;
|
|
260
|
+
await tx`INSERT INTO ${tx.unsafe(table.events)} (alert_id, kind, metadata) VALUES (${input.alertId}, 'delivery_failed', ${JSON.stringify({ error: input.error, kind: input.kind })}::jsonb)`;
|
|
261
|
+
return true;
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
incidents: async (requestedLimit) => {
|
|
265
|
+
await ready();
|
|
266
|
+
return sql`SELECT * FROM ${sql.unsafe(table.incidents)} ORDER BY last_observed_at DESC LIMIT ${boundedLimit(requestedLimit)}`;
|
|
267
|
+
},
|
|
268
|
+
observe,
|
|
269
|
+
processEscalations: async ({ policies, tenantIds }) => {
|
|
270
|
+
await ready();
|
|
271
|
+
const tenants = tenantPredicate(tenantIds);
|
|
272
|
+
const due = await sql`SELECT alert_id, asset_id, occurrence_count, severity, tenant_id FROM ${sql.unsafe(table.incidents)} WHERE status = 'open' AND next_escalation_at <= now() AND (${tenants}::jsonb IS NULL OR tenant_id IN (SELECT jsonb_array_elements_text(${tenants}::jsonb)))`;
|
|
273
|
+
let processed = 0;
|
|
274
|
+
for (const incident of due) {
|
|
275
|
+
const policy = policies.get(incident.tenant_id);
|
|
276
|
+
if (!policy)
|
|
277
|
+
continue;
|
|
278
|
+
const changed = await transaction(sql, async (tx) => {
|
|
279
|
+
const claimed = await tx`UPDATE ${tx.unsafe(table.incidents)} SET next_escalation_at = null, updated_at = now() WHERE alert_id = ${incident.alert_id} AND status = 'open' AND next_escalation_at <= now() RETURNING alert_id`;
|
|
280
|
+
if (claimed.length === 0)
|
|
281
|
+
return false;
|
|
282
|
+
await tx`INSERT INTO ${tx.unsafe(table.events)} (alert_id, kind) VALUES (${incident.alert_id}, 'escalated')`;
|
|
283
|
+
await queue(tx, {
|
|
284
|
+
assetId: incident.asset_id,
|
|
285
|
+
id: incident.alert_id,
|
|
286
|
+
severity: incident.severity
|
|
287
|
+
}, policy.configuration, "escalated", incident.occurrence_count);
|
|
288
|
+
return true;
|
|
289
|
+
});
|
|
290
|
+
if (changed)
|
|
291
|
+
processed += 1;
|
|
292
|
+
}
|
|
293
|
+
return processed;
|
|
294
|
+
},
|
|
295
|
+
resolveInactive: async ({
|
|
296
|
+
activeAlertIds,
|
|
297
|
+
policies,
|
|
298
|
+
resolvedAt,
|
|
299
|
+
tenantIds
|
|
300
|
+
}) => {
|
|
301
|
+
await ready();
|
|
302
|
+
const tenants = tenantPredicate(tenantIds);
|
|
303
|
+
const open = await sql`SELECT alert_id, asset_id, occurrence_count, severity, tenant_id FROM ${sql.unsafe(table.incidents)} WHERE status IN ('open', 'acknowledged') AND (${tenants}::jsonb IS NULL OR tenant_id IN (SELECT jsonb_array_elements_text(${tenants}::jsonb)))`;
|
|
304
|
+
const at = resolvedAt ?? new Date().toISOString();
|
|
305
|
+
let processed = 0;
|
|
306
|
+
for (const incident of open) {
|
|
307
|
+
if (activeAlertIds.has(incident.alert_id))
|
|
308
|
+
continue;
|
|
309
|
+
const policy = policies.get(incident.tenant_id);
|
|
310
|
+
if (!policy)
|
|
311
|
+
continue;
|
|
312
|
+
const changed = await transaction(sql, async (tx) => {
|
|
313
|
+
const resolved = await tx`UPDATE ${tx.unsafe(table.incidents)} SET next_escalation_at = null, resolved_at = ${at}, status = 'resolved', updated_at = now() WHERE alert_id = ${incident.alert_id} AND status IN ('open', 'acknowledged') RETURNING alert_id`;
|
|
314
|
+
if (resolved.length === 0)
|
|
315
|
+
return false;
|
|
316
|
+
await tx`INSERT INTO ${tx.unsafe(table.events)} (alert_id, kind) VALUES (${incident.alert_id}, 'resolved')`;
|
|
317
|
+
await queue(tx, {
|
|
318
|
+
assetId: incident.asset_id,
|
|
319
|
+
id: incident.alert_id,
|
|
320
|
+
severity: incident.severity
|
|
321
|
+
}, policy.configuration, "resolved", incident.occurrence_count);
|
|
322
|
+
return true;
|
|
323
|
+
});
|
|
324
|
+
if (changed)
|
|
325
|
+
processed += 1;
|
|
326
|
+
}
|
|
327
|
+
return processed;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
return { alertIncidents, alertPolicies, ensureAlertSchema };
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// src/index.ts
|
|
334
|
+
var IDENTIFIER2 = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
15
335
|
var SYNC_STATUSES = new Set([
|
|
16
336
|
"failed",
|
|
17
337
|
"not_modified",
|
|
@@ -75,10 +395,10 @@ var prefixTables = (prefix) => ({
|
|
|
75
395
|
vexDecisions: `${prefix}_vex_decisions`
|
|
76
396
|
});
|
|
77
397
|
var vulnerabilityPostgresSchemaSql = (tablePrefix = "vulnerability") => {
|
|
78
|
-
if (!
|
|
79
|
-
throw new Error(`[vulnerabilities-postgres] invalid tablePrefix "${tablePrefix}"; must match ${
|
|
398
|
+
if (!IDENTIFIER2.test(tablePrefix))
|
|
399
|
+
throw new Error(`[vulnerabilities-postgres] invalid tablePrefix "${tablePrefix}"; must match ${IDENTIFIER2.source}`);
|
|
80
400
|
const table = prefixTables(tablePrefix);
|
|
81
|
-
return
|
|
401
|
+
return `${vulnerabilityAlertPostgresSchemaSql(tablePrefix)}
|
|
82
402
|
CREATE TABLE IF NOT EXISTS ${table.snapshots} (
|
|
83
403
|
feed_id text PRIMARY KEY,
|
|
84
404
|
feed_name text NOT NULL,
|
|
@@ -238,6 +558,11 @@ var createPostgresVulnerabilityStore = (options) => {
|
|
|
238
558
|
const table = prefixTables(tablePrefix);
|
|
239
559
|
const sql = options.sql;
|
|
240
560
|
const shouldEnsureSchema = options.ensureSchema ?? true;
|
|
561
|
+
const alertStores = createPostgresVulnerabilityAlertStores({
|
|
562
|
+
ensureSchema: shouldEnsureSchema,
|
|
563
|
+
sql: options.sql,
|
|
564
|
+
tablePrefix
|
|
565
|
+
});
|
|
241
566
|
let schemaPromise;
|
|
242
567
|
const ensureSchema = () => {
|
|
243
568
|
if (!shouldEnsureSchema)
|
|
@@ -952,6 +1277,8 @@ var createPostgresVulnerabilityStore = (options) => {
|
|
|
952
1277
|
}
|
|
953
1278
|
};
|
|
954
1279
|
return {
|
|
1280
|
+
alertIncidents: alertStores.alertIncidents,
|
|
1281
|
+
alertPolicies: alertStores.alertPolicies,
|
|
955
1282
|
ensureSchema,
|
|
956
1283
|
findings,
|
|
957
1284
|
leases,
|
|
@@ -968,5 +1295,9 @@ var createPostgresVulnerabilityStore = (options) => {
|
|
|
968
1295
|
};
|
|
969
1296
|
export {
|
|
970
1297
|
vulnerabilityPostgresSchemaSql,
|
|
971
|
-
|
|
1298
|
+
vulnerabilityAlertPostgresSchemaSql,
|
|
1299
|
+
createPostgresVulnerabilityStore,
|
|
1300
|
+
createPostgresVulnerabilityAlertStores,
|
|
1301
|
+
VulnerabilityAlertNotFoundError,
|
|
1302
|
+
VulnerabilityAlertConflictError
|
|
972
1303
|
};
|
package/dist/manifest.js
CHANGED
|
@@ -9,7 +9,7 @@ var manifest = defineManifest()({
|
|
|
9
9
|
intents: [
|
|
10
10
|
"persist vulnerability intelligence",
|
|
11
11
|
"retain vulnerability sync history",
|
|
12
|
-
"store managed vulnerability findings, VEX decisions, remediation evidence, and risk assessments"
|
|
12
|
+
"store managed vulnerability findings, alert incidents, policy history, notification deliveries, VEX decisions, remediation evidence, and risk assessments"
|
|
13
13
|
],
|
|
14
14
|
keywords: ["vulnerabilities", "Postgres", "CVE", "feed-history"],
|
|
15
15
|
protocols: ["PostgreSQL", "AbsoluteJS vulnerability contracts"]
|
|
@@ -17,7 +17,7 @@ var manifest = defineManifest()({
|
|
|
17
17
|
identity: {
|
|
18
18
|
accent: "#336791",
|
|
19
19
|
category: "operations",
|
|
20
|
-
description: "Durable Postgres snapshots, findings,
|
|
20
|
+
description: "Durable Postgres snapshots, findings, alert policy history, incident timelines, notification deliveries, remediation evidence, and risk assessments.",
|
|
21
21
|
docsUrl: "https://github.com/absolutejs/vulnerabilities-postgres",
|
|
22
22
|
name: "@absolutejs/vulnerabilities-postgres",
|
|
23
23
|
tagline: "Keep vulnerability intelligence durable and auditable."
|
package/dist/manifest.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"intents": [
|
|
9
9
|
"persist vulnerability intelligence",
|
|
10
10
|
"retain vulnerability sync history",
|
|
11
|
-
"store managed vulnerability findings, VEX decisions, remediation evidence, and risk assessments"
|
|
11
|
+
"store managed vulnerability findings, alert incidents, policy history, notification deliveries, VEX decisions, remediation evidence, and risk assessments"
|
|
12
12
|
],
|
|
13
13
|
"keywords": [
|
|
14
14
|
"vulnerabilities",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"identity": {
|
|
25
25
|
"accent": "#336791",
|
|
26
26
|
"category": "operations",
|
|
27
|
-
"description": "Durable Postgres snapshots, findings,
|
|
27
|
+
"description": "Durable Postgres snapshots, findings, alert policy history, incident timelines, notification deliveries, remediation evidence, and risk assessments.",
|
|
28
28
|
"docsUrl": "https://github.com/absolutejs/vulnerabilities-postgres",
|
|
29
29
|
"name": "@absolutejs/vulnerabilities-postgres",
|
|
30
30
|
"tagline": "Keep vulnerability intelligence durable and auditable."
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@absolutejs/vulnerabilities-postgres",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Durable Postgres feeds, findings,
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Durable Postgres feeds, findings, alert incidents, policy history, delivery queues, remediation evidence, and risk assessments for AbsoluteJS vulnerability management.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "BSL-1.1",
|
|
7
7
|
"author": "Alex Kahn",
|