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.
@@ -0,0 +1,206 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import path from "node:path";
3
+ function readJsonSafe(filePath, fallback) {
4
+ try {
5
+ return JSON.parse(readFileSync(filePath, "utf8"));
6
+ }
7
+ catch {
8
+ return fallback;
9
+ }
10
+ }
11
+ function pass(id, category, title) {
12
+ return {
13
+ id,
14
+ category,
15
+ severity: "low",
16
+ status: "pass",
17
+ title,
18
+ detail: title,
19
+ remediation: "",
20
+ automated: true
21
+ };
22
+ }
23
+ function fail(id, category, severity, title, detail, remediation, automated = true) {
24
+ return { id, category, severity, status: "fail", title, detail, remediation, automated };
25
+ }
26
+ function skip(id, category, title, detail, remediation) {
27
+ return { id, category, severity: "low", status: "skipped", title, detail, remediation, automated: false };
28
+ }
29
+ export function collectDiagnoses(rootDir, facts) {
30
+ return [
31
+ checkNpmRc(rootDir),
32
+ checkNpmIgnore(rootDir),
33
+ checkPackageJsonFiles(rootDir),
34
+ checkPnpmWorkspace(rootDir, facts),
35
+ checkBunfig(rootDir, facts),
36
+ checkYarnRc(rootDir, facts),
37
+ checkLockfileCommitted(rootDir),
38
+ checkCiProvenance(rootDir),
39
+ checkLintLockfile(rootDir),
40
+ checkSbomScript(rootDir),
41
+ checkEnvPlaintext(rootDir),
42
+ checkNpxHardening(),
43
+ checkNpm2fa(),
44
+ checkDevContainer(rootDir),
45
+ checkNodeModulesGitignored(rootDir)
46
+ ];
47
+ }
48
+ function checkNpmRc(rootDir) {
49
+ const npmrc = path.join(rootDir, ".npmrc");
50
+ if (!existsSync(npmrc))
51
+ return fail("config.npmrc.missing", "config", "high", "Missing .npmrc security baseline", "No .npmrc found in project root.", "Run `depsentinel init` to generate a secure .npmrc baseline.");
52
+ const content = readFileSync(npmrc, "utf8");
53
+ const missing = [];
54
+ if (!content.includes("ignore-scripts=true"))
55
+ missing.push("ignore-scripts=true");
56
+ if (!content.includes("allow-git=none"))
57
+ missing.push("allow-git=none");
58
+ if (!content.includes("min-release-age"))
59
+ missing.push("min-release-age");
60
+ if (missing.length === 0)
61
+ return pass("config.npmrc.secure", "config", ".npmrc has secure baseline");
62
+ return fail("config.npmrc.incomplete", "config", "medium", `.npmrc missing: ${missing.join(", ")}`, "Your .npmrc lacks key security settings.", "Run `depsentinel init` to regenerate .npmrc.");
63
+ }
64
+ function checkNpmIgnore(rootDir) {
65
+ if (existsSync(path.join(rootDir, ".npmignore")))
66
+ return pass("config.npmignore.present", "config", ".npmignore present");
67
+ return fail("config.npmignore.missing", "config", "medium", "Missing .npmignore", "Without .npmignore, sensitive files may leak into published packages.", "Add a .npmignore file listing patterns to exclude from npm publish.");
68
+ }
69
+ function checkPackageJsonFiles(rootDir) {
70
+ const pkg = path.join(rootDir, "package.json");
71
+ if (!existsSync(pkg))
72
+ return skip("config.package-json.missing", "config", "No package.json found", "Cannot evaluate package.json settings.", "Ensure package.json exists.");
73
+ const parsed = readJsonSafe(pkg, { files: undefined, private: undefined });
74
+ if (parsed.private)
75
+ return pass("config.package-json.files-private", "config", "Private package (no publish risk)");
76
+ if (parsed.files && parsed.files.length > 0)
77
+ return pass("config.package-json.files-present", "config", "package.json has `files` allowlist");
78
+ return fail("config.package-json.files-missing", "config", "medium", "Missing `files` field in package.json", "Without `files`, npm publishes everything not excluded by .npmignore/.gitignore.", "Add a `files` array to package.json with only the dist/ entry point you want published.");
79
+ }
80
+ function checkPnpmWorkspace(rootDir, facts) {
81
+ const wsPath = path.join(rootDir, "pnpm-workspace.yaml");
82
+ if (!existsSync(wsPath)) {
83
+ if (facts.packageManager === "pnpm")
84
+ return fail("config.pnpm-workspace.missing", "config", "high", "pnpm workspace without security hardening", "pnpm is detected but pnpm-workspace.yaml is missing or lacks security settings.", "Run `depsentinel init --preset expo` or copy the security baseline into your pnpm-workspace.yaml.");
85
+ return skip("config.pnpm-workspace.non-pnpm", "config", "Not a pnpm project", "pnpm-workspace.yaml only applies to pnpm projects.", "No action needed unless you switch to pnpm.");
86
+ }
87
+ const content = readFileSync(wsPath, "utf8");
88
+ const missing = [];
89
+ if (!content.includes("minimumReleaseAge"))
90
+ missing.push("minimumReleaseAge");
91
+ if (!content.includes("trustPolicy"))
92
+ missing.push("trustPolicy: no-downgrade");
93
+ if (!content.includes("blockExoticSubdeps: true"))
94
+ missing.push("blockExoticSubdeps: true");
95
+ if (!content.includes("strictDepBuilds: true"))
96
+ missing.push("strictDepBuilds: true");
97
+ if (missing.length === 0)
98
+ return pass("config.pnpm-workspace.secure", "config", "pnpm-workspace.yaml has security baseline");
99
+ return fail("config.pnpm-workspace.incomplete", "config", "high", `pnpm-workspace.yaml missing: ${missing.join(", ")}`, "Your pnpm workspace lacks key security settings.", "Add the missing settings. Run `depsentinel init --preset expo` to see the baseline.");
100
+ }
101
+ function checkBunfig(rootDir, facts) {
102
+ if (facts.packageManager !== "bun")
103
+ return skip("config.bunfig.non-bun", "config", "Not a Bun project", "bunfig.toml only applies to Bun projects.", "No action needed unless you switch to Bun.");
104
+ const bunfigPath = path.join(rootDir, "bunfig.toml");
105
+ if (!existsSync(bunfigPath))
106
+ return fail("config.bunfig.missing", "config", "medium", "Bun project without bunfig.toml hardening", "Add a bunfig.toml with minimumReleaseAge and trustedDependencies.", "Create bunfig.toml with [install] section containing minimumReleaseAge and trustedDependencies.");
107
+ const content = readFileSync(bunfigPath, "utf8");
108
+ if (!content.includes("minimumReleaseAge"))
109
+ return fail("config.bunfig.no-cooldown", "config", "medium", "bunfig.toml missing minimumReleaseAge", "Bun has no cooldown configured for fresh packages.", "Add `minimumReleaseAge = 259200` to [install] section.");
110
+ return pass("config.bunfig.secure", "config", "bunfig.toml has security baseline");
111
+ }
112
+ function checkYarnRc(rootDir, facts) {
113
+ if (facts.packageManager !== "yarn")
114
+ return skip("config.yarnrc.non-yarn", "config", "Not a Yarn project", ".yarnrc.yml only applies to Yarn projects.", "No action needed.");
115
+ const yarnrcPath = path.join(rootDir, ".yarnrc.yml");
116
+ if (!existsSync(yarnrcPath))
117
+ return fail("config.yarnrc.missing", "config", "medium", "Yarn project without .yarnrc.yml hardening", "Yarn detected but no .yarnrc.yml or npmMinimalAgeGate.", "Create .yarnrc.yml with `npmMinimalAgeGate: \"3d\"`.");
118
+ const content = readFileSync(yarnrcPath, "utf8");
119
+ if (!content.includes("npmMinimalAgeGate"))
120
+ return fail("config.yarnrc.no-cooldown", "config", "medium", ".yarnrc.yml missing npmMinimalAgeGate", "Yarn has no cooldown for fresh packages.", "Add `npmMinimalAgeGate: \"3d\"` to .yarnrc.yml.");
121
+ return pass("config.yarnrc.secure", "config", ".yarnrc.yml has security baseline");
122
+ }
123
+ function checkLockfileCommitted(rootDir) {
124
+ const gitDir = path.join(rootDir, ".git");
125
+ if (!existsSync(gitDir))
126
+ return skip("ci.lockfile-committed.no-git", "ci", "Not a git repository", "Cannot check lockfile commit status without git.", "Initialize a git repository and commit your lockfile.");
127
+ const lockfile = path.join(rootDir, "package-lock.json");
128
+ if (!existsSync(lockfile))
129
+ return skip("ci.lockfile-committed.no-lockfile", "ci", "No lockfile to check", "No lockfile found to verify commit status.", "Generate a lockfile with `npm install` and commit it.");
130
+ return skip("ci.lockfile-committed.manual", "ci", "Verify lockfile committed", "Use `git ls-files package-lock.json` to confirm your lockfile is committed.", "Run `git add package-lock.json && git commit -m \"chore: add lockfile\"`.");
131
+ }
132
+ function checkCiProvenance(rootDir) {
133
+ const workflowsDir = path.join(rootDir, ".github", "workflows");
134
+ if (!existsSync(workflowsDir))
135
+ return fail("ci.provenance.no-workflows", "ci", "medium", "No GitHub Actions workflows found", "Cannot verify provenance/id-token configuration without CI workflows.", "Add `permissions: id-token: write` to your publish workflow for npm provenance.");
136
+ const files = readdirSync(workflowsDir).filter((f) => f.endsWith(".yml") || f.endsWith(".yaml"));
137
+ let hasIdToken = false;
138
+ for (const file of files) {
139
+ const content = readFileSync(path.join(workflowsDir, file), "utf8");
140
+ if (content.includes("id-token: write")) {
141
+ hasIdToken = true;
142
+ break;
143
+ }
144
+ }
145
+ if (hasIdToken)
146
+ return pass("ci.provenance.id-token", "ci", "CI workflow has id-token: write");
147
+ return fail("ci.provenance.missing", "ci", "medium", "CI workflows missing id-token: write for provenance", "Publishing with provenance requires `id-token: write` permission.", "Add `permissions:\\n id-token: write` to your publish workflow.");
148
+ }
149
+ function checkLintLockfile(rootDir) {
150
+ const pkgPath = path.join(rootDir, "package.json");
151
+ if (!existsSync(pkgPath))
152
+ return skip("ci.lint-lockfile.no-pkg", "ci", "No package.json", "Cannot check lockfile lint scripts.", "Ensure package.json exists.");
153
+ const pkg = readJsonSafe(pkgPath, {});
154
+ const hasScript = pkg.scripts?.["lint:lockfile"] ?? false;
155
+ const hasDep = pkg.devDependencies?.["lockfile-lint"] ?? false;
156
+ if (hasScript && hasDep)
157
+ return pass("ci.lint-lockfile.configured", "ci", "lockfile-lint configured in package.json");
158
+ if (hasScript && !hasDep)
159
+ return fail("ci.lint-lockfile.no-dep", "ci", "medium", "lint:lockfile script exists but lockfile-lint not in devDependencies", "The lockfile lint script references a tool that is not installed.", "Run `npm install --save-dev lockfile-lint`.");
160
+ return fail("ci.lint-lockfile.missing", "ci", "medium", "Missing lockfile linting gate", "Without lockfile-lint, lockfile injection attacks go undetected.", "Install lockfile-lint and add `\"lint:lockfile\": \"lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https\"` to scripts.");
161
+ }
162
+ function checkSbomScript(rootDir) {
163
+ const pkgPath = path.join(rootDir, "package.json");
164
+ if (!existsSync(pkgPath))
165
+ return skip("ci.sbom.no-pkg", "ci", "No package.json", "Cannot check SBOM scripts.", "Ensure package.json exists.");
166
+ const pkg = readJsonSafe(pkgPath, {});
167
+ const hasSbom = pkg.scripts?.["sbom"] ?? pkg.scripts?.["generate:sbom"] ?? false;
168
+ if (hasSbom)
169
+ return pass("ci.sbom.present", "ci", "SBOM generation script configured");
170
+ return fail("ci.sbom.missing", "ci", "low", "No SBOM generation script", "SBOM helps track what was built and where inputs originated (supply chain transparency).", "Add a script: `\"sbom\": \"npx @cyclonedx/cyclonedx-npm --validate > sbom.json\"`.");
171
+ }
172
+ function checkEnvPlaintext(rootDir) {
173
+ const envPath = path.join(rootDir, ".env");
174
+ if (!existsSync(envPath))
175
+ return pass("maintainer.env.no-file", "maintainer", "No .env file in project root");
176
+ const content = readFileSync(envPath, "utf8");
177
+ const lines = content.split("\n").filter((l) => l.trim() && !l.trim().startsWith("#"));
178
+ const secretLines = lines.filter((l) => {
179
+ const val = l.split("=").slice(1).join("=").trim();
180
+ return val.length > 0 && !val.startsWith("op://") && !val.startsWith("infisical://") && !val.includes("${");
181
+ });
182
+ if (secretLines.length === 0)
183
+ return pass("maintainer.env.secure", "maintainer", ".env uses secret references (not plaintext)");
184
+ return fail("maintainer.env.plaintext", "maintainer", "critical", `${secretLines.length} plaintext secrets in .env`, "Plaintext secrets in .env are exfiltratable by any compromised dependency.", "Replace plaintext values with references (op:// or infisical://) and use a secrets manager CLI at runtime.");
185
+ }
186
+ function checkNpxHardening() {
187
+ return skip("maintainer.npx-hardening.manual", "maintainer", "Verify npx hardening", "npx can silently pull fresh malicious packages without lockfile verification.", "Create a dedicated workspace with pre-installed npx packages, and use `npx --offline --workspace <path>` to block network fetches. See docs/security-best-practices.md for step-by-step instructions.");
188
+ }
189
+ function checkNpm2fa() {
190
+ return skip("maintainer.2fa.manual", "maintainer", "Verify npm account 2FA", "Accounts without 2FA are vulnerable to credential theft and package takeover.", "Run `npm profile enable-2fa auth-and-writes` to enable 2FA for your npm account.");
191
+ }
192
+ function checkDevContainer(rootDir) {
193
+ const devContainerPath = path.join(rootDir, ".devcontainer", "devcontainer.json");
194
+ if (existsSync(devContainerPath))
195
+ return pass("maintainer.devcontainer.present", "maintainer", "Dev Container configured");
196
+ return fail("maintainer.devcontainer.missing", "maintainer", "low", "No Dev Container configured", "Dev Containers isolate npm execution from host system, limiting malware blast radius.", "Create `.devcontainer/devcontainer.json` with a Node.js image and `postCreateCommand: npm ci`.");
197
+ }
198
+ function checkNodeModulesGitignored(rootDir) {
199
+ const gitignorePath = path.join(rootDir, ".gitignore");
200
+ if (!existsSync(gitignorePath))
201
+ return fail("config.gitignore.missing", "config", "medium", "Missing .gitignore", "Without .gitignore, node_modules/ and secrets could be committed accidentally.", "Add a .gitignore file with `node_modules/` and `.env` patterns.");
202
+ const content = readFileSync(gitignorePath, "utf8");
203
+ if (content.includes("node_modules"))
204
+ return pass("config.gitignore.node-modules", "config", ".gitignore blocks node_modules");
205
+ return fail("config.gitignore.no-node-modules", "config", "medium", ".gitignore missing node_modules", "node_modules is not gitignored, risking accidental commits of thousands of files.", "Add `node_modules/` to .gitignore.");
206
+ }
@@ -0,0 +1,25 @@
1
+ const SEVERITY_ORDER = {
2
+ critical: 0,
3
+ high: 1,
4
+ medium: 2,
5
+ low: 3
6
+ };
7
+ export function sortFindings(findings) {
8
+ return [...findings].sort((a, b) => {
9
+ const bySeverity = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
10
+ if (bySeverity !== 0) {
11
+ return bySeverity;
12
+ }
13
+ const byRuleId = a.ruleId.localeCompare(b.ruleId);
14
+ if (byRuleId !== 0) {
15
+ return byRuleId;
16
+ }
17
+ return a.message.localeCompare(b.message);
18
+ });
19
+ }
20
+ export function evaluatePolicies(facts, rules) {
21
+ const findings = rules
22
+ .map((rule) => rule.evaluate(facts))
23
+ .filter((finding) => finding !== null);
24
+ return sortFindings(findings);
25
+ }
@@ -0,0 +1,11 @@
1
+ export function deriveInstallDecision(findings, _score) {
2
+ const hasCritical = findings.some((finding) => finding.severity === "critical");
3
+ if (hasCritical) {
4
+ return "block";
5
+ }
6
+ const hasHigh = findings.some((finding) => finding.severity === "high");
7
+ if (hasHigh) {
8
+ return "warn";
9
+ }
10
+ return "allow";
11
+ }
@@ -0,0 +1,57 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ const CONFIG_FILE = "depsentinel.json";
4
+ function defaults() {
5
+ return { schemaVersion: "1.0.0", preset: "base", policyCatalog: "v1", failOn: ["critical"], overrides: [] };
6
+ }
7
+ function readConfig(cwd) {
8
+ const filePath = path.join(cwd, CONFIG_FILE);
9
+ if (!existsSync(filePath))
10
+ return defaults();
11
+ try {
12
+ const raw = JSON.parse(readFileSync(filePath, "utf8"));
13
+ return {
14
+ schemaVersion: raw.schemaVersion ?? "1.0.0",
15
+ preset: raw.preset ?? "base",
16
+ policyCatalog: raw.policyCatalog ?? "v1",
17
+ failOn: raw.failOn ?? ["critical"],
18
+ overrides: raw.overrides ?? []
19
+ };
20
+ }
21
+ catch {
22
+ return defaults();
23
+ }
24
+ }
25
+ function writeConfig(cwd, config) {
26
+ writeFileSync(path.join(cwd, CONFIG_FILE), JSON.stringify(config, null, 2) + "\n", "utf8");
27
+ }
28
+ export function overrideAdd(options) {
29
+ const cwd = options.cwd ?? process.cwd();
30
+ const config = readConfig(cwd);
31
+ config.overrides = config.overrides.filter((e) => e.ruleId !== options.ruleId);
32
+ config.overrides.push({
33
+ ruleId: options.ruleId,
34
+ reason: options.reason,
35
+ expires: options.expires,
36
+ createdAt: new Date().toISOString().split("T")[0]
37
+ });
38
+ writeConfig(cwd, config);
39
+ return config;
40
+ }
41
+ export function overrideRemove(ruleId, cwd) {
42
+ const cwd_ = cwd ?? process.cwd();
43
+ const config = readConfig(cwd_);
44
+ config.overrides = config.overrides.filter((e) => e.ruleId !== ruleId);
45
+ writeConfig(cwd_, config);
46
+ return config;
47
+ }
48
+ export function overrideList(cwd) {
49
+ return readConfig(cwd ?? process.cwd());
50
+ }
51
+ export function isOverridden(ruleId, cwd) {
52
+ const config = readConfig(cwd ?? process.cwd());
53
+ const entry = config.overrides.find((e) => e.ruleId === ruleId);
54
+ if (!entry)
55
+ return false;
56
+ return new Date(entry.expires) >= new Date();
57
+ }
@@ -0,0 +1,16 @@
1
+ const SEVERITY_WEIGHTS = {
2
+ critical: 30,
3
+ high: 18,
4
+ medium: 10,
5
+ low: 4
6
+ };
7
+ function clamp(value, min, max) {
8
+ return Math.min(max, Math.max(min, value));
9
+ }
10
+ export function computeRiskScore(findings) {
11
+ const deduction = findings.reduce((acc, finding) => acc + SEVERITY_WEIGHTS[finding.severity], 0);
12
+ return clamp(100 - deduction, 0, 100);
13
+ }
14
+ export function getSeverityWeights() {
15
+ return { ...SEVERITY_WEIGHTS };
16
+ }
@@ -0,0 +1,43 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ function nextBackupPath(filePath) {
4
+ let candidate = `${filePath}.bak`;
5
+ let counter = 1;
6
+ while (existsSync(candidate)) {
7
+ candidate = `${filePath}.bak.${counter}`;
8
+ counter += 1;
9
+ }
10
+ return candidate;
11
+ }
12
+ export function planSafeFile(filePath, content) {
13
+ if (!existsSync(filePath)) {
14
+ return { path: filePath, content, status: "create" };
15
+ }
16
+ const current = readFileSync(filePath, "utf8");
17
+ if (current === content) {
18
+ return { path: filePath, content, status: "noop" };
19
+ }
20
+ return {
21
+ path: filePath,
22
+ content,
23
+ status: "update",
24
+ backupPath: nextBackupPath(filePath)
25
+ };
26
+ }
27
+ export function applySafePlan(plan, options = {}) {
28
+ const dryRun = options.dryRun ?? false;
29
+ if (dryRun) {
30
+ return plan;
31
+ }
32
+ for (const file of plan) {
33
+ if (file.status === "noop") {
34
+ continue;
35
+ }
36
+ mkdirSync(path.dirname(file.path), { recursive: true });
37
+ if (file.status === "update" && file.backupPath) {
38
+ copyFileSync(file.path, file.backupPath);
39
+ }
40
+ writeFileSync(file.path, file.content, "utf8");
41
+ }
42
+ return plan;
43
+ }
@@ -0,0 +1,87 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ const execFileAsync = promisify(execFile);
4
+ export const DEFAULT_ADAPTER_TIMEOUT_MS = 5000;
5
+ export function withTimeout(promise, ms) {
6
+ return Promise.race([
7
+ promise,
8
+ new Promise((_, reject) => {
9
+ setTimeout(() => reject(new Error(`Timed out after ${ms}ms`)), ms);
10
+ })
11
+ ]);
12
+ }
13
+ export async function spawnWithTimeout(command, args, tool, timeoutMs) {
14
+ try {
15
+ const result = await withTimeout(execFileAsync(command, args, {
16
+ timeout: timeoutMs,
17
+ windowsHide: true
18
+ }), timeoutMs);
19
+ return {
20
+ tool,
21
+ status: "executed",
22
+ output: result.stdout.trim() || result.stderr.trim() || "(no output)",
23
+ exitCode: 0
24
+ };
25
+ }
26
+ catch (error) {
27
+ const err = error;
28
+ const isTimeout = err.killed === true ||
29
+ err.code === "ETIMEDOUT" ||
30
+ (err.message && /timed out/i.test(err.message));
31
+ return {
32
+ tool,
33
+ status: "failed",
34
+ error: isTimeout ? `Adapter ${tool} timed out after ${timeoutMs}ms` : (err.message ?? String(error)),
35
+ exitCode: err.code === "ENOENT" ? undefined : (err.exitCode ?? 1),
36
+ output: err.stdout?.trim() || err.stderr?.trim() || undefined
37
+ };
38
+ }
39
+ }
40
+ function buildNpxAdapter(tool) {
41
+ return {
42
+ tool,
43
+ check: async (packageName, _cwd) => {
44
+ try {
45
+ // Try running npx <tool> --version to detect tool availability
46
+ await execFileAsync("npx", [tool, "--version"], {
47
+ timeout: DEFAULT_ADAPTER_TIMEOUT_MS,
48
+ windowsHide: true
49
+ });
50
+ // Tool is available — execute the actual check
51
+ return spawnWithTimeout("npx", [tool, packageName], tool, DEFAULT_ADAPTER_TIMEOUT_MS);
52
+ }
53
+ catch {
54
+ // Tool not available — skip gracefully
55
+ return { tool, status: "skipped" };
56
+ }
57
+ }
58
+ };
59
+ }
60
+ export function createToolAdapters() {
61
+ return [
62
+ buildNpxAdapter("npq"),
63
+ buildNpxAdapter("sfw"),
64
+ buildNpxAdapter("lockfile-lint")
65
+ ];
66
+ }
67
+ export async function runOptionalToolAdapters(adapters, packageName, cwd) {
68
+ const results = [];
69
+ for (const adapter of adapters) {
70
+ try {
71
+ const report = await adapter.check(packageName, cwd);
72
+ results.push(report);
73
+ }
74
+ catch (error) {
75
+ results.push({
76
+ tool: adapter.tool,
77
+ status: "failed",
78
+ error: error instanceof Error ? error.message : String(error)
79
+ });
80
+ }
81
+ }
82
+ return {
83
+ executed: results.filter((r) => r.status === "executed"),
84
+ skipped: results.filter((r) => r.status === "skipped"),
85
+ failed: results.filter((r) => r.status === "failed")
86
+ };
87
+ }
@@ -0,0 +1,39 @@
1
+ export function formatScanHuman(envelope) {
2
+ const findingLines = envelope.result.findings.length
3
+ ? envelope.result.findings.map((finding) => `- [${finding.severity}] ${finding.ruleId}: ${finding.message}`).join("\n")
4
+ : "- none";
5
+ const commandLines = envelope.result.remediation_commands.length
6
+ ? envelope.result.remediation_commands.map((cmd) => `- ${cmd}`).join("\n")
7
+ : "- none";
8
+ return [
9
+ "depsentinel scan",
10
+ `risk score: ${envelope.result.risk_score}`,
11
+ `package manager: ${envelope.facts.packageManager}`,
12
+ "findings:",
13
+ findingLines,
14
+ "remediation:",
15
+ commandLines
16
+ ].join("\n");
17
+ }
18
+ export function formatInstallHuman(envelope) {
19
+ const findingLines = envelope.result.findings.length
20
+ ? envelope.result.findings
21
+ .map((f) => `- [${f.severity}] ${f.ruleId}: ${f.message}`)
22
+ .join("\n")
23
+ : "- none";
24
+ const remediationLines = envelope.result.remediationCommands.length
25
+ ? envelope.result.remediationCommands.map((cmd) => `- ${cmd}`).join("\n")
26
+ : "- none";
27
+ const overrideNote = envelope.result.forced ? " (forced override)" : "";
28
+ return [
29
+ "depsentinel install",
30
+ `decision: ${envelope.result.decision}${overrideNote}`,
31
+ `risk score: ${envelope.result.score}`,
32
+ `package manager: ${envelope.facts.packageManager}`,
33
+ "findings:",
34
+ findingLines,
35
+ "remediation:",
36
+ remediationLines,
37
+ `override: depsentinel install --force <package>`
38
+ ].join("\n");
39
+ }
@@ -0,0 +1,6 @@
1
+ export function formatScanJson(envelope) {
2
+ return JSON.stringify(envelope, null, 2);
3
+ }
4
+ export function formatInstallJson(envelope) {
5
+ return JSON.stringify(envelope, null, 2);
6
+ }
@@ -0,0 +1,123 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fixtureAdvisorySource } from "../advisories/fixture-source.js";
4
+ function readJsonSafe(filePath, fallback) {
5
+ try {
6
+ return JSON.parse(readFileSync(filePath, "utf8"));
7
+ }
8
+ catch {
9
+ return fallback;
10
+ }
11
+ }
12
+ const DISALLOWED_PROTOCOLS = ["git+", "http:", "https:", "file:", "link:", "workspace:"];
13
+ function critical(ruleId, message, meta) {
14
+ return {
15
+ ruleId,
16
+ severity: "critical",
17
+ message,
18
+ meta
19
+ };
20
+ }
21
+ export const policyCatalogV1 = [
22
+ {
23
+ id: "lockfile.required",
24
+ description: "Project must include lockfile",
25
+ severity: "critical",
26
+ evaluate: (facts) => {
27
+ if (facts.lockfile) {
28
+ return null;
29
+ }
30
+ return critical("lockfile.required", "Missing lockfile. Commit a lockfile to ensure reproducible installs.");
31
+ }
32
+ },
33
+ {
34
+ id: "dependency.protocol.disallowed",
35
+ description: "Dependencies cannot use disallowed protocols",
36
+ severity: "critical",
37
+ evaluate: (facts) => {
38
+ const violated = Object.entries(facts.dependencies).find(([, version]) => DISALLOWED_PROTOCOLS.some((protocol) => version.startsWith(protocol)));
39
+ if (!violated) {
40
+ return null;
41
+ }
42
+ const [name, version] = violated;
43
+ return critical("dependency.protocol.disallowed", `Dependency ${name} uses disallowed source ${version}.`, { dependency: name, source: version });
44
+ }
45
+ },
46
+ {
47
+ id: "advisory.critical.detected",
48
+ description: "Critical advisory source check",
49
+ severity: "critical",
50
+ evaluate: (facts) => {
51
+ const match = fixtureAdvisorySource.findCriticalMatch(facts);
52
+ if (!match) {
53
+ return null;
54
+ }
55
+ return critical("advisory.critical.detected", `Critical advisory ${match.advisoryId} for ${match.packageName}@${match.affectedVersion}: ${match.title}`, {
56
+ advisoryId: match.advisoryId,
57
+ package: match.packageName,
58
+ affectedVersion: match.affectedVersion
59
+ });
60
+ }
61
+ },
62
+ {
63
+ id: "maintainer.env.plaintext",
64
+ description: "Flag plaintext secrets in .env files",
65
+ severity: "critical",
66
+ evaluate: (facts) => {
67
+ const envPath = path.join(facts.rootDir, ".env");
68
+ if (!existsSync(envPath))
69
+ return null;
70
+ const content = readFileSync(envPath, "utf8");
71
+ const secretLines = content.split("\n").filter((l) => {
72
+ const trimmed = l.trim();
73
+ if (!trimmed || trimmed.startsWith("#"))
74
+ return false;
75
+ const val = trimmed.split("=").slice(1).join("=").trim();
76
+ return val.length > 0 && !val.startsWith("op://") && !val.startsWith("infisical://") && !val.includes("${");
77
+ });
78
+ if (secretLines.length === 0)
79
+ return null;
80
+ return critical("maintainer.env.plaintext", `${secretLines.length} plaintext secrets found in .env. Replace with op:// or infisical:// references.`, { count: secretLines.length });
81
+ }
82
+ },
83
+ {
84
+ id: "ci.sbom.missing",
85
+ description: "Project should have an SBOM generation script",
86
+ severity: "low",
87
+ evaluate: (facts) => {
88
+ const pkgPath = path.join(facts.rootDir, "package.json");
89
+ if (!existsSync(pkgPath))
90
+ return null;
91
+ const pkg = readJsonSafe(pkgPath, {});
92
+ const hasSbom = pkg.scripts?.["sbom"] ?? pkg.scripts?.["generate:sbom"];
93
+ if (hasSbom)
94
+ return null;
95
+ return {
96
+ ruleId: "ci.sbom.missing",
97
+ severity: "low",
98
+ message: "No SBOM generation script. Add `sbom` script using @cyclonedx/cyclonedx-npm for supply chain transparency."
99
+ };
100
+ }
101
+ },
102
+ {
103
+ id: "config.files.allowlist",
104
+ description: "Non-private packages should define `files` allowlist",
105
+ severity: "medium",
106
+ evaluate: (facts) => {
107
+ const pkgPath = path.join(facts.rootDir, "package.json");
108
+ if (!existsSync(pkgPath))
109
+ return null;
110
+ const pkg = readJsonSafe(pkgPath, {});
111
+ if (pkg.private)
112
+ return null;
113
+ if (pkg.files && pkg.files.length > 0)
114
+ return null;
115
+ return {
116
+ ruleId: "config.files.allowlist",
117
+ severity: "medium",
118
+ message: "Public package missing `files` field in package.json. Without it, everything publishes."
119
+ };
120
+ }
121
+ }
122
+ ];
123
+ policyCatalogV1.sort((a, b) => a.id.localeCompare(b.id));
@@ -0,0 +1 @@
1
+ export {};