@prodverdict/engine 0.0.1

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.
Files changed (82) hide show
  1. package/dist/config/index.d.ts +3 -0
  2. package/dist/config/index.d.ts.map +1 -0
  3. package/dist/config/index.js +3 -0
  4. package/dist/config/index.js.map +1 -0
  5. package/dist/config/parse.d.ts +4 -0
  6. package/dist/config/parse.d.ts.map +1 -0
  7. package/dist/config/parse.js +40 -0
  8. package/dist/config/parse.js.map +1 -0
  9. package/dist/config/schema.d.ts +468 -0
  10. package/dist/config/schema.d.ts.map +1 -0
  11. package/dist/config/schema.js +66 -0
  12. package/dist/config/schema.js.map +1 -0
  13. package/dist/connectors/fixture.d.ts +5 -0
  14. package/dist/connectors/fixture.d.ts.map +1 -0
  15. package/dist/connectors/fixture.js +16 -0
  16. package/dist/connectors/fixture.js.map +1 -0
  17. package/dist/connectors/index.d.ts +8 -0
  18. package/dist/connectors/index.d.ts.map +1 -0
  19. package/dist/connectors/index.js +6 -0
  20. package/dist/connectors/index.js.map +1 -0
  21. package/dist/connectors/load-fixtures.d.ts +9 -0
  22. package/dist/connectors/load-fixtures.d.ts.map +1 -0
  23. package/dist/connectors/load-fixtures.js +27 -0
  24. package/dist/connectors/load-fixtures.js.map +1 -0
  25. package/dist/connectors/postgres-live.d.ts +4 -0
  26. package/dist/connectors/postgres-live.d.ts.map +1 -0
  27. package/dist/connectors/postgres-live.js +51 -0
  28. package/dist/connectors/postgres-live.js.map +1 -0
  29. package/dist/connectors/sql-identifiers.d.ts +3 -0
  30. package/dist/connectors/sql-identifiers.d.ts.map +1 -0
  31. package/dist/connectors/sql-identifiers.js +13 -0
  32. package/dist/connectors/sql-identifiers.js.map +1 -0
  33. package/dist/connectors/stripe-live.d.ts +3 -0
  34. package/dist/connectors/stripe-live.d.ts.map +1 -0
  35. package/dist/connectors/stripe-live.js +32 -0
  36. package/dist/connectors/stripe-live.js.map +1 -0
  37. package/dist/connectors/types.d.ts +23 -0
  38. package/dist/connectors/types.d.ts.map +1 -0
  39. package/dist/connectors/types.js +2 -0
  40. package/dist/connectors/types.js.map +1 -0
  41. package/dist/evaluators/access.d.ts +9 -0
  42. package/dist/evaluators/access.d.ts.map +1 -0
  43. package/dist/evaluators/access.js +132 -0
  44. package/dist/evaluators/access.js.map +1 -0
  45. package/dist/evaluators/config.d.ts +22 -0
  46. package/dist/evaluators/config.d.ts.map +1 -0
  47. package/dist/evaluators/config.js +191 -0
  48. package/dist/evaluators/config.js.map +1 -0
  49. package/dist/index.d.ts +13 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +7 -0
  52. package/dist/index.js.map +1 -0
  53. package/dist/types.d.ts +22 -0
  54. package/dist/types.d.ts.map +1 -0
  55. package/dist/types.js +4 -0
  56. package/dist/types.js.map +1 -0
  57. package/dist/verdict.d.ts +3 -0
  58. package/dist/verdict.d.ts.map +1 -0
  59. package/dist/verdict.js +8 -0
  60. package/dist/verdict.js.map +1 -0
  61. package/package.json +39 -0
  62. package/src/config/index.ts +2 -0
  63. package/src/config/parse.test.ts +63 -0
  64. package/src/config/parse.ts +48 -0
  65. package/src/config/schema.ts +83 -0
  66. package/src/connectors/fixture.ts +18 -0
  67. package/src/connectors/index.ts +7 -0
  68. package/src/connectors/load-fixtures.test.ts +19 -0
  69. package/src/connectors/load-fixtures.ts +45 -0
  70. package/src/connectors/postgres-live.ts +61 -0
  71. package/src/connectors/sql-identifiers.test.ts +14 -0
  72. package/src/connectors/sql-identifiers.ts +16 -0
  73. package/src/connectors/stripe-live.ts +38 -0
  74. package/src/connectors/types.ts +25 -0
  75. package/src/evaluators/access.test.ts +206 -0
  76. package/src/evaluators/access.ts +159 -0
  77. package/src/evaluators/config.test.ts +266 -0
  78. package/src/evaluators/config.ts +213 -0
  79. package/src/index.ts +12 -0
  80. package/src/types.ts +27 -0
  81. package/src/verdict.test.ts +29 -0
  82. package/src/verdict.ts +7 -0
@@ -0,0 +1,213 @@
1
+ import type { Finding } from '../types.js';
2
+ import type { ConfigContractConfig } from '../config/schema.js';
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+
6
+ export interface ConfigDataSources {
7
+ /** Absolute path to the repository root to scan */
8
+ repoRoot: string;
9
+ /**
10
+ * Resolved env vars available at check time.
11
+ * Typically `process.env` but can be overridden for testing.
12
+ */
13
+ env: Record<string, string | undefined>;
14
+ }
15
+
16
+ /**
17
+ * Scan JS/TS source files in the repo for environment variable references.
18
+ * Returns all unique variable names referenced via process.env.X or import.meta.env.X.
19
+ */
20
+ export function scanEnvReferences(repoRoot: string): Set<string> {
21
+ const referenced = new Set<string>();
22
+ const PROCESS_ENV_RE = /process\.env\.([A-Z][A-Z0-9_]*)/g;
23
+ const META_ENV_RE = /import\.meta\.env\.([A-Z][A-Z0-9_]*)/g;
24
+ const ENV_CALL_RE = /process\.env\['([A-Z][A-Z0-9_]*)'\]/g;
25
+
26
+ const SCAN_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts']);
27
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.nuxt', 'build', 'coverage']);
28
+
29
+ function walk(dir: string) {
30
+ let entries: fs.Dirent[];
31
+ try {
32
+ entries = fs.readdirSync(dir, { withFileTypes: true });
33
+ } catch {
34
+ return;
35
+ }
36
+
37
+ for (const entry of entries) {
38
+ if (SKIP_DIRS.has(entry.name)) continue;
39
+ const fullPath = path.join(dir, entry.name);
40
+ if (entry.isDirectory()) {
41
+ walk(fullPath);
42
+ } else if (entry.isFile() && SCAN_EXTENSIONS.has(path.extname(entry.name))) {
43
+ // Skip test/spec files — they contain fixture env var names, not production usage
44
+ if (/\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|mts)$/i.test(entry.name)) continue;
45
+ let content: string;
46
+ try {
47
+ content = fs.readFileSync(fullPath, 'utf8');
48
+ } catch {
49
+ continue;
50
+ }
51
+ for (const re of [PROCESS_ENV_RE, META_ENV_RE, ENV_CALL_RE]) {
52
+ re.lastIndex = 0;
53
+ let match: RegExpExecArray | null;
54
+ while ((match = re.exec(content)) !== null) {
55
+ referenced.add(match[1]!);
56
+ }
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ walk(repoRoot);
63
+ return referenced;
64
+ }
65
+
66
+ /**
67
+ * Parse a .env or .env.example file into a map of key -> value (or empty string if no value).
68
+ */
69
+ export function parseEnvFile(filePath: string): Map<string, string> {
70
+ const vars = new Map<string, string>();
71
+ let content: string;
72
+ try {
73
+ content = fs.readFileSync(filePath, 'utf8');
74
+ } catch {
75
+ return vars;
76
+ }
77
+
78
+ for (const line of content.split('\n')) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed || trimmed.startsWith('#')) continue;
81
+ const eqIdx = trimmed.indexOf('=');
82
+ if (eqIdx === -1) {
83
+ vars.set(trimmed, '');
84
+ } else {
85
+ const key = trimmed.slice(0, eqIdx).trim();
86
+ const val = trimmed.slice(eqIdx + 1).trim().replace(/^["']|["']$/g, '');
87
+ if (key) vars.set(key, val);
88
+ }
89
+ }
90
+ return vars;
91
+ }
92
+
93
+ const PLACEHOLDER_VALUES = new Set([
94
+ '',
95
+ 'your_key_here',
96
+ 'your-key-here',
97
+ 'changeme',
98
+ 'change_me',
99
+ 'replace_me',
100
+ 'placeholder',
101
+ 'xxx',
102
+ 'todo',
103
+ 'fixme',
104
+ 'secret',
105
+ 'password',
106
+ '123456',
107
+ 'localhost',
108
+ 'example',
109
+ ]);
110
+
111
+ function isPlaceholder(value: string): boolean {
112
+ return PLACEHOLDER_VALUES.has(value.toLowerCase());
113
+ }
114
+
115
+ export async function evaluateConfig(
116
+ cfg: ConfigContractConfig,
117
+ sources: ConfigDataSources,
118
+ ): Promise<Finding[]> {
119
+ const findings: Finding[] = [];
120
+ const { repoRoot, env } = sources;
121
+
122
+ // ── 1. Required vars must be set ────────────────────────────────────────────
123
+ for (const rule of cfg.rules) {
124
+ if (rule.type === 'required') {
125
+ const { name, severity = cfg.severity, description } = rule;
126
+ const value = env[name];
127
+ if (value === undefined || value === '') {
128
+ findings.push({
129
+ contract: 'config',
130
+ severity,
131
+ entity: `env:${name}`,
132
+ message: `Required environment variable "${name}" is not set.${description ? ` (${description})` : ''}`,
133
+ fix: `Set ${name} in your environment or CI secrets.`,
134
+ });
135
+ } else if (cfg.check_placeholders && isPlaceholder(value)) {
136
+ findings.push({
137
+ contract: 'config',
138
+ severity: 'medium',
139
+ entity: `env:${name}`,
140
+ message: `Environment variable "${name}" appears to contain a placeholder value.`,
141
+ fix: `Replace the placeholder value of ${name} with a real secret.`,
142
+ });
143
+ }
144
+ }
145
+
146
+ if (rule.type === 'not_default') {
147
+ const { name, forbidden_values, severity = cfg.severity } = rule;
148
+ const value = env[name];
149
+ if (value !== undefined && forbidden_values.some((f) => value === f || value.toLowerCase() === f.toLowerCase())) {
150
+ findings.push({
151
+ contract: 'config',
152
+ severity,
153
+ entity: `env:${name}`,
154
+ message: `Environment variable "${name}" is set to a forbidden/default value.`,
155
+ fix: `Change ${name} from its default value before deploying to production.`,
156
+ });
157
+ }
158
+ }
159
+ }
160
+
161
+ // ── 2. Scan repo for env var references and compare against .env.example ────
162
+ if (cfg.scan_references) {
163
+ const referencedVars = scanEnvReferences(repoRoot);
164
+
165
+ // Load .env.example if it exists
166
+ const examplePath = path.join(repoRoot, cfg.env_example_file ?? '.env.example');
167
+ const exampleVars = parseEnvFile(examplePath);
168
+
169
+ // Load .env if it exists (for local dev)
170
+ const localEnvPath = path.join(repoRoot, '.env');
171
+ const localEnvVars = parseEnvFile(localEnvPath);
172
+
173
+ // Combine all "known" declared vars (example + local + current env)
174
+ const knownVars = new Set([
175
+ ...exampleVars.keys(),
176
+ ...localEnvVars.keys(),
177
+ ...Object.keys(env).filter((k) => env[k] !== undefined),
178
+ ]);
179
+
180
+ // Common infrastructure vars that are universally available and not worth warning about
181
+ const ALWAYS_AVAILABLE = new Set([
182
+ 'NODE_ENV',
183
+ 'PORT',
184
+ 'HOST',
185
+ 'HOME',
186
+ 'PATH',
187
+ 'SHELL',
188
+ 'USER',
189
+ 'PWD',
190
+ 'CI',
191
+ 'GITHUB_ACTIONS',
192
+ 'VERCEL',
193
+ 'VERCEL_ENV',
194
+ 'VERCEL_URL',
195
+ 'NEXT_PUBLIC_VERCEL_URL',
196
+ ]);
197
+
198
+ for (const varName of referencedVars) {
199
+ if (ALWAYS_AVAILABLE.has(varName)) continue;
200
+ if (!knownVars.has(varName) && !(cfg.ignore_vars ?? []).includes(varName)) {
201
+ findings.push({
202
+ contract: 'config',
203
+ severity: 'low',
204
+ entity: `env:${varName}`,
205
+ message: `"${varName}" is referenced in source code but not declared in ${cfg.env_example_file ?? '.env.example'}.`,
206
+ fix: `Add ${varName} to ${cfg.env_example_file ?? '.env.example'} so all environments know about it.`,
207
+ });
208
+ }
209
+ }
210
+ }
211
+
212
+ return findings;
213
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export type { Finding, CheckResult, Verdict, Severity, ContractType, ProdVerdictError } from './types.js';
2
+ export { isProdVerdictError } from './types.js';
3
+ export { parseConfigFile, validateConfig } from './config/index.js';
4
+ export type { ProdVerdictConfig, AccessContractConfig, ConfigContractConfig, ConfigRule } from './config/index.js';
5
+ export type { StripeReader, StripeSubscription, DatabaseReader, AppUser } from './connectors/index.js';
6
+ export { createLiveStripeReader, createLivePostgresReader, createFixtureStripeReader, createFixtureDatabaseReader, loadFixtureSubscriptions, loadFixtureUsers, defaultFixturePaths, assertSqlIdentifier, assertSqlIdentifiers } from './connectors/index.js';
7
+ export type { FixturePaths } from './connectors/index.js';
8
+ export { evaluateAccess } from './evaluators/access.js';
9
+ export type { AccessDataSources } from './evaluators/access.js';
10
+ export { evaluateConfig, scanEnvReferences, parseEnvFile } from './evaluators/config.js';
11
+ export type { ConfigDataSources } from './evaluators/config.js';
12
+ export { aggregateVerdict } from './verdict.js';
package/src/types.ts ADDED
@@ -0,0 +1,27 @@
1
+ export type Severity = 'high' | 'medium' | 'low';
2
+ export type Verdict = 'pass' | 'warn' | 'fail';
3
+ export type ContractType = 'access' | 'config' | 'migration' | 'boundary' | 'restore';
4
+
5
+ export interface Finding {
6
+ contract: ContractType;
7
+ severity: Severity;
8
+ /** Namespaced entity identifier, e.g. "user:usr_abc" or "price:price_pro" */
9
+ entity: string;
10
+ message: string;
11
+ fix?: string | undefined;
12
+ }
13
+
14
+ export interface CheckResult {
15
+ contract: ContractType;
16
+ verdict: Verdict;
17
+ findings: Finding[];
18
+ evaluatedAt: string;
19
+ }
20
+
21
+ export interface ProdVerdictError extends Error {
22
+ code: 'CONFIG_INVALID' | 'CONNECTOR_ERROR' | 'UNKNOWN';
23
+ }
24
+
25
+ export function isProdVerdictError(err: unknown): err is ProdVerdictError {
26
+ return err instanceof Error && 'code' in err;
27
+ }
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { aggregateVerdict } from './verdict.js';
3
+ import type { Finding } from './types.js';
4
+
5
+ function f(severity: Finding['severity']): Finding {
6
+ return { contract: 'access', severity, entity: 'test', message: 'test' };
7
+ }
8
+
9
+ describe('aggregateVerdict', () => {
10
+ it('returns pass when no findings', () => {
11
+ expect(aggregateVerdict([])).toBe('pass');
12
+ });
13
+
14
+ it('returns fail when any high finding', () => {
15
+ expect(aggregateVerdict([f('high'), f('low')])).toBe('fail');
16
+ });
17
+
18
+ it('returns warn when only medium findings', () => {
19
+ expect(aggregateVerdict([f('medium')])).toBe('warn');
20
+ });
21
+
22
+ it('returns warn when only low findings', () => {
23
+ expect(aggregateVerdict([f('low')])).toBe('warn');
24
+ });
25
+
26
+ it('returns fail when high + medium', () => {
27
+ expect(aggregateVerdict([f('medium'), f('high')])).toBe('fail');
28
+ });
29
+ });
package/src/verdict.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { Finding, Verdict } from './types.js';
2
+
3
+ export function aggregateVerdict(findings: Finding[]): Verdict {
4
+ if (findings.some((f) => f.severity === 'high')) return 'fail';
5
+ if (findings.some((f) => f.severity === 'medium' || f.severity === 'low')) return 'warn';
6
+ return 'pass';
7
+ }