@ecosyste-ms/critical 1.1.20260822 → 1.3.20260824
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 +46 -3
- package/critical-packages.db.gz +0 -0
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +91 -0
- package/dist/bin.js.map +1 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +386 -0
- package/dist/index.js.map +1 -0
- package/lib/bin.ts +101 -0
- package/lib/index.ts +601 -0
- package/package.json +29 -6
- package/bin.js +0 -88
- package/lib/index.js +0 -419
package/README.md
CHANGED
|
@@ -12,14 +12,27 @@ npm install @ecosyste-ms/critical
|
|
|
12
12
|
|
|
13
13
|
The package includes a pre-built `critical-packages.db` file:
|
|
14
14
|
|
|
15
|
-
```
|
|
15
|
+
```typescript
|
|
16
16
|
import { databasePath } from '@ecosyste-ms/critical'
|
|
17
|
-
import
|
|
17
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
18
18
|
|
|
19
|
-
const db = new
|
|
19
|
+
const db = new DatabaseSync(databasePath)
|
|
20
20
|
const pkg = db.prepare('SELECT * FROM packages WHERE name = ?').get('lodash')
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
Written in TypeScript and published with type declarations; the row shapes the build
|
|
24
|
+
side works with (`ApiPackage`, `ApiAdvisory`, `ApiRepoMetadata`, `BuildInfo`) are
|
|
25
|
+
exported as types.
|
|
26
|
+
|
|
27
|
+
Since 1.3.0 the package has one runtime dependency, [`@ecosyste-ms/ecosystems-ts`][sdk],
|
|
28
|
+
the typed client for the ecosyste.ms API. The build talks to the API through it rather
|
|
29
|
+
than through a hand-rolled fetch layer, so retries, rate limiting and registry routing
|
|
30
|
+
live in one place across the ecosyste.ms packages. The version is pinned exactly, since
|
|
31
|
+
the daily job publishes unattended. Reading the bundled database still touches nothing
|
|
32
|
+
but `node:sqlite`.
|
|
33
|
+
|
|
34
|
+
[sdk]: https://github.com/ecosyste-ms/ecosystems-ts
|
|
35
|
+
|
|
23
36
|
See [ecosyste-ms/mcp](https://github.com/ecosyste-ms/mcp) for a full example.
|
|
24
37
|
|
|
25
38
|
## Download
|
|
@@ -52,6 +65,36 @@ npx @ecosyste-ms/critical -o my-db.db # custom output path
|
|
|
52
65
|
npx @ecosyste-ms/critical --stats # show database statistics
|
|
53
66
|
```
|
|
54
67
|
|
|
68
|
+
Set `ECOSYSTEMS_MAILTO` to a contact address before a full build. The ecosyste.ms API
|
|
69
|
+
allows anonymous callers roughly 5,000 requests an hour and a full build makes around
|
|
70
|
+
9,700; a build that identifies itself gets roughly 15,000. Without it most of the version
|
|
71
|
+
fetches are rate-limited away, and `build()` throws rather than publish a database that
|
|
72
|
+
lost more than 5% of its versions.
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
ECOSYSTEMS_MAILTO=you@example.com npx @ecosyste-ms/critical
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Development
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm ci
|
|
82
|
+
npm run build # tsc: lib/*.ts -> dist/
|
|
83
|
+
npm test # vitest, against lib/ sources -- no build needed
|
|
84
|
+
npm run test:packaged # packs the tarball and tests what it ships
|
|
85
|
+
npm run lint # biome check + tsc --noEmit
|
|
86
|
+
npm run format # biome check --write
|
|
87
|
+
npm run build:db # build critical-packages.db from the API (what the daily job runs)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The default suite runs against `lib/` and needs no build step. `test:packaged` is
|
|
91
|
+
separate because it builds, runs `npm pack`, and exercises the extracted tarball's
|
|
92
|
+
entrypoint, export surface and `bin` — the checks that catch a broken `exports` map
|
|
93
|
+
rather than broken logic.
|
|
94
|
+
|
|
95
|
+
The published package is plain JavaScript, so consumers stay on the Node >= 22.5.0 floor
|
|
96
|
+
that `node:sqlite` sets.
|
|
97
|
+
|
|
55
98
|
## Schema
|
|
56
99
|
|
|
57
100
|
### packages
|
package/critical-packages.db.gz
CHANGED
|
Binary file
|
package/dist/bin.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../lib/bin.ts"],"names":[],"mappings":""}
|
package/dist/bin.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { DatabaseSync } from "node:sqlite";
|
|
4
|
+
import { build, databasePath } from "./index.js";
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
7
|
+
console.log(`Usage: critical [options]
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--output, -o <path> Output database path (default: critical-packages.db)
|
|
11
|
+
--skip-versions Skip fetching version data (faster)
|
|
12
|
+
--stats Show database statistics
|
|
13
|
+
--help, -h Show this help message
|
|
14
|
+
|
|
15
|
+
Environment:
|
|
16
|
+
ECOSYSTEMS_MAILTO Contact address sent to the API. Without it the build runs on
|
|
17
|
+
the anonymous rate-limit tier (~5,000 requests/hour against
|
|
18
|
+
~9,700 needed) and aborts once too many version fetches fail.
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
npx @ecosyste-ms/critical
|
|
22
|
+
npx @ecosyste-ms/critical --skip-versions
|
|
23
|
+
npx @ecosyste-ms/critical -o my-database.db
|
|
24
|
+
npx @ecosyste-ms/critical --stats`);
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
function getArg(flags) {
|
|
28
|
+
for (const flag of flags) {
|
|
29
|
+
const idx = args.indexOf(flag);
|
|
30
|
+
if (idx !== -1 && args[idx + 1]) {
|
|
31
|
+
return args[idx + 1] ?? null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const dbPath = getArg(["--output", "-o"]) || "critical-packages.db";
|
|
37
|
+
if (args.includes("--stats")) {
|
|
38
|
+
const path = existsSync(dbPath) ? dbPath : databasePath;
|
|
39
|
+
if (!existsSync(path)) {
|
|
40
|
+
console.error(`Database not found: ${path}`);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
44
|
+
const info = db.prepare("SELECT * FROM build_info WHERE id = 1").get();
|
|
45
|
+
const count = (table) => Number(db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get()?.count ?? 0);
|
|
46
|
+
const packageCount = count("packages");
|
|
47
|
+
const versionCount = count("versions");
|
|
48
|
+
const advisoryCount = count("advisories");
|
|
49
|
+
const ecosystems = db
|
|
50
|
+
.prepare(`
|
|
51
|
+
SELECT ecosystem, COUNT(*) as count
|
|
52
|
+
FROM packages
|
|
53
|
+
GROUP BY ecosystem
|
|
54
|
+
ORDER BY count DESC
|
|
55
|
+
`)
|
|
56
|
+
.all();
|
|
57
|
+
const severities = db
|
|
58
|
+
.prepare(`
|
|
59
|
+
SELECT severity, COUNT(*) as count
|
|
60
|
+
FROM advisories
|
|
61
|
+
GROUP BY severity
|
|
62
|
+
ORDER BY count DESC
|
|
63
|
+
`)
|
|
64
|
+
.all();
|
|
65
|
+
console.log(`Database: ${path}`);
|
|
66
|
+
console.log(`Built: ${info?.built_at || "unknown"}`);
|
|
67
|
+
console.log(`\nPackages: ${packageCount}`);
|
|
68
|
+
console.log(`Versions: ${versionCount}`);
|
|
69
|
+
console.log(`Advisories: ${advisoryCount}`);
|
|
70
|
+
console.log(`\nBy ecosystem:`);
|
|
71
|
+
for (const e of ecosystems) {
|
|
72
|
+
console.log(` ${e.ecosystem}: ${e.count}`);
|
|
73
|
+
}
|
|
74
|
+
console.log(`\nBy severity:`);
|
|
75
|
+
for (const s of severities) {
|
|
76
|
+
console.log(` ${s.severity || "unknown"}: ${s.count}`);
|
|
77
|
+
}
|
|
78
|
+
db.close();
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
const skipVersions = args.includes("--skip-versions");
|
|
82
|
+
build({
|
|
83
|
+
dbPath,
|
|
84
|
+
fetchVersionsData: !skipVersions,
|
|
85
|
+
})
|
|
86
|
+
.then(() => process.exit(0))
|
|
87
|
+
.catch((err) => {
|
|
88
|
+
console.error(err);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
});
|
|
91
|
+
//# sourceMappingURL=bin.js.map
|
package/dist/bin.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bin.js","sourceRoot":"","sources":["../lib/bin.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAkB,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAEjE,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AAEnC,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;IACnD,OAAO,CAAC,GAAG,CAAC;;;;;;;;;;;;;;;;;oCAiBsB,CAAC,CAAC;IACpC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,SAAS,MAAM,CAAC,KAAe;IAC7B,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;QAC/B,IAAI,GAAG,KAAK,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,GAAG,CAAC,CAAC,EAAE,CAAC;YAChC,OAAO,IAAI,CAAC,GAAG,GAAG,CAAC,CAAC,IAAI,IAAI,CAAC;QAC/B,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,MAAM,GAAG,MAAM,CAAC,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC,IAAI,sBAAsB,CAAC;AAEpE,IAAI,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC;IAC7B,MAAM,IAAI,GAAG,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,YAAY,CAAC;IACxD,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC;QACtB,OAAO,CAAC,KAAK,CAAC,uBAAuB,IAAI,EAAE,CAAC,CAAC;QAC7C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,MAAM,EAAE,GAAG,IAAI,YAAY,CAAC,IAAI,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAC;IACtD,MAAM,IAAI,GAAG,EAAE,CAAC,OAAO,CAAC,uCAAuC,CAAC,CAAC,GAAG,EAEvD,CAAC;IACd,MAAM,KAAK,GAAG,CAAC,KAAa,EAAE,EAAE,CAC9B,MAAM,CAAC,EAAE,CAAC,OAAO,CAAC,iCAAiC,KAAK,EAAE,CAAC,CAAC,GAAG,EAAE,EAAE,KAAK,IAAI,CAAC,CAAC,CAAC;IACjF,MAAM,YAAY,GAAG,KAAK,CAAC,UAAU,CAAC,CAAC;IACvC,MAAM,YAAY,GAAG,KAAK,CAAC,UAAU,CAAC,CAAC;IACvC,MAAM,aAAa,GAAG,KAAK,CAAC,YAAY,CAAC,CAAC;IAC1C,MAAM,UAAU,GAAG,EAAE;SAClB,OAAO,CAAC;;;;;GAKV,CAAC;SACC,GAAG,EAAuD,CAAC;IAC9D,MAAM,UAAU,GAAG,EAAE;SAClB,OAAO,CAAC;;;;;GAKV,CAAC;SACC,GAAG,EAA6D,CAAC;IAEpE,OAAO,CAAC,GAAG,CAAC,aAAa,IAAI,EAAE,CAAC,CAAC;IACjC,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,EAAE,QAAQ,IAAI,SAAS,EAAE,CAAC,CAAC;IACrD,OAAO,CAAC,GAAG,CAAC,eAAe,YAAY,EAAE,CAAC,CAAC;IAC3C,OAAO,CAAC,GAAG,CAAC,aAAa,YAAY,EAAE,CAAC,CAAC;IACzC,OAAO,CAAC,GAAG,CAAC,eAAe,aAAa,EAAE,CAAC,CAAC;IAC5C,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAC/B,KAAK,MAAM,CAAC,IAAI,UAAU,EAAE,CAAC;QAC3B,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,SAAS,KAAK,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;IAC9C,CAAC;IACD,OAAO,CAAC,GAAG,CAAC,gBAAgB,CAAC,CAAC;IAC9B,KAAK,MAAM,CAAC,IAAI,UAAU,EAAE,CAAC;QAC3B,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,QAAQ,IAAI,SAAS,KAAK,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;IAC1D,CAAC;IACD,EAAE,CAAC,KAAK,EAAE,CAAC;IACX,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC;AAED,MAAM,YAAY,GAAG,IAAI,CAAC,QAAQ,CAAC,iBAAiB,CAAC,CAAC;AAEtD,KAAK,CAAC;IACJ,MAAM;IACN,iBAAiB,EAAE,CAAC,YAAY;CACjC,CAAC;KACC,IAAI,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;KAC3B,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;IACtB,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACnB,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { DatabaseSync } from "node:sqlite";
|
|
2
|
+
import { type EcosystemsClient, type PackageWithRegistry } from "@ecosyste-ms/ecosystems-ts";
|
|
3
|
+
declare const databasePath: string;
|
|
4
|
+
/**
|
|
5
|
+
* Every field optional and nullable.
|
|
6
|
+
*
|
|
7
|
+
* The OpenAPI spec marks most package fields required and non-null; the API omits them
|
|
8
|
+
* and sends explicit nulls. Modelling what actually arrives is what keeps the runtime
|
|
9
|
+
* guards below honest instead of looking redundant.
|
|
10
|
+
*/
|
|
11
|
+
type Loose<T> = {
|
|
12
|
+
[K in keyof T]?: T[K] | null;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Repository metadata as `GET /packages/critical` serves it -- every field optional.
|
|
16
|
+
*
|
|
17
|
+
* The API sends `full_name` ("owner/repo") but never `name`, and nests the host here
|
|
18
|
+
* rather than on the package. Both were previously read from the wrong place, which left
|
|
19
|
+
* `repo_metadata.repo_name` and `repo_metadata.host` NULL in every published build.
|
|
20
|
+
*/
|
|
21
|
+
export interface ApiRepoMetadata {
|
|
22
|
+
owner?: string | null;
|
|
23
|
+
name?: string | null;
|
|
24
|
+
full_name?: string | null;
|
|
25
|
+
language?: string | null;
|
|
26
|
+
stargazers_count?: number | null;
|
|
27
|
+
forks_count?: number | null;
|
|
28
|
+
open_issues_count?: number | null;
|
|
29
|
+
archived?: boolean | null;
|
|
30
|
+
fork?: boolean | null;
|
|
31
|
+
host?: ApiHost | null;
|
|
32
|
+
}
|
|
33
|
+
/** The registry host, as nested inside `repo_metadata`. */
|
|
34
|
+
export interface ApiHost {
|
|
35
|
+
name?: string | null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* An advisory as the API nests it in a package.
|
|
39
|
+
*
|
|
40
|
+
* Derived from the package schema rather than the SDK's top-level `Advisory`: the two
|
|
41
|
+
* specs describe the same objects differently -- `advisories.ecosyste.ms` types the
|
|
42
|
+
* optional fields `string | undefined`, while the copy embedded in a package uses
|
|
43
|
+
* `string | null`, which is what the API actually sends.
|
|
44
|
+
*/
|
|
45
|
+
type PackageAdvisory = NonNullable<PackageWithRegistry["advisories"]>[number];
|
|
46
|
+
export type ApiAdvisory = Loose<PackageAdvisory>;
|
|
47
|
+
/**
|
|
48
|
+
* A package from `GET /packages/critical`, as the SDK types it.
|
|
49
|
+
*
|
|
50
|
+
* `repo_metadata` is overridden: the OpenAPI spec types it `Record<string, never>` -- a
|
|
51
|
+
* known-opaque object -- while this package reads nine fields out of it. Everything else
|
|
52
|
+
* is the generated shape.
|
|
53
|
+
*
|
|
54
|
+
* The spec is stricter than the API. It declares `normalized_licenses`, `purl` and
|
|
55
|
+
* `keywords_array` required and non-null; they are not always sent. Every runtime guard
|
|
56
|
+
* below stays regardless of what these types claim.
|
|
57
|
+
*/
|
|
58
|
+
export type ApiPackage = Loose<Omit<PackageWithRegistry, "repo_metadata" | "advisories">> & Pick<PackageWithRegistry, "id" | "ecosystem" | "name"> & {
|
|
59
|
+
advisories?: (ApiAdvisory | null)[] | null;
|
|
60
|
+
repo_metadata?: ApiRepoMetadata | null;
|
|
61
|
+
};
|
|
62
|
+
/** The single `build_info` row, written at the end of every build. */
|
|
63
|
+
export interface BuildInfo {
|
|
64
|
+
built_at: string;
|
|
65
|
+
package_count: number;
|
|
66
|
+
version_count: number;
|
|
67
|
+
advisory_count: number;
|
|
68
|
+
}
|
|
69
|
+
export type ProgressReporter = (message: string) => void;
|
|
70
|
+
export interface BuildOptions {
|
|
71
|
+
dbPath?: string;
|
|
72
|
+
fetchVersionsData?: boolean;
|
|
73
|
+
onProgress?: ProgressReporter;
|
|
74
|
+
/**
|
|
75
|
+
* The API client. Defaults to the shared one; a substitute lets the tests drive the
|
|
76
|
+
* fetch loop's failure accounting without a network.
|
|
77
|
+
*/
|
|
78
|
+
client?: Pick<EcosystemsClient, "listCriticalPackages" | "getVersionNumbers">;
|
|
79
|
+
}
|
|
80
|
+
declare function extractDatabase(source: string, destination: string): Promise<void>;
|
|
81
|
+
declare function registryFor(pkg: {
|
|
82
|
+
ecosystem?: string | null;
|
|
83
|
+
purl?: string | null;
|
|
84
|
+
}): string | null;
|
|
85
|
+
declare function fetchAllCriticalPackages(onProgress?: ProgressReporter, api?: Pick<EcosystemsClient, "listCriticalPackages">): Promise<ApiPackage[]>;
|
|
86
|
+
/** Version numbers for a package, or `[]` when there is no registry to ask. Throws on
|
|
87
|
+
* transport failure -- the caller decides whether one package's loss is tolerable. */
|
|
88
|
+
declare function fetchVersionNumbers(ecosystem: string, name: string): Promise<string[]>;
|
|
89
|
+
declare function createDatabase(dbPath: string): DatabaseSync;
|
|
90
|
+
declare function insertPackage(db: DatabaseSync, pkg: ApiPackage): number;
|
|
91
|
+
declare function insertRepoMetadata(db: DatabaseSync, packageId: number, repoMetadata: ApiRepoMetadata | null | undefined, host?: ApiHost | null): void;
|
|
92
|
+
declare function insertAdvisories(db: DatabaseSync, packageId: number, advisories: (ApiAdvisory | null | undefined)[] | null | undefined): void;
|
|
93
|
+
declare function insertVersions(db: DatabaseSync, packageId: number, versionNumbers: string[] | null | undefined): void;
|
|
94
|
+
declare function build(options?: BuildOptions): Promise<BuildInfo>;
|
|
95
|
+
export { build, createDatabase, databasePath, extractDatabase, fetchAllCriticalPackages, fetchVersionNumbers, insertAdvisories, insertPackage, insertRepoMetadata, insertVersions, registryFor, };
|
|
96
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../lib/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream, existsSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { DatabaseSync } from "node:sqlite";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { createGunzip } from "node:zlib";
|
|
9
|
+
import { createEcosystemsClient, PURL_TYPE_TO_REGISTRY, parsePurl, purlToRegistry, } from "@ecosyste-ms/ecosystems-ts";
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const pkg = JSON.parse(await readFile(join(__dirname, "..", "package.json"), "utf8"));
|
|
12
|
+
const databasePath = join(__dirname, "..", "critical-packages.db");
|
|
13
|
+
const gzPath = join(__dirname, "..", "critical-packages.db.gz");
|
|
14
|
+
// Extract via a uniquely named temp file, then rename into place. rename(2) is atomic
|
|
15
|
+
// within a filesystem, so concurrent importers see either no database or a complete
|
|
16
|
+
// one. Streaming straight to databasePath let them interleave and produce a file
|
|
17
|
+
// SQLite rejects with "database disk image is malformed" -- permanently, since the
|
|
18
|
+
// corrupt file then satisfies the existsSync check on every subsequent run.
|
|
19
|
+
async function extractDatabase(source, destination) {
|
|
20
|
+
const tmpPath = `${destination}.${process.pid}-${randomBytes(6).toString("hex")}.tmp`;
|
|
21
|
+
try {
|
|
22
|
+
await pipeline(createReadStream(source), createGunzip(), createWriteStream(tmpPath));
|
|
23
|
+
await rename(tmpPath, destination);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
await unlink(tmpPath).catch(() => { });
|
|
27
|
+
// Another importer finished first and holds the destination open; on Windows that
|
|
28
|
+
// makes the rename fail. A complete database is there either way, so this is a win.
|
|
29
|
+
if (!existsSync(destination))
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!existsSync(databasePath) && existsSync(gzPath)) {
|
|
34
|
+
await extractDatabase(gzPath, databasePath);
|
|
35
|
+
}
|
|
36
|
+
const CONCURRENCY = 10;
|
|
37
|
+
/** Above this share of failed version fetches the build throws instead of publishing. */
|
|
38
|
+
const MAX_VERSION_FAILURE_RATE = 0.05;
|
|
39
|
+
/**
|
|
40
|
+
* The mailto that moves the build off the anonymous rate-limit tier.
|
|
41
|
+
*
|
|
42
|
+
* Anonymous is 5,000 requests/hour and a full build makes ~9,700, so an unidentified
|
|
43
|
+
* build gets throttled partway through. With a contact address the tier is "polite",
|
|
44
|
+
* 15,000.
|
|
45
|
+
*/
|
|
46
|
+
const CONTACT_EMAIL = process.env.ECOSYSTEMS_MAILTO?.trim() || undefined;
|
|
47
|
+
let client;
|
|
48
|
+
/** The SDK client, created once. Owns retries, backoff, 429/Retry-After and pagination. */
|
|
49
|
+
function ecosystemsClient() {
|
|
50
|
+
if (!client) {
|
|
51
|
+
client = createEcosystemsClient({
|
|
52
|
+
userAgent: `${pkg.name}/${pkg.version}`,
|
|
53
|
+
from: CONTACT_EMAIL,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return client;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The ecosyste.ms registry for a package, or null when there is nothing to query.
|
|
60
|
+
*
|
|
61
|
+
* Routed through the PURL where there is one: the SDK's table is keyed by PURL type, and
|
|
62
|
+
* that is the vocabulary the API sends.
|
|
63
|
+
*
|
|
64
|
+
* `ecosystem` is the fallback for a package with no PURL, translated through
|
|
65
|
+
* `ECOSYSTEM_TO_PURL_TYPE` first -- the two vocabularies disagree for four of the
|
|
66
|
+
* ecosystems here (`rubygems` vs `gem`, `go` vs `golang`, `packagist` vs `composer`,
|
|
67
|
+
* `homebrew` vs `brew`), and an unresolved registry means zero versions fetched with the
|
|
68
|
+
* build still reporting success.
|
|
69
|
+
*/
|
|
70
|
+
const ECOSYSTEM_TO_PURL_TYPE = {
|
|
71
|
+
rubygems: "gem",
|
|
72
|
+
go: "golang",
|
|
73
|
+
packagist: "composer",
|
|
74
|
+
homebrew: "brew",
|
|
75
|
+
};
|
|
76
|
+
function registryFor(pkg) {
|
|
77
|
+
if (pkg.purl) {
|
|
78
|
+
try {
|
|
79
|
+
return purlToRegistry(parsePurl(pkg.purl));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// Malformed PURL: fall through to the ecosystem string.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const ecosystem = pkg.ecosystem?.toLowerCase();
|
|
86
|
+
if (!ecosystem)
|
|
87
|
+
return null;
|
|
88
|
+
const key = ECOSYSTEM_TO_PURL_TYPE[ecosystem] ?? ecosystem;
|
|
89
|
+
// Object.hasOwn, not a bare lookup: "constructor" is a valid key and would otherwise
|
|
90
|
+
// return Object itself -- truthy, typed string, and interpolated into a URL path.
|
|
91
|
+
const registry = Object.hasOwn(PURL_TYPE_TO_REGISTRY, key)
|
|
92
|
+
? PURL_TYPE_TO_REGISTRY[key]
|
|
93
|
+
: undefined;
|
|
94
|
+
return registry || null;
|
|
95
|
+
}
|
|
96
|
+
async function fetchAllCriticalPackages(onProgress, api = ecosystemsClient()) {
|
|
97
|
+
onProgress?.("Fetching critical packages...");
|
|
98
|
+
const packages = await api.listCriticalPackages();
|
|
99
|
+
return packages;
|
|
100
|
+
}
|
|
101
|
+
/** Version numbers for a package, or `[]` when there is no registry to ask. Throws on
|
|
102
|
+
* transport failure -- the caller decides whether one package's loss is tolerable. */
|
|
103
|
+
async function fetchVersionNumbers(ecosystem, name) {
|
|
104
|
+
const registry = registryFor({ ecosystem });
|
|
105
|
+
if (!registry)
|
|
106
|
+
return [];
|
|
107
|
+
return await ecosystemsClient().getVersionNumbers(registry, name);
|
|
108
|
+
}
|
|
109
|
+
function createDatabase(dbPath) {
|
|
110
|
+
const db = new DatabaseSync(dbPath);
|
|
111
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
112
|
+
db.exec(`
|
|
113
|
+
CREATE TABLE packages (
|
|
114
|
+
id INTEGER PRIMARY KEY,
|
|
115
|
+
ecosystem TEXT NOT NULL,
|
|
116
|
+
name TEXT NOT NULL,
|
|
117
|
+
purl TEXT,
|
|
118
|
+
namespace TEXT,
|
|
119
|
+
description TEXT,
|
|
120
|
+
homepage TEXT,
|
|
121
|
+
repository_url TEXT,
|
|
122
|
+
licenses TEXT,
|
|
123
|
+
normalized_licenses TEXT,
|
|
124
|
+
latest_version TEXT,
|
|
125
|
+
versions_count INTEGER,
|
|
126
|
+
downloads INTEGER,
|
|
127
|
+
downloads_period TEXT,
|
|
128
|
+
dependent_packages_count INTEGER,
|
|
129
|
+
dependent_repos_count INTEGER,
|
|
130
|
+
first_release_at TEXT,
|
|
131
|
+
latest_release_at TEXT,
|
|
132
|
+
last_synced_at TEXT,
|
|
133
|
+
keywords TEXT,
|
|
134
|
+
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
CREATE UNIQUE INDEX idx_packages_ecosystem_name ON packages(ecosystem, name);
|
|
138
|
+
CREATE INDEX idx_packages_purl ON packages(purl);
|
|
139
|
+
CREATE INDEX idx_packages_licenses ON packages(licenses);
|
|
140
|
+
CREATE INDEX idx_packages_ecosystem ON packages(ecosystem);
|
|
141
|
+
|
|
142
|
+
CREATE TABLE versions (
|
|
143
|
+
package_id INTEGER NOT NULL,
|
|
144
|
+
number TEXT NOT NULL,
|
|
145
|
+
PRIMARY KEY (package_id, number),
|
|
146
|
+
FOREIGN KEY (package_id) REFERENCES packages(id)
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
CREATE TABLE advisories (
|
|
150
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
151
|
+
package_id INTEGER NOT NULL,
|
|
152
|
+
uuid TEXT NOT NULL,
|
|
153
|
+
url TEXT,
|
|
154
|
+
title TEXT,
|
|
155
|
+
description TEXT,
|
|
156
|
+
severity TEXT,
|
|
157
|
+
published_at TEXT,
|
|
158
|
+
cvss_score REAL,
|
|
159
|
+
FOREIGN KEY (package_id) REFERENCES packages(id)
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
CREATE INDEX idx_advisories_package_id ON advisories(package_id);
|
|
163
|
+
CREATE INDEX idx_advisories_uuid ON advisories(uuid);
|
|
164
|
+
CREATE INDEX idx_advisories_severity ON advisories(severity);
|
|
165
|
+
CREATE UNIQUE INDEX idx_advisories_package_uuid ON advisories(package_id, uuid);
|
|
166
|
+
|
|
167
|
+
CREATE TABLE repo_metadata (
|
|
168
|
+
package_id INTEGER PRIMARY KEY,
|
|
169
|
+
owner TEXT,
|
|
170
|
+
repo_name TEXT,
|
|
171
|
+
full_name TEXT,
|
|
172
|
+
host TEXT,
|
|
173
|
+
language TEXT,
|
|
174
|
+
stargazers_count INTEGER,
|
|
175
|
+
forks_count INTEGER,
|
|
176
|
+
open_issues_count INTEGER,
|
|
177
|
+
archived INTEGER,
|
|
178
|
+
fork INTEGER,
|
|
179
|
+
FOREIGN KEY (package_id) REFERENCES packages(id)
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
CREATE INDEX idx_repo_full_name ON repo_metadata(full_name);
|
|
183
|
+
CREATE INDEX idx_repo_owner ON repo_metadata(owner);
|
|
184
|
+
|
|
185
|
+
CREATE VIRTUAL TABLE packages_fts USING fts5(
|
|
186
|
+
ecosystem,
|
|
187
|
+
name,
|
|
188
|
+
description,
|
|
189
|
+
keywords,
|
|
190
|
+
content=packages,
|
|
191
|
+
content_rowid=id
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
CREATE TRIGGER packages_ai AFTER INSERT ON packages BEGIN
|
|
195
|
+
INSERT INTO packages_fts(rowid, ecosystem, name, description, keywords)
|
|
196
|
+
VALUES (new.id, new.ecosystem, new.name, new.description, new.keywords);
|
|
197
|
+
END;
|
|
198
|
+
|
|
199
|
+
CREATE TRIGGER packages_ad AFTER DELETE ON packages BEGIN
|
|
200
|
+
INSERT INTO packages_fts(packages_fts, rowid, ecosystem, name, description, keywords)
|
|
201
|
+
VALUES ('delete', old.id, old.ecosystem, old.name, old.description, old.keywords);
|
|
202
|
+
END;
|
|
203
|
+
|
|
204
|
+
CREATE TRIGGER packages_au AFTER UPDATE ON packages BEGIN
|
|
205
|
+
INSERT INTO packages_fts(packages_fts, rowid, ecosystem, name, description, keywords)
|
|
206
|
+
VALUES ('delete', old.id, old.ecosystem, old.name, old.description, old.keywords);
|
|
207
|
+
INSERT INTO packages_fts(rowid, ecosystem, name, description, keywords)
|
|
208
|
+
VALUES (new.id, new.ecosystem, new.name, new.description, new.keywords);
|
|
209
|
+
END;
|
|
210
|
+
|
|
211
|
+
CREATE TABLE build_info (
|
|
212
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
213
|
+
built_at TEXT NOT NULL,
|
|
214
|
+
package_count INTEGER,
|
|
215
|
+
version_count INTEGER,
|
|
216
|
+
advisory_count INTEGER
|
|
217
|
+
);
|
|
218
|
+
`);
|
|
219
|
+
return db;
|
|
220
|
+
}
|
|
221
|
+
function insertPackage(db, pkg) {
|
|
222
|
+
const stmt = db.prepare(`
|
|
223
|
+
INSERT OR REPLACE INTO packages (
|
|
224
|
+
id, ecosystem, name, purl, namespace, description, homepage,
|
|
225
|
+
repository_url, licenses, normalized_licenses, latest_version,
|
|
226
|
+
versions_count, downloads, downloads_period, dependent_packages_count,
|
|
227
|
+
dependent_repos_count, first_release_at, latest_release_at,
|
|
228
|
+
last_synced_at, keywords
|
|
229
|
+
) VALUES (
|
|
230
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
231
|
+
)
|
|
232
|
+
`);
|
|
233
|
+
const keywords = Array.isArray(pkg.keywords_array) ? pkg.keywords_array.join(" ") : null;
|
|
234
|
+
// Not JSON.stringify alone: it returns undefined for undefined, which node:sqlite
|
|
235
|
+
// refuses to bind, and the literal string "null" for null.
|
|
236
|
+
const normalizedLicenses = Array.isArray(pkg.normalized_licenses)
|
|
237
|
+
? JSON.stringify(pkg.normalized_licenses)
|
|
238
|
+
: null;
|
|
239
|
+
stmt.run(pkg.id, pkg.ecosystem, pkg.name, pkg.purl ?? null, pkg.namespace ?? null, pkg.description ?? null, pkg.homepage ?? null, pkg.repository_url ?? null, pkg.licenses ?? null, normalizedLicenses, pkg.latest_release_number ?? null, pkg.versions_count ?? null, pkg.downloads ?? null, pkg.downloads_period ?? null, pkg.dependent_packages_count ?? null, pkg.dependent_repos_count ?? null, pkg.first_release_published_at ?? null, pkg.latest_release_published_at ?? null, pkg.last_synced_at ?? null, keywords);
|
|
240
|
+
return pkg.id;
|
|
241
|
+
}
|
|
242
|
+
function insertRepoMetadata(db, packageId, repoMetadata, host) {
|
|
243
|
+
if (!repoMetadata)
|
|
244
|
+
return;
|
|
245
|
+
// The explicit argument stays for callers that have a host from somewhere else.
|
|
246
|
+
const hostName = host?.name ?? repoMetadata.host?.name ?? null;
|
|
247
|
+
const repoName = repoMetadata.name ?? repoMetadata.full_name?.split("/").pop() ?? null;
|
|
248
|
+
const stmt = db.prepare(`
|
|
249
|
+
INSERT OR REPLACE INTO repo_metadata (
|
|
250
|
+
package_id, owner, repo_name, full_name, host, language,
|
|
251
|
+
stargazers_count, forks_count, open_issues_count, archived, fork
|
|
252
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
253
|
+
`);
|
|
254
|
+
stmt.run(packageId, repoMetadata.owner ?? null, repoName, repoMetadata.full_name ?? null, hostName, repoMetadata.language ?? null, repoMetadata.stargazers_count ?? null, repoMetadata.forks_count ?? null, repoMetadata.open_issues_count ?? null, repoMetadata.archived ? 1 : 0, repoMetadata.fork ? 1 : 0);
|
|
255
|
+
}
|
|
256
|
+
function insertAdvisories(db, packageId, advisories) {
|
|
257
|
+
if (!advisories || advisories.length === 0)
|
|
258
|
+
return;
|
|
259
|
+
const stmt = db.prepare(`
|
|
260
|
+
INSERT OR REPLACE INTO advisories (
|
|
261
|
+
package_id, uuid, url, title, description, severity, published_at, cvss_score
|
|
262
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
263
|
+
`);
|
|
264
|
+
for (const advisory of advisories) {
|
|
265
|
+
if (!advisory?.uuid)
|
|
266
|
+
continue;
|
|
267
|
+
stmt.run(packageId, advisory.uuid, advisory.url ?? null, advisory.title ?? null, advisory.description ?? null, advisory.severity ?? null, advisory.published_at ?? null, advisory.cvss_score ?? null);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function insertVersions(db, packageId, versionNumbers) {
|
|
271
|
+
if (!versionNumbers || versionNumbers.length === 0)
|
|
272
|
+
return;
|
|
273
|
+
const stmt = db.prepare(`
|
|
274
|
+
INSERT OR REPLACE INTO versions (package_id, number) VALUES (?, ?)
|
|
275
|
+
`);
|
|
276
|
+
for (const number of versionNumbers) {
|
|
277
|
+
stmt.run(packageId, number);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** `SELECT COUNT(*)`, narrowed: node:sqlite hands back `Record<string, SQLOutputValue>`. */
|
|
281
|
+
function countRows(db, table) {
|
|
282
|
+
const row = db.prepare(`SELECT COUNT(*) as count FROM ${table}`).get();
|
|
283
|
+
return Number(row?.count ?? 0);
|
|
284
|
+
}
|
|
285
|
+
function updateBuildInfo(db) {
|
|
286
|
+
db.prepare(`
|
|
287
|
+
INSERT OR REPLACE INTO build_info (id, built_at, package_count, version_count, advisory_count)
|
|
288
|
+
VALUES (1, ?, ?, ?, ?)
|
|
289
|
+
`).run(new Date().toISOString(), countRows(db, "packages"), countRows(db, "versions"), countRows(db, "advisories"));
|
|
290
|
+
}
|
|
291
|
+
async function build(options = {}) {
|
|
292
|
+
const { dbPath = "critical-packages.db", fetchVersionsData = true, onProgress = console.log, client: api = ecosystemsClient(), } = options;
|
|
293
|
+
await mkdir(dirname(dbPath) || ".", { recursive: true }).catch(() => { });
|
|
294
|
+
await unlink(dbPath).catch(() => { });
|
|
295
|
+
await unlink(`${dbPath}-wal`).catch(() => { });
|
|
296
|
+
await unlink(`${dbPath}-shm`).catch(() => { });
|
|
297
|
+
onProgress("Creating database...");
|
|
298
|
+
const db = createDatabase(dbPath);
|
|
299
|
+
// Otherwise every throw below leaks the handle, which on Windows locks the file the
|
|
300
|
+
// caller is about to clean up. The version-failure threshold makes that a reachable
|
|
301
|
+
// path, not just a corrupt-data one.
|
|
302
|
+
try {
|
|
303
|
+
onProgress("Fetching critical packages...");
|
|
304
|
+
const packages = await fetchAllCriticalPackages(onProgress, api);
|
|
305
|
+
onProgress(`Found ${packages.length} critical packages`);
|
|
306
|
+
onProgress("Inserting packages...");
|
|
307
|
+
db.exec("BEGIN");
|
|
308
|
+
try {
|
|
309
|
+
for (const pkg of packages) {
|
|
310
|
+
insertPackage(db, pkg);
|
|
311
|
+
insertRepoMetadata(db, pkg.id, pkg.repo_metadata);
|
|
312
|
+
insertAdvisories(db, pkg.id, pkg.advisories);
|
|
313
|
+
}
|
|
314
|
+
db.exec("COMMIT");
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
db.exec("ROLLBACK");
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
if (fetchVersionsData) {
|
|
321
|
+
onProgress("Fetching versions...");
|
|
322
|
+
let completed = 0;
|
|
323
|
+
const total = packages.length;
|
|
324
|
+
// One package failing is tolerable; a pattern of failures means the run was
|
|
325
|
+
// throttled. Count them and report at the end rather than swallowing each one.
|
|
326
|
+
let failed = 0;
|
|
327
|
+
let firstError;
|
|
328
|
+
const processPackage = async (pkg) => {
|
|
329
|
+
const registry = registryFor(pkg);
|
|
330
|
+
if (!registry)
|
|
331
|
+
return { pkg, versions: [] };
|
|
332
|
+
try {
|
|
333
|
+
const versions = await api.getVersionNumbers(registry, pkg.name);
|
|
334
|
+
return { pkg, versions };
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
failed++;
|
|
338
|
+
firstError ??= err;
|
|
339
|
+
return { pkg, versions: [] };
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
// Process in batches with concurrency limit
|
|
343
|
+
for (let i = 0; i < packages.length; i += CONCURRENCY) {
|
|
344
|
+
const batch = packages.slice(i, i + CONCURRENCY);
|
|
345
|
+
const results = await Promise.all(batch.map(processPackage));
|
|
346
|
+
db.exec("BEGIN");
|
|
347
|
+
try {
|
|
348
|
+
for (const { pkg, versions } of results) {
|
|
349
|
+
if (versions.length > 0) {
|
|
350
|
+
insertVersions(db, pkg.id, versions);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
db.exec("COMMIT");
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
db.exec("ROLLBACK");
|
|
357
|
+
throw err;
|
|
358
|
+
}
|
|
359
|
+
completed += batch.length;
|
|
360
|
+
onProgress(`Fetched versions for ${completed}/${total} packages`);
|
|
361
|
+
}
|
|
362
|
+
if (failed > 0) {
|
|
363
|
+
const share = ((failed / total) * 100).toFixed(1);
|
|
364
|
+
const reason = firstError instanceof Error ? firstError.message : String(firstError);
|
|
365
|
+
onProgress(`WARNING: version fetch failed for ${failed}/${total} packages (${share}%)`);
|
|
366
|
+
onProgress(` first failure: ${reason}`);
|
|
367
|
+
// A run that loses this much is not a build worth publishing.
|
|
368
|
+
if (failed / total > MAX_VERSION_FAILURE_RATE) {
|
|
369
|
+
throw new Error(`version fetch failed for ${share}% of packages (limit ${(MAX_VERSION_FAILURE_RATE * 100).toFixed(0)}%); first failure: ${reason}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
updateBuildInfo(db);
|
|
374
|
+
const info = db
|
|
375
|
+
.prepare("SELECT * FROM build_info WHERE id = 1")
|
|
376
|
+
.get();
|
|
377
|
+
onProgress(`Build complete: ${info.package_count} packages, ${info.version_count} versions, ${info.advisory_count} advisories`);
|
|
378
|
+
return info;
|
|
379
|
+
}
|
|
380
|
+
finally {
|
|
381
|
+
// The success path returns through here too, and close() is not idempotent.
|
|
382
|
+
db.close();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
export { build, createDatabase, databasePath, extractDatabase, fetchAllCriticalPackages, fetchVersionNumbers, insertAdvisories, insertPackage, insertRepoMetadata, insertVersions, registryFor, };
|
|
386
|
+
//# sourceMappingURL=index.js.map
|