@absolutejs/vulnerabilities-postgres 0.1.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/LICENSE +91 -0
- package/README.md +24 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +423 -0
- package/dist/manifest.d.ts +2 -0
- package/dist/manifest.js +84 -0
- package/dist/manifest.json +103 -0
- package/package.json +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Business Source License 1.1
|
|
2
|
+
|
|
3
|
+
**Licensor:** Alex Kahn
|
|
4
|
+
|
|
5
|
+
**Licensed Work:** @absolutejs/vulnerabilities-postgres (https://github.com/absolutejs/vulnerabilities-postgres)
|
|
6
|
+
|
|
7
|
+
**Change Date:** July 18, 2030
|
|
8
|
+
|
|
9
|
+
**Change License:** Apache License, Version 2.0
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Terms
|
|
14
|
+
|
|
15
|
+
The Licensor hereby grants you the right to copy, modify, create derivative
|
|
16
|
+
works, redistribute, and make non-production use of the Licensed Work. The
|
|
17
|
+
Licensor may make an Additional Use Grant, permitting limited production use.
|
|
18
|
+
|
|
19
|
+
### Additional Use Grant
|
|
20
|
+
|
|
21
|
+
You may use the Licensed Work in production, provided your use does not include
|
|
22
|
+
any of the following:
|
|
23
|
+
|
|
24
|
+
1. **Offering a Competing Service.** You may not offer the Licensed Work, or
|
|
25
|
+
any derivative or substantial portion of it, to third parties as a hosted or
|
|
26
|
+
managed vulnerability-management, exposure-management, container-security,
|
|
27
|
+
cloud-security-posture-management, or attack-surface-management service.
|
|
28
|
+
This includes any product whose primary value to its users is the
|
|
29
|
+
functionality the Licensed Work provides.
|
|
30
|
+
|
|
31
|
+
2. **Resale or Redistribution as a Standalone Product.** You may not sell,
|
|
32
|
+
license, or distribute the Licensed Work, or any derivative or fork of it,
|
|
33
|
+
as a standalone commercial product.
|
|
34
|
+
|
|
35
|
+
3. **Removal of Attribution.** Any derivative work, fork, or redistribution of
|
|
36
|
+
the Licensed Work must prominently credit AbsoluteJS and include a link to
|
|
37
|
+
the original project repository
|
|
38
|
+
(https://github.com/absolutejs/vulnerabilities-postgres).
|
|
39
|
+
|
|
40
|
+
For clarity, the following uses are expressly permitted:
|
|
41
|
+
|
|
42
|
+
- Using the Licensed Work to scan, inventory, gate, remediate, or report on
|
|
43
|
+
vulnerabilities in your own applications, websites, internal tools, or SaaS
|
|
44
|
+
products, so long as vulnerability management itself is not the primary
|
|
45
|
+
product you are selling.
|
|
46
|
+
- Using the Licensed Work as a dependency in commercial software you build and
|
|
47
|
+
sell, as long as that software is not a competing hosted security service of
|
|
48
|
+
the kind described in clause 1.
|
|
49
|
+
- Providing consulting, development, or professional services to clients using
|
|
50
|
+
the Licensed Work.
|
|
51
|
+
- Forking and modifying the Licensed Work for your own internal use, provided
|
|
52
|
+
attribution is maintained.
|
|
53
|
+
|
|
54
|
+
### Change Date and Change License
|
|
55
|
+
|
|
56
|
+
On the Change Date specified above, or on such other date as the Licensor may
|
|
57
|
+
specify by written notice, the Licensed Work will be made available under the
|
|
58
|
+
Change License (Apache License, Version 2.0). Until the Change Date, the terms
|
|
59
|
+
of this Business Source License 1.1 apply.
|
|
60
|
+
|
|
61
|
+
### Trademark
|
|
62
|
+
|
|
63
|
+
This license does not grant you any rights to use the "AbsoluteJS" or
|
|
64
|
+
"@absolutejs" name, logo, or any related trademarks. Forks and derivative works
|
|
65
|
+
must not be named or branded in a manner that suggests endorsement by or
|
|
66
|
+
affiliation with AbsoluteJS or the Licensor.
|
|
67
|
+
|
|
68
|
+
### Notices
|
|
69
|
+
|
|
70
|
+
You must not remove or obscure any licensing, copyright, or other notices
|
|
71
|
+
included in the Licensed Work.
|
|
72
|
+
|
|
73
|
+
### No Warranty
|
|
74
|
+
|
|
75
|
+
THE LICENSED WORK IS PROVIDED "AS IS". THE LICENSOR HEREBY DISCLAIMS ALL
|
|
76
|
+
WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO WARRANTIES OF
|
|
77
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO
|
|
78
|
+
EVENT SHALL THE LICENSOR BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY,
|
|
79
|
+
WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR
|
|
80
|
+
IN CONNECTION WITH THE LICENSED WORK OR THE USE OR OTHER DEALINGS IN THE
|
|
81
|
+
LICENSED WORK.
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
## Contact
|
|
86
|
+
|
|
87
|
+
For commercial licensing inquiries or additional permissions, contact:
|
|
88
|
+
|
|
89
|
+
- **Alex Kahn**
|
|
90
|
+
- alexkahndev@gmail.com
|
|
91
|
+
- alexkahndev.github.io
|
package/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @absolutejs/vulnerabilities-postgres
|
|
2
|
+
|
|
3
|
+
Durable Postgres persistence for `@absolutejs/vulnerabilities`.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createPostgresVulnerabilityStore } from "@absolutejs/vulnerabilities-postgres";
|
|
7
|
+
import postgres from "postgres";
|
|
8
|
+
|
|
9
|
+
const persistence = createPostgresVulnerabilityStore({
|
|
10
|
+
sql: postgres(process.env.DATABASE_URL!),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const osvSnapshots = persistence.snapshots();
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The package stores complete provider snapshots and records, appendable sync
|
|
17
|
+
history, tenant-scoped managed findings, and expiring distributed refresh
|
|
18
|
+
leases. Snapshot replacement is one Postgres statement, so readers never see a
|
|
19
|
+
half-replaced record set. Schema creation is lazy and idempotent, or can be
|
|
20
|
+
disabled when application migrations own the tables.
|
|
21
|
+
|
|
22
|
+
The SQL surface is compatible with postgres.js and Neon-style tagged-template
|
|
23
|
+
clients. Table prefixes are strictly validated before identifiers are included
|
|
24
|
+
in SQL.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type FeedSnapshotStore, type FeedSyncRunStore, type ManagedFindingStore } from "@absolutejs/vulnerabilities";
|
|
2
|
+
export type PostgresTag = {
|
|
3
|
+
(strings: TemplateStringsArray, ...values: never[]): PromiseLike<unknown[]>;
|
|
4
|
+
unsafe: (sql: string, ...args: never[]) => PromiseLike<unknown[]>;
|
|
5
|
+
};
|
|
6
|
+
export type FeedLeaseRequest = {
|
|
7
|
+
feedId: string;
|
|
8
|
+
now?: Date;
|
|
9
|
+
ownerId: string;
|
|
10
|
+
ttlMs: number;
|
|
11
|
+
};
|
|
12
|
+
export type FeedLeaseStore = {
|
|
13
|
+
acquire: (request: FeedLeaseRequest) => Promise<boolean>;
|
|
14
|
+
release: (feedId: string, ownerId: string) => Promise<boolean>;
|
|
15
|
+
};
|
|
16
|
+
export type PostgresVulnerabilityStore = {
|
|
17
|
+
ensureSchema: () => Promise<void>;
|
|
18
|
+
findings: ManagedFindingStore;
|
|
19
|
+
leases: FeedLeaseStore;
|
|
20
|
+
snapshots: <T>() => FeedSnapshotStore<T>;
|
|
21
|
+
syncRuns: FeedSyncRunStore;
|
|
22
|
+
};
|
|
23
|
+
export type CreatePostgresVulnerabilityStoreOptions = {
|
|
24
|
+
ensureSchema?: boolean;
|
|
25
|
+
sql: PostgresTag;
|
|
26
|
+
tablePrefix?: string;
|
|
27
|
+
};
|
|
28
|
+
export declare const vulnerabilityPostgresSchemaSql: (tablePrefix?: string) => string;
|
|
29
|
+
export declare const createPostgresVulnerabilityStore: (options: CreatePostgresVulnerabilityStoreOptions) => PostgresVulnerabilityStore;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
import {
|
|
4
|
+
ManagedVulnerabilityFindingSchema
|
|
5
|
+
} from "@absolutejs/vulnerabilities";
|
|
6
|
+
import { Value } from "@sinclair/typebox/value";
|
|
7
|
+
var IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
8
|
+
var SYNC_STATUSES = new Set([
|
|
9
|
+
"failed",
|
|
10
|
+
"not_modified",
|
|
11
|
+
"stale",
|
|
12
|
+
"updated"
|
|
13
|
+
]);
|
|
14
|
+
var requiredText = (value, label) => {
|
|
15
|
+
const normalized = value.trim();
|
|
16
|
+
if (normalized.length === 0)
|
|
17
|
+
throw new Error(`${label} is required`);
|
|
18
|
+
return normalized;
|
|
19
|
+
};
|
|
20
|
+
var limit = (value, fallback = 100) => {
|
|
21
|
+
const normalized = value ?? fallback;
|
|
22
|
+
if (!Number.isInteger(normalized) || normalized < 1 || normalized > 1000)
|
|
23
|
+
throw new Error("Postgres query limit must be an integer from 1 through 1000");
|
|
24
|
+
return normalized;
|
|
25
|
+
};
|
|
26
|
+
var timestamp = (value, label) => {
|
|
27
|
+
if (value instanceof Date)
|
|
28
|
+
return value.toISOString();
|
|
29
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value)))
|
|
30
|
+
throw new Error(`${label} must be a valid timestamp`);
|
|
31
|
+
return new Date(value).toISOString();
|
|
32
|
+
};
|
|
33
|
+
var json = (value, label) => {
|
|
34
|
+
if (typeof value === "string") {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(value);
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(`${label} must contain valid JSON`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
};
|
|
43
|
+
var normalizeCursor = (value) => {
|
|
44
|
+
const cursor = json(value, "Feed cursor");
|
|
45
|
+
const nullable = (entry, label) => {
|
|
46
|
+
if (entry === null || typeof entry === "string")
|
|
47
|
+
return entry;
|
|
48
|
+
throw new Error(`${label} must be a string or null`);
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
etag: nullable(cursor.etag, "Feed cursor etag"),
|
|
52
|
+
lastModified: nullable(cursor.lastModified, "Feed cursor lastModified"),
|
|
53
|
+
token: nullable(cursor.token, "Feed cursor token")
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
var prefixTables = (prefix) => ({
|
|
57
|
+
findings: `${prefix}_findings`,
|
|
58
|
+
leases: `${prefix}_feed_leases`,
|
|
59
|
+
records: `${prefix}_feed_records`,
|
|
60
|
+
runs: `${prefix}_feed_sync_runs`,
|
|
61
|
+
snapshots: `${prefix}_feed_snapshots`
|
|
62
|
+
});
|
|
63
|
+
var vulnerabilityPostgresSchemaSql = (tablePrefix = "vulnerability") => {
|
|
64
|
+
if (!IDENTIFIER.test(tablePrefix))
|
|
65
|
+
throw new Error(`[vulnerabilities-postgres] invalid tablePrefix "${tablePrefix}"; must match ${IDENTIFIER.source}`);
|
|
66
|
+
const table = prefixTables(tablePrefix);
|
|
67
|
+
return `
|
|
68
|
+
CREATE TABLE IF NOT EXISTS ${table.snapshots} (
|
|
69
|
+
feed_id text PRIMARY KEY,
|
|
70
|
+
feed_name text NOT NULL,
|
|
71
|
+
feed_url text NOT NULL,
|
|
72
|
+
cursor jsonb NOT NULL,
|
|
73
|
+
fetched_at timestamptz NOT NULL,
|
|
74
|
+
revision text,
|
|
75
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
76
|
+
);
|
|
77
|
+
CREATE TABLE IF NOT EXISTS ${table.records} (
|
|
78
|
+
feed_id text NOT NULL REFERENCES ${table.snapshots}(feed_id) ON DELETE CASCADE,
|
|
79
|
+
record_id text NOT NULL,
|
|
80
|
+
modified_at timestamptz NOT NULL,
|
|
81
|
+
value jsonb NOT NULL,
|
|
82
|
+
PRIMARY KEY (feed_id, record_id)
|
|
83
|
+
);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS ${table.records}_modified_idx
|
|
85
|
+
ON ${table.records} (feed_id, modified_at DESC);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS ${table.runs} (
|
|
87
|
+
id text PRIMARY KEY,
|
|
88
|
+
feed_id text NOT NULL,
|
|
89
|
+
started_at timestamptz NOT NULL,
|
|
90
|
+
completed_at timestamptz NOT NULL,
|
|
91
|
+
status text NOT NULL CHECK (status IN ('failed', 'not_modified', 'stale', 'updated')),
|
|
92
|
+
error text,
|
|
93
|
+
records integer NOT NULL CHECK (records >= 0),
|
|
94
|
+
revision text
|
|
95
|
+
);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS ${table.runs}_feed_started_idx
|
|
97
|
+
ON ${table.runs} (feed_id, started_at DESC);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS ${table.runs}_status_started_idx
|
|
99
|
+
ON ${table.runs} (status, started_at DESC);
|
|
100
|
+
CREATE TABLE IF NOT EXISTS ${table.findings} (
|
|
101
|
+
tenant_id text NOT NULL,
|
|
102
|
+
finding_id text NOT NULL,
|
|
103
|
+
asset_id text NOT NULL,
|
|
104
|
+
component_id text NOT NULL,
|
|
105
|
+
severity text NOT NULL,
|
|
106
|
+
status text NOT NULL,
|
|
107
|
+
first_seen_at timestamptz NOT NULL,
|
|
108
|
+
last_seen_at timestamptz NOT NULL,
|
|
109
|
+
value jsonb NOT NULL,
|
|
110
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
111
|
+
PRIMARY KEY (tenant_id, finding_id)
|
|
112
|
+
);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS ${table.findings}_tenant_status_idx
|
|
114
|
+
ON ${table.findings} (tenant_id, status, last_seen_at DESC);
|
|
115
|
+
CREATE INDEX IF NOT EXISTS ${table.findings}_tenant_severity_idx
|
|
116
|
+
ON ${table.findings} (tenant_id, severity, last_seen_at DESC);
|
|
117
|
+
CREATE INDEX IF NOT EXISTS ${table.findings}_tenant_asset_idx
|
|
118
|
+
ON ${table.findings} (tenant_id, asset_id, last_seen_at DESC);
|
|
119
|
+
CREATE TABLE IF NOT EXISTS ${table.leases} (
|
|
120
|
+
feed_id text PRIMARY KEY,
|
|
121
|
+
owner_id text NOT NULL,
|
|
122
|
+
expires_at timestamptz NOT NULL,
|
|
123
|
+
updated_at timestamptz NOT NULL DEFAULT now()
|
|
124
|
+
);
|
|
125
|
+
`;
|
|
126
|
+
};
|
|
127
|
+
var createPostgresVulnerabilityStore = (options) => {
|
|
128
|
+
const tablePrefix = options.tablePrefix ?? "vulnerability";
|
|
129
|
+
const ddl = vulnerabilityPostgresSchemaSql(tablePrefix);
|
|
130
|
+
const table = prefixTables(tablePrefix);
|
|
131
|
+
const sql = options.sql;
|
|
132
|
+
const shouldEnsureSchema = options.ensureSchema ?? true;
|
|
133
|
+
let schemaPromise;
|
|
134
|
+
const ensureSchema = () => {
|
|
135
|
+
if (!shouldEnsureSchema)
|
|
136
|
+
return Promise.resolve();
|
|
137
|
+
if (schemaPromise)
|
|
138
|
+
return schemaPromise;
|
|
139
|
+
schemaPromise = Promise.resolve(sql.unsafe(ddl)).then(() => {
|
|
140
|
+
return;
|
|
141
|
+
});
|
|
142
|
+
return schemaPromise;
|
|
143
|
+
};
|
|
144
|
+
const snapshots = () => ({
|
|
145
|
+
load: async (feedId) => {
|
|
146
|
+
await ensureSchema();
|
|
147
|
+
const id = requiredText(feedId, "Feed id");
|
|
148
|
+
const rows = await sql`
|
|
149
|
+
SELECT
|
|
150
|
+
snapshot.feed_id,
|
|
151
|
+
snapshot.feed_name,
|
|
152
|
+
snapshot.feed_url,
|
|
153
|
+
snapshot.cursor,
|
|
154
|
+
snapshot.fetched_at,
|
|
155
|
+
snapshot.revision,
|
|
156
|
+
COALESCE((
|
|
157
|
+
SELECT jsonb_agg(
|
|
158
|
+
jsonb_build_object(
|
|
159
|
+
'id', record.record_id,
|
|
160
|
+
'modifiedAt', record.modified_at,
|
|
161
|
+
'value', record.value
|
|
162
|
+
) ORDER BY record.record_id
|
|
163
|
+
)
|
|
164
|
+
FROM ${sql.unsafe(table.records)} record
|
|
165
|
+
WHERE record.feed_id = snapshot.feed_id
|
|
166
|
+
), '[]'::jsonb) AS records
|
|
167
|
+
FROM ${sql.unsafe(table.snapshots)} snapshot
|
|
168
|
+
WHERE snapshot.feed_id = ${id}
|
|
169
|
+
`;
|
|
170
|
+
const row = rows[0];
|
|
171
|
+
if (!row)
|
|
172
|
+
return null;
|
|
173
|
+
const records = json(row.records, "Feed records");
|
|
174
|
+
if (!Array.isArray(records))
|
|
175
|
+
throw new Error("Feed records must be an array");
|
|
176
|
+
return {
|
|
177
|
+
cursor: normalizeCursor(row.cursor),
|
|
178
|
+
feed: {
|
|
179
|
+
id: row.feed_id,
|
|
180
|
+
name: row.feed_name,
|
|
181
|
+
url: row.feed_url
|
|
182
|
+
},
|
|
183
|
+
fetchedAt: timestamp(row.fetched_at, "Feed fetchedAt"),
|
|
184
|
+
records: records.map((record) => ({
|
|
185
|
+
id: requiredText(record.id, "Feed record id"),
|
|
186
|
+
modifiedAt: timestamp(record.modifiedAt, "Feed record modifiedAt"),
|
|
187
|
+
value: record.value
|
|
188
|
+
})),
|
|
189
|
+
revision: row.revision
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
save: async (snapshot) => {
|
|
193
|
+
await ensureSchema();
|
|
194
|
+
const records = JSON.stringify(snapshot.records.map((record) => ({
|
|
195
|
+
id: requiredText(record.id, "Feed record id"),
|
|
196
|
+
modified_at: timestamp(record.modifiedAt, "Feed record modifiedAt"),
|
|
197
|
+
value: record.value
|
|
198
|
+
})));
|
|
199
|
+
const cursor = JSON.stringify(snapshot.cursor);
|
|
200
|
+
await sql`
|
|
201
|
+
WITH saved_snapshot AS (
|
|
202
|
+
INSERT INTO ${sql.unsafe(table.snapshots)} (
|
|
203
|
+
feed_id, feed_name, feed_url, cursor, fetched_at, revision, updated_at
|
|
204
|
+
) VALUES (
|
|
205
|
+
${requiredText(snapshot.feed.id, "Feed id")},
|
|
206
|
+
${requiredText(snapshot.feed.name, "Feed name")},
|
|
207
|
+
${requiredText(snapshot.feed.url, "Feed URL")},
|
|
208
|
+
${cursor}::jsonb,
|
|
209
|
+
${timestamp(snapshot.fetchedAt, "Feed fetchedAt")}::timestamptz,
|
|
210
|
+
${snapshot.revision},
|
|
211
|
+
now()
|
|
212
|
+
)
|
|
213
|
+
ON CONFLICT (feed_id) DO UPDATE SET
|
|
214
|
+
feed_name = EXCLUDED.feed_name,
|
|
215
|
+
feed_url = EXCLUDED.feed_url,
|
|
216
|
+
cursor = EXCLUDED.cursor,
|
|
217
|
+
fetched_at = EXCLUDED.fetched_at,
|
|
218
|
+
revision = EXCLUDED.revision,
|
|
219
|
+
updated_at = now()
|
|
220
|
+
RETURNING feed_id
|
|
221
|
+
), incoming AS (
|
|
222
|
+
SELECT item.id, item.modified_at::timestamptz, item.value
|
|
223
|
+
FROM jsonb_to_recordset(${records}::jsonb)
|
|
224
|
+
AS item(id text, modified_at text, value jsonb)
|
|
225
|
+
), removed AS (
|
|
226
|
+
DELETE FROM ${sql.unsafe(table.records)} record
|
|
227
|
+
WHERE record.feed_id = ${snapshot.feed.id}
|
|
228
|
+
AND NOT EXISTS (
|
|
229
|
+
SELECT 1 FROM incoming WHERE incoming.id = record.record_id
|
|
230
|
+
)
|
|
231
|
+
RETURNING record_id
|
|
232
|
+
), written AS (
|
|
233
|
+
INSERT INTO ${sql.unsafe(table.records)} (
|
|
234
|
+
feed_id, record_id, modified_at, value
|
|
235
|
+
)
|
|
236
|
+
SELECT saved_snapshot.feed_id, incoming.id, incoming.modified_at, incoming.value
|
|
237
|
+
FROM saved_snapshot CROSS JOIN incoming
|
|
238
|
+
ON CONFLICT (feed_id, record_id) DO UPDATE SET
|
|
239
|
+
modified_at = EXCLUDED.modified_at,
|
|
240
|
+
value = EXCLUDED.value
|
|
241
|
+
RETURNING record_id
|
|
242
|
+
)
|
|
243
|
+
SELECT
|
|
244
|
+
(SELECT count(*) FROM removed) AS removed,
|
|
245
|
+
(SELECT count(*) FROM written) AS written
|
|
246
|
+
`;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
const syncRuns = {
|
|
250
|
+
append: async (run) => {
|
|
251
|
+
await ensureSchema();
|
|
252
|
+
if (!SYNC_STATUSES.has(run.status))
|
|
253
|
+
throw new Error(`Unsupported feed sync status: ${run.status}`);
|
|
254
|
+
if (!Number.isInteger(run.records) || run.records < 0)
|
|
255
|
+
throw new Error("Feed sync records must be a non-negative integer");
|
|
256
|
+
await sql`
|
|
257
|
+
INSERT INTO ${sql.unsafe(table.runs)} (
|
|
258
|
+
id, feed_id, started_at, completed_at, status, error, records, revision
|
|
259
|
+
) VALUES (
|
|
260
|
+
${requiredText(run.id, "Feed sync run id")},
|
|
261
|
+
${requiredText(run.feedId, "Feed sync feed id")},
|
|
262
|
+
${timestamp(run.startedAt, "Feed sync startedAt")}::timestamptz,
|
|
263
|
+
${timestamp(run.completedAt, "Feed sync completedAt")}::timestamptz,
|
|
264
|
+
${run.status}, ${run.error}, ${run.records}, ${run.revision}
|
|
265
|
+
)
|
|
266
|
+
ON CONFLICT (id) DO UPDATE SET
|
|
267
|
+
feed_id = EXCLUDED.feed_id,
|
|
268
|
+
started_at = EXCLUDED.started_at,
|
|
269
|
+
completed_at = EXCLUDED.completed_at,
|
|
270
|
+
status = EXCLUDED.status,
|
|
271
|
+
error = EXCLUDED.error,
|
|
272
|
+
records = EXCLUDED.records,
|
|
273
|
+
revision = EXCLUDED.revision
|
|
274
|
+
`;
|
|
275
|
+
},
|
|
276
|
+
list: async (filter = {}) => {
|
|
277
|
+
await ensureSchema();
|
|
278
|
+
const feedId = filter.feedId?.trim() || null;
|
|
279
|
+
const status = filter.status ?? null;
|
|
280
|
+
const rows = await sql`
|
|
281
|
+
SELECT id, feed_id, started_at, completed_at, status, error, records, revision
|
|
282
|
+
FROM ${sql.unsafe(table.runs)}
|
|
283
|
+
WHERE (${feedId}::text IS NULL OR feed_id = ${feedId})
|
|
284
|
+
AND (${status}::text IS NULL OR status = ${status})
|
|
285
|
+
ORDER BY started_at DESC, id DESC
|
|
286
|
+
LIMIT ${limit(filter.limit)}
|
|
287
|
+
`;
|
|
288
|
+
return rows.map((row) => ({
|
|
289
|
+
completedAt: timestamp(row.completed_at, "Feed sync completedAt"),
|
|
290
|
+
error: row.error,
|
|
291
|
+
feedId: row.feed_id,
|
|
292
|
+
id: row.id,
|
|
293
|
+
records: Number(row.records),
|
|
294
|
+
revision: row.revision,
|
|
295
|
+
startedAt: timestamp(row.started_at, "Feed sync startedAt"),
|
|
296
|
+
status: row.status
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
const saveFindings = async (findings2) => {
|
|
301
|
+
await ensureSchema();
|
|
302
|
+
if (findings2.length === 0)
|
|
303
|
+
return;
|
|
304
|
+
for (const finding of findings2) {
|
|
305
|
+
const findingId = finding.id;
|
|
306
|
+
if (!Value.Check(ManagedVulnerabilityFindingSchema, finding))
|
|
307
|
+
throw new Error(`Managed finding ${findingId} is invalid`);
|
|
308
|
+
}
|
|
309
|
+
const payload = JSON.stringify(findings2.map((finding) => ({
|
|
310
|
+
asset_id: finding.assetId,
|
|
311
|
+
component_id: finding.componentId,
|
|
312
|
+
finding_id: finding.id,
|
|
313
|
+
first_seen_at: finding.firstSeenAt,
|
|
314
|
+
last_seen_at: finding.lastSeenAt,
|
|
315
|
+
severity: finding.severity,
|
|
316
|
+
status: finding.status,
|
|
317
|
+
tenant_id: finding.tenantId,
|
|
318
|
+
value: finding
|
|
319
|
+
})));
|
|
320
|
+
await sql`
|
|
321
|
+
INSERT INTO ${sql.unsafe(table.findings)} (
|
|
322
|
+
tenant_id, finding_id, asset_id, component_id, severity, status,
|
|
323
|
+
first_seen_at, last_seen_at, value, updated_at
|
|
324
|
+
)
|
|
325
|
+
SELECT
|
|
326
|
+
item.tenant_id, item.finding_id, item.asset_id, item.component_id,
|
|
327
|
+
item.severity, item.status, item.first_seen_at::timestamptz,
|
|
328
|
+
item.last_seen_at::timestamptz, item.value, now()
|
|
329
|
+
FROM jsonb_to_recordset(${payload}::jsonb) AS item(
|
|
330
|
+
tenant_id text, finding_id text, asset_id text, component_id text,
|
|
331
|
+
severity text, status text, first_seen_at text, last_seen_at text,
|
|
332
|
+
value jsonb
|
|
333
|
+
)
|
|
334
|
+
ON CONFLICT (tenant_id, finding_id) DO UPDATE SET
|
|
335
|
+
asset_id = EXCLUDED.asset_id,
|
|
336
|
+
component_id = EXCLUDED.component_id,
|
|
337
|
+
severity = EXCLUDED.severity,
|
|
338
|
+
status = EXCLUDED.status,
|
|
339
|
+
first_seen_at = LEAST(${sql.unsafe(table.findings)}.first_seen_at, EXCLUDED.first_seen_at),
|
|
340
|
+
last_seen_at = GREATEST(${sql.unsafe(table.findings)}.last_seen_at, EXCLUDED.last_seen_at),
|
|
341
|
+
value = EXCLUDED.value,
|
|
342
|
+
updated_at = now()
|
|
343
|
+
`;
|
|
344
|
+
};
|
|
345
|
+
const parseFinding = (value) => {
|
|
346
|
+
const finding = json(value, "Managed finding");
|
|
347
|
+
if (!Value.Check(ManagedVulnerabilityFindingSchema, finding))
|
|
348
|
+
throw new Error("Stored managed finding is invalid");
|
|
349
|
+
return finding;
|
|
350
|
+
};
|
|
351
|
+
const findings = {
|
|
352
|
+
get: async (tenantId, findingId) => {
|
|
353
|
+
await ensureSchema();
|
|
354
|
+
const rows = await sql`
|
|
355
|
+
SELECT value FROM ${sql.unsafe(table.findings)}
|
|
356
|
+
WHERE tenant_id = ${requiredText(tenantId, "Tenant id")}
|
|
357
|
+
AND finding_id = ${requiredText(findingId, "Finding id")}
|
|
358
|
+
`;
|
|
359
|
+
return rows[0] ? parseFinding(rows[0].value) : null;
|
|
360
|
+
},
|
|
361
|
+
list: async (filter) => {
|
|
362
|
+
await ensureSchema();
|
|
363
|
+
const tenantId = requiredText(filter.tenantId, "Tenant id");
|
|
364
|
+
const assetId = filter.assetId?.trim() || null;
|
|
365
|
+
const severity = filter.severity ?? null;
|
|
366
|
+
const status = filter.status ?? null;
|
|
367
|
+
const rows = await sql`
|
|
368
|
+
SELECT value FROM ${sql.unsafe(table.findings)}
|
|
369
|
+
WHERE tenant_id = ${tenantId}
|
|
370
|
+
AND (${assetId}::text IS NULL OR asset_id = ${assetId})
|
|
371
|
+
AND (${severity}::text IS NULL OR severity = ${severity})
|
|
372
|
+
AND (${status}::text IS NULL OR status = ${status})
|
|
373
|
+
ORDER BY last_seen_at DESC, finding_id
|
|
374
|
+
LIMIT ${limit(filter.limit)}
|
|
375
|
+
`;
|
|
376
|
+
return rows.map(({ value }) => parseFinding(value));
|
|
377
|
+
},
|
|
378
|
+
save: async (finding) => saveFindings([finding]),
|
|
379
|
+
saveMany: saveFindings
|
|
380
|
+
};
|
|
381
|
+
const leases = {
|
|
382
|
+
acquire: async (request) => {
|
|
383
|
+
await ensureSchema();
|
|
384
|
+
if (!Number.isFinite(request.ttlMs) || request.ttlMs <= 0)
|
|
385
|
+
throw new Error("Feed lease ttlMs must be positive and finite");
|
|
386
|
+
const now = request.now ?? new Date;
|
|
387
|
+
const expiresAt = new Date(now.getTime() + request.ttlMs);
|
|
388
|
+
const rows = await sql`
|
|
389
|
+
INSERT INTO ${sql.unsafe(table.leases)} (
|
|
390
|
+
feed_id, owner_id, expires_at, updated_at
|
|
391
|
+
) VALUES (
|
|
392
|
+
${requiredText(request.feedId, "Feed lease feed id")},
|
|
393
|
+
${requiredText(request.ownerId, "Feed lease owner id")},
|
|
394
|
+
${expiresAt.toISOString()}::timestamptz,
|
|
395
|
+
${now.toISOString()}::timestamptz
|
|
396
|
+
)
|
|
397
|
+
ON CONFLICT (feed_id) DO UPDATE SET
|
|
398
|
+
owner_id = EXCLUDED.owner_id,
|
|
399
|
+
expires_at = EXCLUDED.expires_at,
|
|
400
|
+
updated_at = EXCLUDED.updated_at
|
|
401
|
+
WHERE ${sql.unsafe(table.leases)}.expires_at <= ${now.toISOString()}::timestamptz
|
|
402
|
+
OR ${sql.unsafe(table.leases)}.owner_id = EXCLUDED.owner_id
|
|
403
|
+
RETURNING owner_id
|
|
404
|
+
`;
|
|
405
|
+
return rows[0]?.owner_id === request.ownerId.trim();
|
|
406
|
+
},
|
|
407
|
+
release: async (feedId, ownerId) => {
|
|
408
|
+
await ensureSchema();
|
|
409
|
+
const rows = await sql`
|
|
410
|
+
DELETE FROM ${sql.unsafe(table.leases)}
|
|
411
|
+
WHERE feed_id = ${requiredText(feedId, "Feed lease feed id")}
|
|
412
|
+
AND owner_id = ${requiredText(ownerId, "Feed lease owner id")}
|
|
413
|
+
RETURNING feed_id
|
|
414
|
+
`;
|
|
415
|
+
return rows.length > 0;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
return { ensureSchema, findings, leases, snapshots, syncRuns };
|
|
419
|
+
};
|
|
420
|
+
export {
|
|
421
|
+
vulnerabilityPostgresSchemaSql,
|
|
422
|
+
createPostgresVulnerabilityStore
|
|
423
|
+
};
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/manifest.ts
|
|
3
|
+
import { defineImplementation, defineManifest } from "@absolutejs/manifest";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
|
+
var manifest = defineManifest()({
|
|
6
|
+
contract: 2,
|
|
7
|
+
discovery: {
|
|
8
|
+
audiences: ["platform-operators", "security-teams"],
|
|
9
|
+
intents: [
|
|
10
|
+
"persist vulnerability intelligence",
|
|
11
|
+
"retain vulnerability sync history",
|
|
12
|
+
"store managed vulnerability findings"
|
|
13
|
+
],
|
|
14
|
+
keywords: ["vulnerabilities", "Postgres", "CVE", "feed-history"],
|
|
15
|
+
protocols: ["PostgreSQL", "AbsoluteJS vulnerability contracts"]
|
|
16
|
+
},
|
|
17
|
+
identity: {
|
|
18
|
+
accent: "#336791",
|
|
19
|
+
category: "operations",
|
|
20
|
+
description: "Durable Postgres snapshots, records, sync history, managed findings, and distributed refresh leases.",
|
|
21
|
+
docsUrl: "https://github.com/absolutejs/vulnerabilities-postgres",
|
|
22
|
+
name: "@absolutejs/vulnerabilities-postgres",
|
|
23
|
+
tagline: "Keep vulnerability intelligence durable and auditable."
|
|
24
|
+
},
|
|
25
|
+
implements: [
|
|
26
|
+
defineImplementation()({
|
|
27
|
+
contract: "vulnerabilities/persistence",
|
|
28
|
+
factory: "createPostgresVulnerabilityStore",
|
|
29
|
+
from: "@absolutejs/vulnerabilities-postgres",
|
|
30
|
+
requires: {
|
|
31
|
+
env: [
|
|
32
|
+
{
|
|
33
|
+
description: "Postgres connection string for vulnerability intelligence and findings",
|
|
34
|
+
example: "postgres://user:pass@host/db",
|
|
35
|
+
key: "DATABASE_URL",
|
|
36
|
+
secret: true
|
|
37
|
+
}
|
|
38
|
+
],
|
|
39
|
+
peers: [
|
|
40
|
+
{
|
|
41
|
+
name: "@neondatabase/serverless",
|
|
42
|
+
range: ">=0.10.0",
|
|
43
|
+
reason: "HTTP Postgres client for serverless environments"
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
services: [
|
|
47
|
+
{
|
|
48
|
+
description: "Stores vulnerability intelligence, history, findings, and leases",
|
|
49
|
+
id: "postgres"
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
},
|
|
53
|
+
settings: Type.Object({
|
|
54
|
+
ensureSchema: Type.Optional(Type.Boolean({
|
|
55
|
+
default: true,
|
|
56
|
+
description: "Create vulnerability tables automatically on first use.",
|
|
57
|
+
title: "Create tables automatically"
|
|
58
|
+
})),
|
|
59
|
+
tablePrefix: Type.Optional(Type.String({
|
|
60
|
+
default: "vulnerability",
|
|
61
|
+
description: "Prefix for all vulnerability persistence tables.",
|
|
62
|
+
pattern: "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
|
63
|
+
title: "Table prefix"
|
|
64
|
+
}))
|
|
65
|
+
}),
|
|
66
|
+
title: "Your Postgres database (durable vulnerability intelligence)",
|
|
67
|
+
wiring: {
|
|
68
|
+
code: "createPostgresVulnerabilityStore({ sql: neon(${env.DATABASE_URL} ?? ''), ...${settings} })",
|
|
69
|
+
imports: [
|
|
70
|
+
{ from: "@neondatabase/serverless", names: ["neon"] },
|
|
71
|
+
{
|
|
72
|
+
from: "@absolutejs/vulnerabilities-postgres",
|
|
73
|
+
names: ["createPostgresVulnerabilityStore"]
|
|
74
|
+
}
|
|
75
|
+
]
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
],
|
|
79
|
+
settings: Type.Object({}),
|
|
80
|
+
wiring: []
|
|
81
|
+
});
|
|
82
|
+
export {
|
|
83
|
+
manifest
|
|
84
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contract": 2,
|
|
3
|
+
"discovery": {
|
|
4
|
+
"audiences": [
|
|
5
|
+
"platform-operators",
|
|
6
|
+
"security-teams"
|
|
7
|
+
],
|
|
8
|
+
"intents": [
|
|
9
|
+
"persist vulnerability intelligence",
|
|
10
|
+
"retain vulnerability sync history",
|
|
11
|
+
"store managed vulnerability findings"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"vulnerabilities",
|
|
15
|
+
"Postgres",
|
|
16
|
+
"CVE",
|
|
17
|
+
"feed-history"
|
|
18
|
+
],
|
|
19
|
+
"protocols": [
|
|
20
|
+
"PostgreSQL",
|
|
21
|
+
"AbsoluteJS vulnerability contracts"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"identity": {
|
|
25
|
+
"accent": "#336791",
|
|
26
|
+
"category": "operations",
|
|
27
|
+
"description": "Durable Postgres snapshots, records, sync history, managed findings, and distributed refresh leases.",
|
|
28
|
+
"docsUrl": "https://github.com/absolutejs/vulnerabilities-postgres",
|
|
29
|
+
"name": "@absolutejs/vulnerabilities-postgres",
|
|
30
|
+
"tagline": "Keep vulnerability intelligence durable and auditable."
|
|
31
|
+
},
|
|
32
|
+
"implements": [
|
|
33
|
+
{
|
|
34
|
+
"contract": "vulnerabilities/persistence",
|
|
35
|
+
"factory": "createPostgresVulnerabilityStore",
|
|
36
|
+
"from": "@absolutejs/vulnerabilities-postgres",
|
|
37
|
+
"requires": {
|
|
38
|
+
"env": [
|
|
39
|
+
{
|
|
40
|
+
"description": "Postgres connection string for vulnerability intelligence and findings",
|
|
41
|
+
"example": "postgres://user:pass@host/db",
|
|
42
|
+
"key": "DATABASE_URL",
|
|
43
|
+
"secret": true
|
|
44
|
+
}
|
|
45
|
+
],
|
|
46
|
+
"peers": [
|
|
47
|
+
{
|
|
48
|
+
"name": "@neondatabase/serverless",
|
|
49
|
+
"range": ">=0.10.0",
|
|
50
|
+
"reason": "HTTP Postgres client for serverless environments"
|
|
51
|
+
}
|
|
52
|
+
],
|
|
53
|
+
"services": [
|
|
54
|
+
{
|
|
55
|
+
"description": "Stores vulnerability intelligence, history, findings, and leases",
|
|
56
|
+
"id": "postgres"
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"settings": {
|
|
61
|
+
"type": "object",
|
|
62
|
+
"properties": {
|
|
63
|
+
"ensureSchema": {
|
|
64
|
+
"default": true,
|
|
65
|
+
"description": "Create vulnerability tables automatically on first use.",
|
|
66
|
+
"title": "Create tables automatically",
|
|
67
|
+
"type": "boolean"
|
|
68
|
+
},
|
|
69
|
+
"tablePrefix": {
|
|
70
|
+
"default": "vulnerability",
|
|
71
|
+
"description": "Prefix for all vulnerability persistence tables.",
|
|
72
|
+
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
|
|
73
|
+
"title": "Table prefix",
|
|
74
|
+
"type": "string"
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
"title": "Your Postgres database (durable vulnerability intelligence)",
|
|
79
|
+
"wiring": {
|
|
80
|
+
"code": "createPostgresVulnerabilityStore({ sql: neon(${env.DATABASE_URL} ?? ''), ...${settings} })",
|
|
81
|
+
"imports": [
|
|
82
|
+
{
|
|
83
|
+
"from": "@neondatabase/serverless",
|
|
84
|
+
"names": [
|
|
85
|
+
"neon"
|
|
86
|
+
]
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"from": "@absolutejs/vulnerabilities-postgres",
|
|
90
|
+
"names": [
|
|
91
|
+
"createPostgresVulnerabilityStore"
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
],
|
|
98
|
+
"settings": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"properties": {}
|
|
101
|
+
},
|
|
102
|
+
"wiring": []
|
|
103
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@absolutejs/vulnerabilities-postgres",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Durable Postgres feed snapshots, sync history, managed findings, and refresh leases for AbsoluteJS vulnerability management.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "BSL-1.1",
|
|
7
|
+
"author": "Alex Kahn",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/absolutejs/vulnerabilities-postgres.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/absolutejs/vulnerabilities-postgres",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/absolutejs/vulnerabilities-postgres/issues"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"main": "./dist/index.js",
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"absolutejs": {
|
|
22
|
+
"manifestContract": 2
|
|
23
|
+
},
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.ts",
|
|
27
|
+
"import": "./dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"./manifest": {
|
|
30
|
+
"types": "./dist/manifest.d.ts",
|
|
31
|
+
"import": "./dist/manifest.js"
|
|
32
|
+
},
|
|
33
|
+
"./manifest.json": "./dist/manifest.json"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"format": "prettier --write \"./**/*.{ts,json,md}\"",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "bun test",
|
|
44
|
+
"build": "rm -rf dist && bun build src/index.ts src/manifest.ts --outdir dist --target=bun --external @absolutejs/manifest --external @absolutejs/vulnerabilities --external @sinclair/typebox --external postgres --external @neondatabase/serverless && tsc -p tsconfig.build.json && absolute-manifest emit",
|
|
45
|
+
"check:package": "bun run format && bun run typecheck && bun run test && bun run build"
|
|
46
|
+
},
|
|
47
|
+
"keywords": [
|
|
48
|
+
"absolutejs",
|
|
49
|
+
"cve",
|
|
50
|
+
"postgres",
|
|
51
|
+
"security",
|
|
52
|
+
"vulnerability-management"
|
|
53
|
+
],
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@absolutejs/manifest": "0.3.0",
|
|
56
|
+
"@absolutejs/vulnerabilities": "0.5.0",
|
|
57
|
+
"@sinclair/typebox": "0.34.52"
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@neondatabase/serverless": ">=0.10.0",
|
|
61
|
+
"postgres": ">=3.4.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"@neondatabase/serverless": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"postgres": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@electric-sql/pglite": "0.3.14",
|
|
73
|
+
"@types/bun": "1.3.14",
|
|
74
|
+
"prettier": "3.8.1",
|
|
75
|
+
"typescript": "5.9.3"
|
|
76
|
+
}
|
|
77
|
+
}
|