depsentinel 0.1.2
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 +55 -0
- package/dist/advisories/fixture-source.js +25 -0
- package/dist/advisories/live-source.js +83 -0
- package/dist/advisories/source.js +1 -0
- package/dist/cli.js +133 -0
- package/dist/commands/ci.js +59 -0
- package/dist/commands/doctor.js +79 -0
- package/dist/commands/fix.js +101 -0
- package/dist/commands/init.js +143 -0
- package/dist/commands/install.js +83 -0
- package/dist/commands/scan.js +39 -0
- package/dist/commands/trust.js +353 -0
- package/dist/core/detector.js +54 -0
- package/dist/core/doctor-checks.js +206 -0
- package/dist/core/evaluate.js +25 -0
- package/dist/core/install-decision.js +11 -0
- package/dist/core/overrides.js +57 -0
- package/dist/core/risk-score.js +16 -0
- package/dist/core/safe-write.js +43 -0
- package/dist/core/tool-adapters.js +87 -0
- package/dist/formatters/human.js +39 -0
- package/dist/formatters/json.js +6 -0
- package/dist/policies/catalog.v1.js +123 -0
- package/dist/types/contracts.js +1 -0
- package/docs/security-best-practices.md +202 -0
- package/package.json +55 -0
package/README.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# depsentinel
|
|
2
|
+
|
|
3
|
+
**JS/TS supply-chain hardening CLI.** Detect your package manager, evaluate risk, and apply secure defaults — in one command.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx depsentinel scan # diagnose your project
|
|
9
|
+
npx depsentinel init --write # generate secure baseline
|
|
10
|
+
npx depsentinel ci --json # fail CI if critical policies violated
|
|
11
|
+
npx depsentinel install express # check package safety before adding it
|
|
12
|
+
npx depsentinel doctor # full 26-point security diagnosis
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
| Command | Purpose |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `scan` | Detect PM, lockfile, framework; return `risk_score` + `remediation_commands` |
|
|
20
|
+
| `init --write` | Generate `.npmrc`, `.npmignore`, CI workflow, PM-specific configs |
|
|
21
|
+
| `ci --json` | Policy gate for CI pipelines; exits non-zero on critical findings |
|
|
22
|
+
| `install <pkg>` | Preflight check before adding a dependency (`allow\|warn\|block`) |
|
|
23
|
+
| `doctor` | Diagnose project against 26 npm security best practices |
|
|
24
|
+
| `fix --write` | Auto-apply known remediations (`.npmrc`, scripts, configs) |
|
|
25
|
+
| `trust add\|remove\|list` | Manage allow/ignore build-script trust per package manager |
|
|
26
|
+
| `override add\|remove\|list` | Manage policy exceptions with reason and expiration |
|
|
27
|
+
|
|
28
|
+
## What it enforces
|
|
29
|
+
|
|
30
|
+
- **Disable post-install scripts** — `.npmrc` `ignore-scripts=true`
|
|
31
|
+
- **Block git-based dependencies** — `.npmrc` `allow-git=none`
|
|
32
|
+
- **Package cooldown** — `.npmrc` `min-release-age=3` (3 days)
|
|
33
|
+
- **pnpm hardening** — `minimumReleaseAge`, `trustPolicy: no-downgrade`, `blockExoticSubdeps`, `strictDepBuilds`
|
|
34
|
+
- **Deterministic CI installs** — frozen lockfile per package manager
|
|
35
|
+
- **Advisory checks** — critical vulnerability matching
|
|
36
|
+
- **Tool adapters** — optional `npq`, `sfw`, `lockfile-lint` integration
|
|
37
|
+
- **Override system** — time-bound exceptions with documented reasons
|
|
38
|
+
|
|
39
|
+
Supports **npm**, **pnpm**, **yarn**, and **bun** — auto-detected. Unknown frameworks degrade gracefully to universal JS/TS baseline.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install -g depsentinel
|
|
45
|
+
# or
|
|
46
|
+
pnpm add -g depsentinel
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Docs
|
|
50
|
+
|
|
51
|
+
Full security best-practices guide with 26-point coverage matrix: [`docs/security-best-practices.md`](docs/security-best-practices.md)
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const FIXTURE_CRITICAL_ADVISORIES = [
|
|
2
|
+
{
|
|
3
|
+
packageName: "vulnerable-lib",
|
|
4
|
+
affectedVersion: "1.0.0",
|
|
5
|
+
advisoryId: "DSA-2026-0001",
|
|
6
|
+
title: "Remote code execution in vulnerable-lib"
|
|
7
|
+
}
|
|
8
|
+
];
|
|
9
|
+
function matchFixture(facts, fixtures) {
|
|
10
|
+
for (const fixture of fixtures) {
|
|
11
|
+
const dependencyVersion = facts.dependencies[fixture.packageName];
|
|
12
|
+
if (dependencyVersion === fixture.affectedVersion) {
|
|
13
|
+
return {
|
|
14
|
+
packageName: fixture.packageName,
|
|
15
|
+
affectedVersion: fixture.affectedVersion,
|
|
16
|
+
advisoryId: fixture.advisoryId,
|
|
17
|
+
title: fixture.title
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export const fixtureAdvisorySource = {
|
|
24
|
+
findCriticalMatch: (facts) => matchFixture(facts, FIXTURE_CRITICAL_ADVISORIES)
|
|
25
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const DEFAULT_TTL_MS = 60 * 60 * 1000;
|
|
2
|
+
const GRACE_MS = 15 * 60 * 1000;
|
|
3
|
+
export function createLiveAdvisorySource(options) {
|
|
4
|
+
const ttl = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
5
|
+
const fallback = options.fallbackSource ?? null;
|
|
6
|
+
let cache = null;
|
|
7
|
+
let refreshInFlight = null;
|
|
8
|
+
function isFresh() {
|
|
9
|
+
if (!cache)
|
|
10
|
+
return false;
|
|
11
|
+
return Date.now() - cache.fetchedAt < ttl;
|
|
12
|
+
}
|
|
13
|
+
function isGraceStale() {
|
|
14
|
+
if (!cache)
|
|
15
|
+
return false;
|
|
16
|
+
return Date.now() - cache.fetchedAt < ttl + GRACE_MS;
|
|
17
|
+
}
|
|
18
|
+
async function refresh() {
|
|
19
|
+
if (refreshInFlight)
|
|
20
|
+
return refreshInFlight;
|
|
21
|
+
refreshInFlight = (async () => {
|
|
22
|
+
try {
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const to = setTimeout(() => controller.abort(), 10000);
|
|
25
|
+
const res = await fetch(options.endpoint, { signal: controller.signal });
|
|
26
|
+
clearTimeout(to);
|
|
27
|
+
if (!res.ok)
|
|
28
|
+
return false;
|
|
29
|
+
const data = (await res.json());
|
|
30
|
+
cache = { advisories: data.advisories ?? [], fetchedAt: Date.now() };
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
refreshInFlight = null;
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
return refreshInFlight;
|
|
41
|
+
}
|
|
42
|
+
function findCriticalMatch(facts) {
|
|
43
|
+
if (cache && isFresh()) {
|
|
44
|
+
return matchAdvisories(facts, cache.advisories);
|
|
45
|
+
}
|
|
46
|
+
if (cache && isGraceStale()) {
|
|
47
|
+
void refresh();
|
|
48
|
+
return matchAdvisories(facts, cache.advisories);
|
|
49
|
+
}
|
|
50
|
+
if (fallback) {
|
|
51
|
+
const match = fallback.findCriticalMatch(facts);
|
|
52
|
+
void refresh();
|
|
53
|
+
return match;
|
|
54
|
+
}
|
|
55
|
+
void refresh();
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
function getStatus() {
|
|
59
|
+
return {
|
|
60
|
+
source: options.endpoint,
|
|
61
|
+
lastFetched: cache?.fetchedAt ?? null,
|
|
62
|
+
cachedCount: cache?.advisories.length ?? 0,
|
|
63
|
+
ttlMs: ttl
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { findCriticalMatch, refresh, getStatus };
|
|
67
|
+
}
|
|
68
|
+
function matchAdvisories(facts, advisories) {
|
|
69
|
+
for (const adv of advisories) {
|
|
70
|
+
if (adv.severity !== "critical")
|
|
71
|
+
continue;
|
|
72
|
+
const version = facts.dependencies[adv.packageName];
|
|
73
|
+
if (version && version === adv.affectedVersion) {
|
|
74
|
+
return {
|
|
75
|
+
packageName: adv.packageName,
|
|
76
|
+
affectedVersion: adv.affectedVersion,
|
|
77
|
+
advisoryId: adv.advisoryId,
|
|
78
|
+
title: adv.title
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cac } from "cac";
|
|
3
|
+
import { runCi } from "./commands/ci.js";
|
|
4
|
+
import { runDoctor } from "./commands/doctor.js";
|
|
5
|
+
import { runFix } from "./commands/fix.js";
|
|
6
|
+
import { runInit } from "./commands/init.js";
|
|
7
|
+
import { runInstall } from "./commands/install.js";
|
|
8
|
+
import { runScan } from "./commands/scan.js";
|
|
9
|
+
import { runTrust } from "./commands/trust.js";
|
|
10
|
+
import { overrideAdd, overrideList, overrideRemove } from "./core/overrides.js";
|
|
11
|
+
export function createCli() {
|
|
12
|
+
const cli = cac("depsentinel");
|
|
13
|
+
cli
|
|
14
|
+
.command("scan", "Run dependency risk scan")
|
|
15
|
+
.option("--json", "Emit machine-readable JSON")
|
|
16
|
+
.action((options) => {
|
|
17
|
+
const result = runScan({ json: Boolean(options.json) });
|
|
18
|
+
process.stdout.write(`${result.output}\n`);
|
|
19
|
+
const hasCritical = result.envelope.result.findings.some((finding) => finding.severity === "critical");
|
|
20
|
+
if (hasCritical) {
|
|
21
|
+
process.exitCode = 1;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
cli
|
|
25
|
+
.command("ci", "Run CI policy gate")
|
|
26
|
+
.option("--json", "Emit machine-readable JSON")
|
|
27
|
+
.action((options) => {
|
|
28
|
+
const result = runCi({ json: Boolean(options.json) });
|
|
29
|
+
process.stdout.write(`${result.output}\n`);
|
|
30
|
+
if (result.shouldFail) {
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
cli
|
|
35
|
+
.command("init", "Initialize depsentinel policy artifacts")
|
|
36
|
+
.option("--preset <preset>", "Preset to initialize (base|expo)")
|
|
37
|
+
.option("--write", "Apply changes (default is dry-run)")
|
|
38
|
+
.option("--json", "Emit machine-readable JSON")
|
|
39
|
+
.action((options) => {
|
|
40
|
+
const result = runInit({
|
|
41
|
+
preset: options.preset,
|
|
42
|
+
dryRun: !Boolean(options.write),
|
|
43
|
+
json: Boolean(options.json)
|
|
44
|
+
});
|
|
45
|
+
process.stdout.write(`${result.output}\n`);
|
|
46
|
+
});
|
|
47
|
+
cli
|
|
48
|
+
.command("install <package>", "Evaluate and execute safe dependency install")
|
|
49
|
+
.option("--force", "Override block/warn decision")
|
|
50
|
+
.option("--json", "Emit machine-readable JSON")
|
|
51
|
+
.action((pkg, options) => {
|
|
52
|
+
const result = runInstall({
|
|
53
|
+
packageName: pkg,
|
|
54
|
+
force: Boolean(options.force),
|
|
55
|
+
json: Boolean(options.json)
|
|
56
|
+
});
|
|
57
|
+
process.stdout.write(`${result.output}\n`);
|
|
58
|
+
process.exitCode = result.exitCode;
|
|
59
|
+
});
|
|
60
|
+
cli
|
|
61
|
+
.command("doctor", "Diagnose project against 26 security best practices")
|
|
62
|
+
.option("--json", "Emit machine-readable JSON")
|
|
63
|
+
.action((options) => {
|
|
64
|
+
const result = runDoctor({ json: Boolean(options.json) });
|
|
65
|
+
process.stdout.write(`${result.output}\n`);
|
|
66
|
+
if (result.envelope.result.failed > 0) {
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
cli
|
|
71
|
+
.command("fix", "Apply known remediations for detected gaps (dry-run by default)")
|
|
72
|
+
.option("--write", "Apply changes")
|
|
73
|
+
.option("--json", "Emit machine-readable JSON")
|
|
74
|
+
.action((options) => {
|
|
75
|
+
const result = runFix({ dryRun: !options.write, json: Boolean(options.json) });
|
|
76
|
+
process.stdout.write(`${result.output}\n`);
|
|
77
|
+
});
|
|
78
|
+
cli
|
|
79
|
+
.command("trust <action> [package]", "Manage package-manager trust lists")
|
|
80
|
+
.option("--mode <mode>", "Mode: allow-build | ignore-build")
|
|
81
|
+
.option("--pm <pm>", "Package manager override")
|
|
82
|
+
.option("--write", "Apply changes (default is dry-run)")
|
|
83
|
+
.option("--json", "Emit machine-readable JSON")
|
|
84
|
+
.action((action, packageName, options) => {
|
|
85
|
+
const result = runTrust({
|
|
86
|
+
action,
|
|
87
|
+
packageName,
|
|
88
|
+
mode: options.mode,
|
|
89
|
+
manager: options.pm,
|
|
90
|
+
dryRun: !Boolean(options.write),
|
|
91
|
+
json: Boolean(options.json)
|
|
92
|
+
});
|
|
93
|
+
process.stdout.write(`${result.output}\n`);
|
|
94
|
+
process.exitCode = result.exitCode;
|
|
95
|
+
});
|
|
96
|
+
cli
|
|
97
|
+
.command("override <action> [ruleId]", "Manage policy overrides (add | remove | list)")
|
|
98
|
+
.option("--reason <reason>", "Reason for the override")
|
|
99
|
+
.option("--expires <date>", "Expiration date (YYYY-MM-DD)")
|
|
100
|
+
.action((action, ruleId, options) => {
|
|
101
|
+
if (action === "list") {
|
|
102
|
+
const store = overrideList();
|
|
103
|
+
process.stdout.write(JSON.stringify(store, null, 2) + "\n");
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!ruleId) {
|
|
107
|
+
process.stderr.write("ruleId is required for add/remove\n");
|
|
108
|
+
process.exitCode = 1;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (action === "remove") {
|
|
112
|
+
const store = overrideRemove(ruleId);
|
|
113
|
+
process.stdout.write(JSON.stringify(store, null, 2) + "\n");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (action === "add") {
|
|
117
|
+
if (!options.reason || !options.expires) {
|
|
118
|
+
process.stderr.write("--reason and --expires are required for add\n");
|
|
119
|
+
process.exitCode = 1;
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const store = overrideAdd({ ruleId, reason: options.reason, expires: options.expires });
|
|
123
|
+
process.stdout.write(JSON.stringify(store, null, 2) + "\n");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
process.stderr.write(`Unknown action: ${action}\n`);
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
});
|
|
129
|
+
return cli;
|
|
130
|
+
}
|
|
131
|
+
const cli = createCli();
|
|
132
|
+
cli.help();
|
|
133
|
+
cli.parse();
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { formatScanJson } from "../formatters/json.js";
|
|
2
|
+
import { runScan } from "./scan.js";
|
|
3
|
+
function buildDiagnostics(envelope) {
|
|
4
|
+
const diagnostics = [];
|
|
5
|
+
if (envelope.facts.packageManager === "unknown") {
|
|
6
|
+
diagnostics.push("No lockfile detected. Add one of: package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lockb.");
|
|
7
|
+
}
|
|
8
|
+
if (envelope.facts.framework === "unknown") {
|
|
9
|
+
diagnostics.push("Framework not detected. Running universal JS/TS baseline checks only.");
|
|
10
|
+
}
|
|
11
|
+
return diagnostics;
|
|
12
|
+
}
|
|
13
|
+
function formatCiHuman(envelope) {
|
|
14
|
+
const findingLines = envelope.result.findings.length
|
|
15
|
+
? envelope.result.findings.map((finding) => `- [${finding.severity}] ${finding.ruleId}: ${finding.message}`).join("\n")
|
|
16
|
+
: "- none";
|
|
17
|
+
const remediationLines = envelope.result.remediation_commands.length
|
|
18
|
+
? envelope.result.remediation_commands.map((command) => `- ${command}`).join("\n")
|
|
19
|
+
: "- none";
|
|
20
|
+
const diagnosticLines = envelope.result.diagnostics.length
|
|
21
|
+
? envelope.result.diagnostics.map((message) => `- ${message}`).join("\n")
|
|
22
|
+
: "- none";
|
|
23
|
+
return [
|
|
24
|
+
"depsentinel ci",
|
|
25
|
+
`risk score: ${envelope.result.risk_score}`,
|
|
26
|
+
`gate: ${envelope.result.failed_critical_policy ? "fail" : "pass"}`,
|
|
27
|
+
`package manager: ${envelope.facts.packageManager}`,
|
|
28
|
+
`framework hint: ${envelope.facts.framework}`,
|
|
29
|
+
"findings:",
|
|
30
|
+
findingLines,
|
|
31
|
+
"remediation:",
|
|
32
|
+
remediationLines,
|
|
33
|
+
"diagnostics:",
|
|
34
|
+
diagnosticLines
|
|
35
|
+
].join("\n");
|
|
36
|
+
}
|
|
37
|
+
export function runCi(options = {}) {
|
|
38
|
+
const scan = runScan({ cwd: options.cwd, json: true });
|
|
39
|
+
const failedCriticalPolicy = scan.envelope.result.findings.some((finding) => finding.severity === "critical");
|
|
40
|
+
const envelope = {
|
|
41
|
+
schemaVersion: "1.0.0",
|
|
42
|
+
command: "ci",
|
|
43
|
+
facts: scan.envelope.facts,
|
|
44
|
+
result: {
|
|
45
|
+
risk_score: scan.envelope.result.risk_score,
|
|
46
|
+
proposed_diff: scan.envelope.result.proposed_diff,
|
|
47
|
+
remediation_commands: scan.envelope.result.remediation_commands,
|
|
48
|
+
findings: scan.envelope.result.findings,
|
|
49
|
+
failed_critical_policy: failedCriticalPolicy,
|
|
50
|
+
diagnostics: []
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
envelope.result.diagnostics = buildDiagnostics(envelope);
|
|
54
|
+
return {
|
|
55
|
+
envelope,
|
|
56
|
+
output: options.json ? formatScanJson(envelope) : formatCiHuman(envelope),
|
|
57
|
+
shouldFail: failedCriticalPolicy
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { detectProjectFacts } from "../core/detector.js";
|
|
2
|
+
import { collectDiagnoses } from "../core/doctor-checks.js";
|
|
3
|
+
import { computeRiskScore } from "../core/risk-score.js";
|
|
4
|
+
function buildRemediationCommands(facts) {
|
|
5
|
+
const install = facts.packageManager === "pnpm"
|
|
6
|
+
? "pnpm install --frozen-lockfile"
|
|
7
|
+
: facts.packageManager === "yarn"
|
|
8
|
+
? "yarn install --immutable"
|
|
9
|
+
: facts.packageManager === "bun"
|
|
10
|
+
? "bun install --frozen-lockfile"
|
|
11
|
+
: facts.packageManager === "npm"
|
|
12
|
+
? "npm ci"
|
|
13
|
+
: "corepack enable && pnpm install --frozen-lockfile";
|
|
14
|
+
return [install, "depsentinel init --write", "depsentinel ci --json"];
|
|
15
|
+
}
|
|
16
|
+
function formatDoctorHuman(facts, diagnoses) {
|
|
17
|
+
const byCategory = new Map();
|
|
18
|
+
for (const d of diagnoses) {
|
|
19
|
+
const list = byCategory.get(d.category) ?? [];
|
|
20
|
+
list.push(d);
|
|
21
|
+
byCategory.set(d.category, list);
|
|
22
|
+
}
|
|
23
|
+
const statusIcon = (s) => (s === "pass" ? "PASS" : s === "fail" ? "FAIL" : "SKIP");
|
|
24
|
+
const severityFlag = (sv) => (sv === "critical" ? "!! " : sv === "high" ? "! " : "");
|
|
25
|
+
const lines = ["depsentinel doctor", `package manager: ${facts.packageManager}`, `framework hint: ${facts.framework}`, ""];
|
|
26
|
+
const order = ["config", "dependencies", "ci", "maintainer"];
|
|
27
|
+
for (const cat of order) {
|
|
28
|
+
const items = byCategory.get(cat) ?? [];
|
|
29
|
+
if (items.length === 0)
|
|
30
|
+
continue;
|
|
31
|
+
const fails = items.filter((d) => d.status === "fail").length;
|
|
32
|
+
const passes = items.filter((d) => d.status === "pass").length;
|
|
33
|
+
const skips = items.filter((d) => d.status === "skipped").length;
|
|
34
|
+
lines.push(`--- ${cat.toUpperCase()} (${passes} pass, ${fails} fail, ${skips} skip) ---`);
|
|
35
|
+
for (const d of items) {
|
|
36
|
+
lines.push(` [${statusIcon(d.status)}] ${severityFlag(d.severity)}${d.title}`);
|
|
37
|
+
if (d.status === "fail") {
|
|
38
|
+
lines.push(` Why: ${d.detail}`);
|
|
39
|
+
lines.push(` Fix: ${d.remediation}`);
|
|
40
|
+
}
|
|
41
|
+
if (d.status === "skipped") {
|
|
42
|
+
lines.push(` Note: ${d.detail}`);
|
|
43
|
+
lines.push(` Action: ${d.remediation}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
lines.push("");
|
|
47
|
+
}
|
|
48
|
+
return lines.join("\n");
|
|
49
|
+
}
|
|
50
|
+
export function runDoctor(options = {}) {
|
|
51
|
+
const cwd = options.cwd ?? process.cwd();
|
|
52
|
+
const facts = detectProjectFacts(cwd);
|
|
53
|
+
const diagnoses = collectDiagnoses(cwd, facts);
|
|
54
|
+
const passed = diagnoses.filter((d) => d.status === "pass").length;
|
|
55
|
+
const failed = diagnoses.filter((d) => d.status === "fail").length;
|
|
56
|
+
const skipped = diagnoses.filter((d) => d.status === "skipped").length;
|
|
57
|
+
const riskFindings = diagnoses.filter((d) => d.status === "fail").map((d) => ({
|
|
58
|
+
ruleId: d.id,
|
|
59
|
+
severity: d.severity,
|
|
60
|
+
message: d.title
|
|
61
|
+
}));
|
|
62
|
+
const score = computeRiskScore(riskFindings);
|
|
63
|
+
const envelope = {
|
|
64
|
+
schemaVersion: "1.0.0",
|
|
65
|
+
command: "doctor",
|
|
66
|
+
facts,
|
|
67
|
+
result: {
|
|
68
|
+
risk_score: score,
|
|
69
|
+
diagnoses,
|
|
70
|
+
passed,
|
|
71
|
+
failed,
|
|
72
|
+
skipped
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return {
|
|
76
|
+
envelope,
|
|
77
|
+
output: options.json ? JSON.stringify(envelope, null, 2) : formatDoctorHuman(facts, diagnoses)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { detectProjectFacts } from "../core/detector.js";
|
|
4
|
+
import { planSafeFile, applySafePlan } from "../core/safe-write.js";
|
|
5
|
+
function readJsonSafe(filePath) {
|
|
6
|
+
if (!existsSync(filePath))
|
|
7
|
+
return {};
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function collectFixPlans(cwd) {
|
|
16
|
+
const facts = detectProjectFacts(cwd);
|
|
17
|
+
const plans = [];
|
|
18
|
+
// .npmrc
|
|
19
|
+
const npmrcPath = path.join(cwd, ".npmrc");
|
|
20
|
+
const npmrcContent = [
|
|
21
|
+
"# depsentinel npm security baseline",
|
|
22
|
+
"ignore-scripts=true",
|
|
23
|
+
"allow-git=none",
|
|
24
|
+
"min-release-age=3",
|
|
25
|
+
""
|
|
26
|
+
].join("\n");
|
|
27
|
+
plans.push(planSafeFile(npmrcPath, npmrcContent));
|
|
28
|
+
// .npmignore
|
|
29
|
+
const npmignorePath = path.join(cwd, ".npmignore");
|
|
30
|
+
const npmignoreContent = "# depsentinel secure npmignore\n.env\n*.log\ncoverage/\nnode_modules/\n";
|
|
31
|
+
plans.push(planSafeFile(npmignorePath, npmignoreContent));
|
|
32
|
+
// .gitignore
|
|
33
|
+
const gitignorePath = path.join(cwd, ".gitignore");
|
|
34
|
+
if (!existsSync(gitignorePath)) {
|
|
35
|
+
plans.push(planSafeFile(gitignorePath, "# depsentinel\ndist/\nnode_modules/\n.env\n*.log\n"));
|
|
36
|
+
}
|
|
37
|
+
// Accumulate package.json changes in memory
|
|
38
|
+
const pkgPath = path.join(cwd, "package.json");
|
|
39
|
+
const pkg = readJsonSafe(pkgPath);
|
|
40
|
+
let pkgDirty = false;
|
|
41
|
+
// files allowlist
|
|
42
|
+
if (pkg.name && !pkg.private && !pkg.files) {
|
|
43
|
+
pkg.files = ["dist"];
|
|
44
|
+
pkgDirty = true;
|
|
45
|
+
}
|
|
46
|
+
// lint:lockfile script
|
|
47
|
+
if (!pkg.scripts)
|
|
48
|
+
pkg.scripts = {};
|
|
49
|
+
if (!pkg.scripts["lint:lockfile"]) {
|
|
50
|
+
pkg.scripts["lint:lockfile"] = "lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https";
|
|
51
|
+
pkgDirty = true;
|
|
52
|
+
}
|
|
53
|
+
// sbom script
|
|
54
|
+
if (!pkg.scripts["sbom"] && !pkg.scripts["generate:sbom"]) {
|
|
55
|
+
pkg.scripts["sbom"] = "npx @cyclonedx/cyclonedx-npm --validate > sbom.json";
|
|
56
|
+
pkgDirty = true;
|
|
57
|
+
}
|
|
58
|
+
if (pkgDirty) {
|
|
59
|
+
plans.push(planSafeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n"));
|
|
60
|
+
}
|
|
61
|
+
// Bunfig
|
|
62
|
+
if (facts.packageManager === "bun") {
|
|
63
|
+
const bunfigPath = path.join(cwd, "bunfig.toml");
|
|
64
|
+
const bunfigContent = "[install]\nminimumReleaseAge = 259200\n";
|
|
65
|
+
plans.push(planSafeFile(bunfigPath, bunfigContent));
|
|
66
|
+
}
|
|
67
|
+
// Yarnrc
|
|
68
|
+
if (facts.packageManager === "yarn") {
|
|
69
|
+
const yarnrcPath = path.join(cwd, ".yarnrc.yml");
|
|
70
|
+
const yarnrcContent = "npmMinimalAgeGate: \"3d\"\n";
|
|
71
|
+
plans.push(planSafeFile(yarnrcPath, yarnrcContent));
|
|
72
|
+
}
|
|
73
|
+
return { plans, facts };
|
|
74
|
+
}
|
|
75
|
+
function buildEntries(plans) {
|
|
76
|
+
return plans.map((p) => ({
|
|
77
|
+
path: path.basename(p.path),
|
|
78
|
+
status: (p.status === "noop" ? "noop" : p.status === "create" ? "created" : "applied"),
|
|
79
|
+
backupPath: p.backupPath ? path.basename(p.backupPath) : undefined,
|
|
80
|
+
reason: p.status === "update" ? "content updated" : p.status === "create" ? "new file" : "already current"
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
export function runFix(options = {}) {
|
|
84
|
+
const cwd = options.cwd ?? process.cwd();
|
|
85
|
+
const dryRun = options.dryRun ?? true;
|
|
86
|
+
const { plans } = collectFixPlans(cwd);
|
|
87
|
+
const applied = applySafePlan(plans, { dryRun });
|
|
88
|
+
const entries = buildEntries(plans);
|
|
89
|
+
const outputLines = [
|
|
90
|
+
"depsentinel fix",
|
|
91
|
+
`Mode: ${dryRun ? "dry-run" : "apply"}`,
|
|
92
|
+
...entries.map((e) => {
|
|
93
|
+
const backup = e.backupPath ? ` (backup: ${e.backupPath})` : "";
|
|
94
|
+
return `- ${e.path}: ${e.status} — ${e.reason}${backup}`;
|
|
95
|
+
})
|
|
96
|
+
];
|
|
97
|
+
const output = options.json
|
|
98
|
+
? JSON.stringify({ command: "fix", dryRun, entries }, null, 2)
|
|
99
|
+
: outputLines.join("\n");
|
|
100
|
+
return { entries, dryRun, output };
|
|
101
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { applySafePlan, planSafeFile } from "../core/safe-write.js";
|
|
3
|
+
import { detectProjectFacts } from "../core/detector.js";
|
|
4
|
+
function buildDepsentinelConfig(preset) {
|
|
5
|
+
return JSON.stringify({
|
|
6
|
+
schemaVersion: "1.0.0",
|
|
7
|
+
preset,
|
|
8
|
+
policyCatalog: "v1",
|
|
9
|
+
failOn: ["critical"],
|
|
10
|
+
overrides: []
|
|
11
|
+
}, null, 2);
|
|
12
|
+
}
|
|
13
|
+
function buildNpmRc() {
|
|
14
|
+
return [
|
|
15
|
+
"# depsentinel npm security baseline",
|
|
16
|
+
"ignore-scripts=true",
|
|
17
|
+
"allow-git=none",
|
|
18
|
+
"min-release-age=3",
|
|
19
|
+
""
|
|
20
|
+
].join("\n");
|
|
21
|
+
}
|
|
22
|
+
function buildPnpmWorkspace() {
|
|
23
|
+
return [
|
|
24
|
+
"packages:",
|
|
25
|
+
" - \".\"",
|
|
26
|
+
"",
|
|
27
|
+
"# depsentinel pnpm security baseline",
|
|
28
|
+
"minimumReleaseAge: 43200",
|
|
29
|
+
"trustPolicy: no-downgrade",
|
|
30
|
+
"blockExoticSubdeps: true",
|
|
31
|
+
"strictDepBuilds: true",
|
|
32
|
+
"allowBuilds:",
|
|
33
|
+
" esbuild: true",
|
|
34
|
+
" rolldown: true",
|
|
35
|
+
" unrs-resolver: true",
|
|
36
|
+
""
|
|
37
|
+
].join("\n");
|
|
38
|
+
}
|
|
39
|
+
function buildBunfig() {
|
|
40
|
+
return [
|
|
41
|
+
"[install]",
|
|
42
|
+
"minimumReleaseAge = 259200",
|
|
43
|
+
""
|
|
44
|
+
].join("\n");
|
|
45
|
+
}
|
|
46
|
+
function buildYarnRc() {
|
|
47
|
+
return [
|
|
48
|
+
"npmMinimalAgeGate: \"3d\"",
|
|
49
|
+
""
|
|
50
|
+
].join("\n");
|
|
51
|
+
}
|
|
52
|
+
function buildNpmIgnore() {
|
|
53
|
+
return [
|
|
54
|
+
"# depsentinel secure npmignore",
|
|
55
|
+
".env",
|
|
56
|
+
"*.log",
|
|
57
|
+
"coverage/",
|
|
58
|
+
"node_modules/",
|
|
59
|
+
""
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
62
|
+
function buildCiWorkflow() {
|
|
63
|
+
return [
|
|
64
|
+
"name: depsentinel-ci",
|
|
65
|
+
"",
|
|
66
|
+
"on:",
|
|
67
|
+
" pull_request:",
|
|
68
|
+
" push:",
|
|
69
|
+
" branches: [main]",
|
|
70
|
+
"",
|
|
71
|
+
"jobs:",
|
|
72
|
+
" policy-gate:",
|
|
73
|
+
" runs-on: ubuntu-latest",
|
|
74
|
+
" steps:",
|
|
75
|
+
" - uses: actions/checkout@v4",
|
|
76
|
+
" - uses: pnpm/action-setup@v4",
|
|
77
|
+
" - uses: actions/setup-node@v4",
|
|
78
|
+
" with:",
|
|
79
|
+
" node-version: 22",
|
|
80
|
+
" cache: pnpm",
|
|
81
|
+
" - name: Install dependencies",
|
|
82
|
+
" run: |",
|
|
83
|
+
" if [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --frozen-lockfile;",
|
|
84
|
+
" elif [ -f yarn.lock ]; then corepack enable && yarn install --immutable;",
|
|
85
|
+
" elif [ -f bun.lockb ]; then bun install --frozen-lockfile;",
|
|
86
|
+
" elif [ -f package-lock.json ]; then npm ci;",
|
|
87
|
+
" else corepack enable && pnpm install; fi",
|
|
88
|
+
" - name: Build depsentinel CLI",
|
|
89
|
+
" run: pnpm build",
|
|
90
|
+
" - name: Run depsentinel CI gate",
|
|
91
|
+
" run: node dist/cli.js ci --json"
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
function toFilePlan(result) {
|
|
95
|
+
return {
|
|
96
|
+
path: path.basename(result.path),
|
|
97
|
+
status: result.status,
|
|
98
|
+
backupPath: result.backupPath ? path.basename(result.backupPath) : undefined
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function runInit(options = {}) {
|
|
102
|
+
const cwd = options.cwd ?? process.cwd();
|
|
103
|
+
const preset = options.preset ?? "base";
|
|
104
|
+
const dryRun = options.dryRun ?? true;
|
|
105
|
+
const facts = detectProjectFacts(cwd);
|
|
106
|
+
const planned = [
|
|
107
|
+
planSafeFile(path.join(cwd, "depsentinel.json"), `${buildDepsentinelConfig(preset)}\n`),
|
|
108
|
+
planSafeFile(path.join(cwd, ".npmrc"), buildNpmRc()),
|
|
109
|
+
planSafeFile(path.join(cwd, ".npmignore"), buildNpmIgnore()),
|
|
110
|
+
planSafeFile(path.join(cwd, ".github", "workflows", "depsentinel-ci.yml"), `${buildCiWorkflow()}\n`)
|
|
111
|
+
];
|
|
112
|
+
if (facts.packageManager === "pnpm" || facts.packageManager === "unknown") {
|
|
113
|
+
planned.push(planSafeFile(path.join(cwd, "pnpm-workspace.yaml"), buildPnpmWorkspace()));
|
|
114
|
+
}
|
|
115
|
+
if (facts.packageManager === "bun") {
|
|
116
|
+
planned.push(planSafeFile(path.join(cwd, "bunfig.toml"), buildBunfig()));
|
|
117
|
+
}
|
|
118
|
+
if (facts.packageManager === "yarn") {
|
|
119
|
+
planned.push(planSafeFile(path.join(cwd, ".yarnrc.yml"), buildYarnRc()));
|
|
120
|
+
}
|
|
121
|
+
const applied = applySafePlan(planned, { dryRun });
|
|
122
|
+
const files = applied.map(toFilePlan);
|
|
123
|
+
const envelope = {
|
|
124
|
+
schemaVersion: "1.0.0",
|
|
125
|
+
command: "init",
|
|
126
|
+
result: {
|
|
127
|
+
preset,
|
|
128
|
+
dryRun,
|
|
129
|
+
files
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const output = options.json
|
|
133
|
+
? JSON.stringify(envelope, null, 2)
|
|
134
|
+
: [
|
|
135
|
+
`Init preset: ${preset}`,
|
|
136
|
+
`Mode: ${dryRun ? "dry-run" : "apply"}`,
|
|
137
|
+
...files.map((file) => {
|
|
138
|
+
const backup = file.backupPath ? ` (backup: ${file.backupPath})` : "";
|
|
139
|
+
return `- ${file.path}: ${file.status}${backup}`;
|
|
140
|
+
})
|
|
141
|
+
].join("\n");
|
|
142
|
+
return { envelope, output };
|
|
143
|
+
}
|