opstruth 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/scan.js CHANGED
@@ -1,10 +1,45 @@
1
1
  import path from 'node:path';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
2
4
  import { walkFiles, readText, readJson, pathExists } from './fs.js';
3
5
  import { redact } from './redact.js';
4
6
  import { mergeIgnores } from './boundary.js';
5
7
 
8
+ const execFileAsync = promisify(execFile);
9
+
6
10
  export const DEFAULT_SKIP_DIRS = mergeIgnores();
7
- export const RISK_PATTERNS = [/OPENAI_API_KEY/i, /SUPABASE_SERVICE_ROLE_KEY/i, /service_role/i, /access_token/i, /refresh_token/i, /client_secret/i, /private_key/i, /webhook_secret/i, /api_key/i, /bearer/i, /authorization/i];
11
+ export const SECRET_CATEGORIES = [
12
+ 'actionable_source_finding',
13
+ 'documentation_reference',
14
+ 'placeholder_or_example',
15
+ 'local_only_file',
16
+ 'generated_artifact',
17
+ 'dependency_or_lockfile',
18
+ 'ignored_binary',
19
+ 'unknown_requires_review'
20
+ ];
21
+
22
+ export const RISK_PATTERNS = [
23
+ { label: 'OPENAI_API_KEY', regex: /OPENAI_API_KEY/i },
24
+ { label: 'SUPABASE_SERVICE_ROLE_KEY', regex: /SUPABASE_SERVICE_ROLE_KEY/i },
25
+ { label: 'service_role', regex: /service_role/i },
26
+ { label: 'access_token', regex: /access_token/i },
27
+ { label: 'refresh_token', regex: /refresh_token/i },
28
+ { label: 'client_secret', regex: /client_secret/i },
29
+ { label: 'private_key', regex: /private_key/i },
30
+ { label: 'webhook_secret', regex: /webhook_secret/i },
31
+ { label: 'api_key', regex: /api_key/i },
32
+ { label: 'bearer', regex: /bearer/i },
33
+ { label: 'authorization', regex: /authorization/i },
34
+ { label: 'GH_TOKEN', regex: /GH_TOKEN/i },
35
+ { label: 'GITHUB_TOKEN', regex: /GITHUB_TOKEN/i },
36
+ { label: 'SUPABASE_ACCESS_TOKEN', regex: /SUPABASE_ACCESS_TOKEN/i },
37
+ { label: 'IMPORT_REDDIT_TIPS_SECRET', regex: /IMPORT_REDDIT_TIPS_SECRET/i },
38
+ { label: 'NPM_TOKEN', regex: /NPM_TOKEN/i },
39
+ { label: 'jwt_like', regex: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/ },
40
+ { label: 'unknown_token_like', regex: /\b[A-Za-z0-9_-]{40,}\b/ }
41
+ ];
42
+
8
43
  const OPSTRUTH_SCANNER_FILES = new Set([
9
44
  'src/lib/redact.js',
10
45
  'src/lib/scan.js',
@@ -16,6 +51,7 @@ const OPSTRUTH_SCANNER_FILES = new Set([
16
51
  'cli/test/typescript-compatibility.test.js',
17
52
  'fixtures/risky-secret-app/src/config.js'
18
53
  ]);
54
+
19
55
  const FIXTURE_PACKAGE_NAMES = new Set([
20
56
  'plain-node-app',
21
57
  'vite-react-app',
@@ -30,23 +66,97 @@ const FIXTURE_PACKAGE_NAMES = new Set([
30
66
  'route-config-app'
31
67
  ]);
32
68
 
33
- export function isLikelyText(file) { return /\.(js|mjs|cjs|ts|tsx|jsx|json|jsonc|toml|yml|yaml|md|txt|env|sql|html|css)$/i.test(file) || !path.extname(file); }
69
+ const LOCKFILE_NAMES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']);
70
+ const GENERATED_PREFIXES = ['dist/', 'dist-ssr/', 'build/', '.next/', '.cache/', 'coverage/'];
71
+ const DEPENDENCY_PREFIXES = ['node_modules/'];
72
+
73
+ export function isLikelyText(file) {
74
+ return /\.(js|mjs|cjs|ts|tsx|jsx|json|jsonc|toml|yml|yaml|md|txt|env|sql|html|css)$/i.test(file) || !path.extname(file);
75
+ }
34
76
 
35
77
  function matchesAllowlist(file, line, { allowlistPaths = [], allowlistPatterns = [] } = {}) {
36
78
  if (allowlistPaths.some((item) => file === item || file.startsWith(item.replace(/\/$/, '') + '/'))) return true;
37
79
  return allowlistPatterns.some((pattern) => {
38
- try { return new RegExp(pattern).test(line) || new RegExp(pattern).test(file); } catch { return false; }
80
+ try {
81
+ return new RegExp(pattern).test(line) || new RegExp(pattern).test(file);
82
+ } catch {
83
+ return false;
84
+ }
39
85
  });
40
86
  }
41
87
 
42
- function classifySecretLine(line) {
43
- if (/[=:]\s*["']?[^"'\s;]+/.test(line)) return 'secret-like value';
88
+ function isDocumentationFile(file) {
89
+ return file === 'README.md' || file.endsWith('/README.md') || file.startsWith('docs/') || file.startsWith('cli/docs/') || /\.md$/i.test(file);
90
+ }
91
+
92
+ function isGeneratedPath(file) {
93
+ return GENERATED_PREFIXES.some((prefix) => file.startsWith(prefix));
94
+ }
95
+
96
+ function isDependencyPath(file) {
97
+ return DEPENDENCY_PREFIXES.some((prefix) => file.startsWith(prefix));
98
+ }
99
+
100
+ function isLockfile(file) {
101
+ return LOCKFILE_NAMES.has(path.basename(file));
102
+ }
103
+
104
+ function isEnvFile(file) {
105
+ return path.basename(file).startsWith('.env');
106
+ }
107
+
108
+ const SECRET_ASSIGNMENT_RE = /\b(?:const\s+|let\s+|var\s+|export\s+)?(?:OPENAI_API_KEY|SUPABASE_SERVICE_ROLE_KEY|GH_TOKEN|GITHUB_TOKEN|SUPABASE_ACCESS_TOKEN|IMPORT_REDDIT_TIPS_SECRET|NPM_TOKEN|service_role|access_token|refresh_token|client_secret|private_key|webhook_secret|api_key|authorization|bearer)\b\s*[:=]\s*["']?([^"'\s;]+)/i;
109
+
110
+ function hasAssignment(line) {
111
+ return SECRET_ASSIGNMENT_RE.test(line);
112
+ }
113
+
114
+ function assignedValue(line) {
115
+ const match = line.match(SECRET_ASSIGNMENT_RE);
116
+ return match?.[1] || '';
117
+ }
118
+
119
+ function isPlaceholderValue(line) {
120
+ const value = assignedValue(line).trim();
121
+ return /^(YOUR_[A-Z0-9_]+_HERE|__REDACTED_VALUE__|<[^>]*secret[^>]*>|example-only|\[REDACTED\]|REDACTED|\*{3,}|xxx+|placeholder)$/i.test(value);
122
+ }
123
+
124
+ function classifyKind(line) {
125
+ if (hasAssignment(line)) return 'secret-like value';
44
126
  return 'secret reference';
45
127
  }
46
128
 
129
+ export function classifySecretReference({ file, line = '', pattern = '', tracked = false, rootContext = 'source file' } = {}) {
130
+ if (!isLikelyText(file)) {
131
+ return { category: 'ignored_binary', severity: 'skipped', status: 'skipped', kind: 'ignored binary', context: 'binary file' };
132
+ }
133
+ if (isDependencyPath(file) || isLockfile(file)) {
134
+ return { category: 'dependency_or_lockfile', severity: 'skipped', status: 'skipped', kind: 'ignored dependency/lockfile', context: 'dependency or lockfile' };
135
+ }
136
+ if (isGeneratedPath(file)) {
137
+ return { category: 'generated_artifact', severity: 'skipped', status: 'skipped', kind: 'ignored generated artifact', context: 'generated artifact' };
138
+ }
139
+ if (isEnvFile(file) && !tracked) {
140
+ return { category: 'local_only_file', severity: 'skipped', status: 'skipped', kind: 'local-only env file', context: 'local-only file' };
141
+ }
142
+ if (isPlaceholderValue(line)) {
143
+ return { category: 'placeholder_or_example', severity: 'info', status: 'info', kind: 'placeholder/example', context: classifySourceContext(file, rootContext) };
144
+ }
145
+ if (pattern === 'unknown_token_like') {
146
+ return { category: 'unknown_requires_review', severity: 'review', status: 'warn', kind: 'unknown token-like content', context: classifySourceContext(file, rootContext) };
147
+ }
148
+ if (isDocumentationFile(file) && !hasAssignment(line)) {
149
+ return { category: 'documentation_reference', severity: 'info', status: 'info', kind: 'documentation reference', context: 'documentation reference' };
150
+ }
151
+ if (isEnvFile(file) && tracked) {
152
+ return { category: 'actionable_source_finding', severity: 'review', status: 'warn', kind: classifyKind(line), context: 'tracked env file' };
153
+ }
154
+ return { category: 'actionable_source_finding', severity: 'review', status: 'warn', kind: classifyKind(line), context: classifySourceContext(file, rootContext) };
155
+ }
156
+
47
157
  function classifySourceContext(file, rootContext = 'source file') {
48
158
  if (file.startsWith('fixtures/') || file.startsWith('cli/fixtures/')) return 'fixture/demo file';
49
- if (file.startsWith('docs/') || file.startsWith('cli/docs/')) return 'documentation reference';
159
+ if (isDocumentationFile(file)) return 'documentation reference';
50
160
  return rootContext;
51
161
  }
52
162
 
@@ -78,37 +188,93 @@ async function isOpstruthRoot(root) {
78
188
  return false;
79
189
  }
80
190
 
81
- export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS, allowlistPaths = [], allowlistPatterns = [] } = {}) {
191
+ async function trackedFiles(root) {
192
+ try {
193
+ const { stdout } = await execFileAsync('git', ['ls-files'], { cwd: root });
194
+ return new Set(stdout.split(/\r?\n/).filter(Boolean).map((file) => file.replaceAll('\\', '/')));
195
+ } catch {
196
+ return new Set();
197
+ }
198
+ }
199
+
200
+ function emptySummary() {
201
+ return Object.fromEntries(SECRET_CATEGORIES.map((category) => [category, 0]));
202
+ }
203
+
204
+ function summaryText(summary) {
205
+ return [
206
+ `Actionable findings: ${summary.actionable_source_finding || 0}`,
207
+ `Documentation references: ${summary.documentation_reference || 0}`,
208
+ `Placeholders/examples: ${summary.placeholder_or_example || 0}`,
209
+ `Local-only files: ${summary.local_only_file || 0}`,
210
+ `Generated artifacts: ${summary.generated_artifact || 0}`,
211
+ `Dependency/lockfile paths: ${summary.dependency_or_lockfile || 0}`,
212
+ `Ignored binaries: ${summary.ignored_binary || 0}`,
213
+ `Unknown requiring review: ${summary.unknown_requires_review || 0}`
214
+ ].join('; ');
215
+ }
216
+
217
+ export function formatSecretSummary(summary = emptySummary()) {
218
+ return summaryText(summary);
219
+ }
220
+
221
+ export async function scanRiskyReferencesDetailed(root, { skipDirs = DEFAULT_SKIP_DIRS, allowlistPaths = [], allowlistPatterns = [] } = {}) {
82
222
  const files = await walkFiles(root, { skipDirs: mergeIgnores(skipDirs) });
223
+ const records = [];
83
224
  const findings = [];
225
+ const summary = emptySummary();
84
226
  const suppressInternalScannerDefinitions = await isOpstruthRoot(root);
85
227
  const rootContext = await classifyRootContext(root);
228
+ const tracked = await trackedFiles(root);
229
+
230
+ function record(item) {
231
+ summary[item.category] = (summary[item.category] || 0) + 1;
232
+ records.push(item);
233
+ if (item.status === 'warn' || item.status === 'fail') findings.push(item);
234
+ }
235
+
86
236
  for (const file of files) {
87
237
  if (suppressInternalScannerDefinitions && OPSTRUTH_SCANNER_FILES.has(file.rel)) continue;
238
+ const isTracked = tracked.has(file.rel);
239
+ const pathOnly = classifySecretReference({ file: file.rel, line: '', tracked: isTracked, rootContext });
240
+ if (pathOnly.category === 'ignored_binary' || pathOnly.category === 'dependency_or_lockfile' || pathOnly.category === 'generated_artifact') {
241
+ record({ file: file.rel, line: null, pattern: 'path', match: 'path', preview: '', excerpt: '', ...pathOnly });
242
+ continue;
243
+ }
244
+ if (pathOnly.category === 'local_only_file') {
245
+ record({ file: file.rel, line: null, pattern: 'env_file', match: 'env_file', preview: '', excerpt: '', ...pathOnly });
246
+ continue;
247
+ }
88
248
  if (!isLikelyText(file.rel)) continue;
89
- if (path.basename(file.rel).startsWith('.env')) continue;
90
249
  let text = '';
91
- try { text = await readText(file.full); } catch { continue; }
250
+ try {
251
+ text = await readText(file.full);
252
+ } catch {
253
+ continue;
254
+ }
92
255
  const lines = text.split(/\r?\n/);
93
256
  lines.forEach((line, index) => {
94
257
  if (matchesAllowlist(file.rel, line, { allowlistPaths, allowlistPatterns })) return;
95
- for (const pattern of RISK_PATTERNS) {
96
- if (pattern.test(line)) {
97
- findings.push({
258
+ for (const matcher of RISK_PATTERNS) {
259
+ if (matcher.regex.test(line)) {
260
+ const classification = classifySecretReference({ file: file.rel, line, pattern: matcher.label, tracked: isTracked, rootContext });
261
+ record({
98
262
  file: file.rel,
99
263
  line: index + 1,
100
- pattern: pattern.source.replaceAll('\\', ''),
101
- match: pattern.source.replaceAll('\\', ''),
102
- kind: classifySecretLine(line),
103
- context: classifySourceContext(file.rel, rootContext),
264
+ pattern: matcher.label,
265
+ match: matcher.label,
104
266
  preview: redact(line.trim()).slice(0, 160),
105
267
  excerpt: redact(line.trim()).slice(0, 160),
106
- severity: 'review'
268
+ ...classification
107
269
  });
108
270
  break;
109
271
  }
110
272
  }
111
273
  });
112
274
  }
113
- return findings;
275
+ return { findings, records, summary, summaryText: summaryText(summary) };
276
+ }
277
+
278
+ export async function scanRiskyReferences(root, options = {}) {
279
+ return (await scanRiskyReferencesDetailed(root, options)).findings;
114
280
  }
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import { runRepo } from './commands/repo.js';
3
2
  import { runSecrets } from './commands/secrets.js';
4
3
  import { runQuality } from './commands/quality.js';
@@ -6,11 +5,11 @@ import { runSupabase } from './commands/supabase.js';
6
5
  import { runCloudflare } from './commands/cloudflare.js';
7
6
  import { runRoutes } from './commands/routes.js';
8
7
  import { runLocal } from './commands/local.js';
8
+ import { runGitHubCi } from './commands/github-ci.js';
9
9
  import { runEvidence } from './commands/evidence.js';
10
10
  import { createResult, finalizeStatus, worstStatus } from './lib/result.js';
11
11
  import { detectStack, hasSupabase, hasCloudflare } from './lib/detect.js';
12
- import { findDefaultRoutesConfig } from './lib/config.js';
13
- import { pathExists } from './lib/fs.js';
12
+ import { findDefaultRoutesConfig, loadOpstruthConfig } from './lib/config.js';
14
13
  import { resolveProjectBoundary } from './lib/boundary.js';
15
14
  import { selectProbes } from './lib/probes.js';
16
15
 
@@ -48,12 +47,19 @@ function probeJson(probe) {
48
47
  };
49
48
  }
50
49
 
50
+ function hasConfigLocalInputs(loadedConfig) {
51
+ if (loadedConfig.warning) return true;
52
+ const configLocal = loadedConfig.config?.local || {};
53
+ return Array.isArray(configLocal.ports) && configLocal.ports.length > 0;
54
+ }
55
+
51
56
  export async function runOrchestrator(options = {}) {
52
57
  const startCwd = options.cwd || process.cwd();
53
58
  const boundary = await resolveProjectBoundary(startCwd);
54
59
  const cwd = boundary.root;
55
60
  const stack = await detectStack(cwd);
56
61
  const probeSelection = await selectProbes({ root: cwd, stack, boundary, options });
62
+ const loadedConfig = await loadOpstruthConfig(cwd);
57
63
  options = { ...options, cwd };
58
64
  const skip = new Set(options.skip || []);
59
65
  const childResults = [];
@@ -68,11 +74,14 @@ export async function runOrchestrator(options = {}) {
68
74
  else childResults.push(skippedResult('supabase', 'Supabase checks skipped because no supabase directory was detected.', 'Supabase database exposure was not checked'));
69
75
  if (await hasCloudflare(cwd)) await maybe('cloudflare', () => runCloudflare(options));
70
76
  else childResults.push(skippedResult('cloudflare', 'Cloudflare checks skipped because no Wrangler config was detected.', 'Cloudflare deployment configuration was not checked'));
77
+ const githubCiConfig = loadedConfig.config?.github?.ci || {};
78
+ if (options.githubCi || githubCiConfig.enabled === true) {
79
+ await maybe('github-ci', () => runGitHubCi({ ...options, workflow: options.workflow || githubCiConfig.workflow }));
80
+ }
71
81
  const routeConfig = await findDefaultRoutesConfig(cwd);
72
82
  if (options.baseUrl || options.routesFile || routeConfig) await maybe('routes', () => runRoutes(options));
73
83
  else childResults.push(skippedResult('routes', 'Route checks skipped because no base URL or routes config was provided.', 'Production/public route availability was not checked'));
74
- const hasLocalConfig = await pathExists(path.join(cwd, 'opstruth.local.json'));
75
- if (options.port?.length || options.healthProvided || options.process || options.service || hasLocalConfig) await maybe('local', () => runLocal(options));
84
+ if (options.port?.length || options.healthProvided || options.process || options.service || hasConfigLocalInputs(loadedConfig)) await maybe('local', () => runLocal(options));
76
85
  else childResults.push(skippedResult('local', 'Local runtime checks skipped because no port, health path, process, or service was provided.', 'Local runtime liveness was not checked'));
77
86
  const aggregate = createResult('opstruth', worstStatus(childResults.map((item) => item.status)), {
78
87
  summary: 'One-command read-only proof run completed.',