opstruth 0.1.2 → 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/README.md +26 -2
- package/examples/supabase-live-redacted-evidence.json +78 -0
- package/package.json +1 -1
- package/src/cli.js +77 -40
- package/src/commands/github-ci.js +338 -0
- package/src/commands/local.js +7 -1
- package/src/commands/probes.js +33 -15
- package/src/commands/quality.js +212 -20
- package/src/commands/routes.js +60 -9
- package/src/commands/secrets.js +37 -12
- package/src/commands/supabase-live.js +564 -0
- package/src/lib/config.js +41 -3
- package/src/lib/exec.js +21 -3
- package/src/lib/git.js +5 -3
- package/src/lib/markdown.js +21 -0
- package/src/lib/probes.js +44 -2
- package/src/lib/redact.js +1 -0
- package/src/lib/scan.js +241 -14
- package/src/orchestrator.js +39 -17
package/src/lib/markdown.js
CHANGED
|
@@ -178,9 +178,24 @@ export function evidenceMarkdown({ title = 'opstruth Evidence Pack', status = 'n
|
|
|
178
178
|
'## Check Results',
|
|
179
179
|
list(checks, '- No checks attached', 25),
|
|
180
180
|
'',
|
|
181
|
+
'## Probe Results',
|
|
182
|
+
list(checks, '- No probe results attached', 25),
|
|
183
|
+
'',
|
|
181
184
|
'## Verified Facts',
|
|
182
185
|
list(liveVerification, '- No live verification evidence attached', 20),
|
|
183
186
|
'',
|
|
187
|
+
'## Warnings',
|
|
188
|
+
list(risks.filter((risk) => /warn|warning|review/i.test(risk)), '- None recorded', 20),
|
|
189
|
+
'',
|
|
190
|
+
'## Failures',
|
|
191
|
+
list(risks.filter((risk) => /fail|failure|blocked/i.test(risk)), '- None recorded', 20),
|
|
192
|
+
'',
|
|
193
|
+
'## Skipped / Not Configured',
|
|
194
|
+
'Skipped checks are proof gaps, not failures. See command output for skipped probe IDs and reasons.',
|
|
195
|
+
'',
|
|
196
|
+
'## Not Verified',
|
|
197
|
+
'Production, local runtime, database state, queues, publishing, and external AI usage are not verified unless explicit read-only inputs or external evidence are attached.',
|
|
198
|
+
'',
|
|
184
199
|
'## Risks And Gaps',
|
|
185
200
|
table(['Severity', 'Finding'], riskRows.slice(0, 25)),
|
|
186
201
|
'',
|
|
@@ -196,6 +211,12 @@ export function evidenceMarkdown({ title = 'opstruth Evidence Pack', status = 'n
|
|
|
196
211
|
'## Safety Boundaries',
|
|
197
212
|
list(safetyBoundaries, '- Read-only checks only'),
|
|
198
213
|
'',
|
|
214
|
+
'## Evidence Files / Paths',
|
|
215
|
+
list(scope.concat(filesChanged).filter(Boolean), '- No evidence paths attached', 30),
|
|
216
|
+
'',
|
|
217
|
+
'## Confidence',
|
|
218
|
+
confidenceFor({ status, failures: risks.filter((risk) => /fail|failure|blocked/i.test(risk)), warnings: risks, skipped: [], notVerified: [] }),
|
|
219
|
+
'',
|
|
199
220
|
'## Next Safe Step',
|
|
200
221
|
nextSafeStep || 'Run the narrowest missing read-only verification and attach the result.'
|
|
201
222
|
].join('\n') + '\n';
|
package/src/lib/probes.js
CHANGED
|
@@ -10,7 +10,7 @@ function nodeDependencyDetector(name) {
|
|
|
10
10
|
return async (_root, stack) => stack.dependencies?.includes(name);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
const RAW_PROBE_CATALOGUE = [
|
|
14
14
|
{
|
|
15
15
|
id: 'git.status',
|
|
16
16
|
name: 'Git status',
|
|
@@ -463,6 +463,48 @@ export const PROBE_CATALOGUE = [
|
|
|
463
463
|
}
|
|
464
464
|
];
|
|
465
465
|
|
|
466
|
+
function inputsRequiredFor(probe) {
|
|
467
|
+
if (probe.id.startsWith('routes.')) return ['--base-url or route config'];
|
|
468
|
+
if (probe.id === 'local.ports') return ['--port or local config'];
|
|
469
|
+
if (probe.id === 'local.health') return ['--port and --health or local config'];
|
|
470
|
+
if (probe.id === 'supabase.migrations') return ['supabase/migrations directory'];
|
|
471
|
+
if (probe.id === 'cloudflare.wrangler') return ['wrangler.toml, wrangler.json, or wrangler.jsonc'];
|
|
472
|
+
if (probe.id.startsWith('quality.')) return ['matching package.json script'];
|
|
473
|
+
if (probe.id.startsWith('node.')) return ['matching package metadata/config/source'];
|
|
474
|
+
if (probe.id.startsWith('git.')) return ['git repository'];
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function skipReasonFor(probe) {
|
|
479
|
+
if (probe.id.startsWith('routes.')) return 'Requires --base-url, --routes, or opstruth.config.json route entries';
|
|
480
|
+
if (probe.id === 'local.ports') return 'Requires --port or opstruth.config.json local ports';
|
|
481
|
+
if (probe.id === 'local.health') return 'Requires --port with --health or opstruth.config.json local health paths';
|
|
482
|
+
if (probe.id === 'supabase.migrations') return 'Requires a Supabase migrations directory';
|
|
483
|
+
if (probe.id === 'cloudflare.wrangler') return 'Requires Wrangler configuration';
|
|
484
|
+
if (probe.id.startsWith('quality.')) return 'Requires a matching non-placeholder package.json script';
|
|
485
|
+
if (probe.id.startsWith('git.')) return 'Requires a git repository';
|
|
486
|
+
return 'Not relevant to detected stack or missing configuration';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function normalizeProbe(probe) {
|
|
490
|
+
const inputsRequired = probe.inputsRequired || inputsRequiredFor(probe);
|
|
491
|
+
return {
|
|
492
|
+
...probe,
|
|
493
|
+
mode: probe.mode || probe.defaultMode,
|
|
494
|
+
mutability: probe.mutability || 'none',
|
|
495
|
+
inputsRequired,
|
|
496
|
+
evidenceExpectation: probe.evidenceExpectation || probe.evidenceCollected || [],
|
|
497
|
+
skipReason: probe.skipReason || skipReasonFor(probe),
|
|
498
|
+
proofLimitation: probe.proofLimitation || probe.doesNotProve,
|
|
499
|
+
supportedStacks: probe.supportedStacks || [probe.stack],
|
|
500
|
+
notVerified: probe.notVerified || [probe.doesNotProve],
|
|
501
|
+
falsePositiveRisk: probe.falsePositiveRisk || 'Low to medium; depends on project conventions and fixture/demo content.',
|
|
502
|
+
falseNegativeRisk: probe.falseNegativeRisk || 'Does not prove absence outside scanned files, configured inputs, or supported stack heuristics.'
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export const PROBE_CATALOGUE = RAW_PROBE_CATALOGUE.map(normalizeProbe);
|
|
507
|
+
|
|
466
508
|
export async function selectProbes({ root, stack, boundary, options = {} }) {
|
|
467
509
|
const only = new Set(options.only || []);
|
|
468
510
|
const skip = new Set(options.skip || []);
|
|
@@ -483,7 +525,7 @@ export async function selectProbes({ root, stack, boundary, options = {} }) {
|
|
|
483
525
|
}
|
|
484
526
|
const relevant = await probe.detector(root, stack, boundary, options);
|
|
485
527
|
if (relevant) selected.push(probe);
|
|
486
|
-
else skipped.push({ ...probe, reason: 'Not relevant to detected stack or missing configuration' });
|
|
528
|
+
else skipped.push({ ...probe, reason: probe.skipReason || 'Not relevant to detected stack or missing configuration' });
|
|
487
529
|
}
|
|
488
530
|
return { selected, skipped, catalogueSize: PROBE_CATALOGUE.length };
|
|
489
531
|
}
|
package/src/lib/redact.js
CHANGED
|
@@ -16,6 +16,7 @@ export function redact(value = '') {
|
|
|
16
16
|
let output = String(value);
|
|
17
17
|
for (const pattern of VALUE_PATTERNS) output = output.replace(pattern, '$1[REDACTED]');
|
|
18
18
|
output = output.replace(/([A-Za-z0-9_]{12,}\.[A-Za-z0-9_\-]{12,}\.[A-Za-z0-9_\-]{12,})/g, '[REDACTED_TOKEN]');
|
|
19
|
+
output = output.replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED_TOKEN]');
|
|
19
20
|
return output;
|
|
20
21
|
}
|
|
21
22
|
|
package/src/lib/scan.js
CHANGED
|
@@ -1,53 +1,280 @@
|
|
|
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
|
|
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',
|
|
11
46
|
'src/lib/probes.js',
|
|
12
47
|
'test/typescript-compatibility.test.js',
|
|
48
|
+
'cli/src/lib/redact.js',
|
|
49
|
+
'cli/src/lib/scan.js',
|
|
50
|
+
'cli/src/lib/probes.js',
|
|
51
|
+
'cli/test/typescript-compatibility.test.js',
|
|
13
52
|
'fixtures/risky-secret-app/src/config.js'
|
|
14
53
|
]);
|
|
15
54
|
|
|
16
|
-
|
|
55
|
+
const FIXTURE_PACKAGE_NAMES = new Set([
|
|
56
|
+
'plain-node-app',
|
|
57
|
+
'vite-react-app',
|
|
58
|
+
'next-app',
|
|
59
|
+
'tanstack-app',
|
|
60
|
+
'cloudflare-worker-app',
|
|
61
|
+
'supabase-app',
|
|
62
|
+
'default-npm-placeholder-test',
|
|
63
|
+
'failing-real-test-script',
|
|
64
|
+
'risky-secret-app',
|
|
65
|
+
'missing-build-script',
|
|
66
|
+
'route-config-app'
|
|
67
|
+
]);
|
|
68
|
+
|
|
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
|
+
}
|
|
76
|
+
|
|
77
|
+
function matchesAllowlist(file, line, { allowlistPaths = [], allowlistPatterns = [] } = {}) {
|
|
78
|
+
if (allowlistPaths.some((item) => file === item || file.startsWith(item.replace(/\/$/, '') + '/'))) return true;
|
|
79
|
+
return allowlistPatterns.some((pattern) => {
|
|
80
|
+
try {
|
|
81
|
+
return new RegExp(pattern).test(line) || new RegExp(pattern).test(file);
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
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';
|
|
126
|
+
return 'secret reference';
|
|
127
|
+
}
|
|
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
|
+
|
|
157
|
+
function classifySourceContext(file, rootContext = 'source file') {
|
|
158
|
+
if (file.startsWith('fixtures/') || file.startsWith('cli/fixtures/')) return 'fixture/demo file';
|
|
159
|
+
if (isDocumentationFile(file)) return 'documentation reference';
|
|
160
|
+
return rootContext;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function classifyRootContext(root) {
|
|
164
|
+
const packageFile = path.join(root, 'package.json');
|
|
165
|
+
if (!(await pathExists(packageFile))) return 'source file';
|
|
166
|
+
try {
|
|
167
|
+
const name = (await readJson(packageFile)).name;
|
|
168
|
+
return FIXTURE_PACKAGE_NAMES.has(name) ? 'fixture/demo file' : 'source file';
|
|
169
|
+
} catch {
|
|
170
|
+
return 'source file';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
17
173
|
|
|
18
174
|
async function isOpstruthRoot(root) {
|
|
19
175
|
const packageFile = path.join(root, 'package.json');
|
|
20
|
-
|
|
21
|
-
try {
|
|
176
|
+
const cliPackageFile = path.join(root, 'cli/package.json');
|
|
177
|
+
try {
|
|
178
|
+
if (await pathExists(packageFile)) {
|
|
179
|
+
const name = (await readJson(packageFile)).name;
|
|
180
|
+
if (name === 'opstruth' || name === 'opstruth-monorepo') return true;
|
|
181
|
+
}
|
|
182
|
+
if (await pathExists(cliPackageFile)) {
|
|
183
|
+
return (await readJson(cliPackageFile)).name === 'opstruth';
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
return false;
|
|
22
189
|
}
|
|
23
190
|
|
|
24
|
-
|
|
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 = [] } = {}) {
|
|
25
222
|
const files = await walkFiles(root, { skipDirs: mergeIgnores(skipDirs) });
|
|
223
|
+
const records = [];
|
|
26
224
|
const findings = [];
|
|
225
|
+
const summary = emptySummary();
|
|
27
226
|
const suppressInternalScannerDefinitions = await isOpstruthRoot(root);
|
|
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
|
+
|
|
28
236
|
for (const file of files) {
|
|
29
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
|
+
}
|
|
30
248
|
if (!isLikelyText(file.rel)) continue;
|
|
31
|
-
if (path.basename(file.rel).startsWith('.env')) continue;
|
|
32
249
|
let text = '';
|
|
33
|
-
try {
|
|
250
|
+
try {
|
|
251
|
+
text = await readText(file.full);
|
|
252
|
+
} catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
34
255
|
const lines = text.split(/\r?\n/);
|
|
35
256
|
lines.forEach((line, index) => {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
257
|
+
if (matchesAllowlist(file.rel, line, { allowlistPaths, allowlistPatterns })) return;
|
|
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({
|
|
39
262
|
file: file.rel,
|
|
40
263
|
line: index + 1,
|
|
41
|
-
pattern:
|
|
42
|
-
match:
|
|
264
|
+
pattern: matcher.label,
|
|
265
|
+
match: matcher.label,
|
|
43
266
|
preview: redact(line.trim()).slice(0, 160),
|
|
44
267
|
excerpt: redact(line.trim()).slice(0, 160),
|
|
45
|
-
|
|
268
|
+
...classification
|
|
46
269
|
});
|
|
47
270
|
break;
|
|
48
271
|
}
|
|
49
272
|
}
|
|
50
273
|
});
|
|
51
274
|
}
|
|
52
|
-
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;
|
|
53
280
|
}
|
package/src/orchestrator.js
CHANGED
|
@@ -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
|
|
|
@@ -25,12 +24,42 @@ function nextStepFor(aggregate) {
|
|
|
25
24
|
return 'Attach the evidence pack to the change or handoff.';
|
|
26
25
|
}
|
|
27
26
|
|
|
27
|
+
function probeJson(probe) {
|
|
28
|
+
return {
|
|
29
|
+
id: probe.id,
|
|
30
|
+
name: probe.name,
|
|
31
|
+
area: probe.area,
|
|
32
|
+
stack: probe.stack,
|
|
33
|
+
mode: probe.mode,
|
|
34
|
+
safetyLevel: probe.safetyLevel,
|
|
35
|
+
defaultMode: probe.defaultMode,
|
|
36
|
+
mutability: probe.mutability,
|
|
37
|
+
inputsRequired: probe.inputsRequired,
|
|
38
|
+
evidenceCollected: probe.evidenceCollected,
|
|
39
|
+
evidenceExpectation: probe.evidenceExpectation,
|
|
40
|
+
proves: probe.proves,
|
|
41
|
+
doesNotProve: probe.doesNotProve,
|
|
42
|
+
proofLimitation: probe.proofLimitation,
|
|
43
|
+
skipReason: probe.skipReason,
|
|
44
|
+
nextSafeStep: probe.nextSafeStep,
|
|
45
|
+
supportedStacks: probe.supportedStacks,
|
|
46
|
+
notVerified: probe.notVerified
|
|
47
|
+
};
|
|
48
|
+
}
|
|
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
|
+
|
|
28
56
|
export async function runOrchestrator(options = {}) {
|
|
29
57
|
const startCwd = options.cwd || process.cwd();
|
|
30
58
|
const boundary = await resolveProjectBoundary(startCwd);
|
|
31
59
|
const cwd = boundary.root;
|
|
32
60
|
const stack = await detectStack(cwd);
|
|
33
61
|
const probeSelection = await selectProbes({ root: cwd, stack, boundary, options });
|
|
62
|
+
const loadedConfig = await loadOpstruthConfig(cwd);
|
|
34
63
|
options = { ...options, cwd };
|
|
35
64
|
const skip = new Set(options.skip || []);
|
|
36
65
|
const childResults = [];
|
|
@@ -45,11 +74,14 @@ export async function runOrchestrator(options = {}) {
|
|
|
45
74
|
else childResults.push(skippedResult('supabase', 'Supabase checks skipped because no supabase directory was detected.', 'Supabase database exposure was not checked'));
|
|
46
75
|
if (await hasCloudflare(cwd)) await maybe('cloudflare', () => runCloudflare(options));
|
|
47
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
|
+
}
|
|
48
81
|
const routeConfig = await findDefaultRoutesConfig(cwd);
|
|
49
82
|
if (options.baseUrl || options.routesFile || routeConfig) await maybe('routes', () => runRoutes(options));
|
|
50
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'));
|
|
51
|
-
|
|
52
|
-
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));
|
|
53
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'));
|
|
54
86
|
const aggregate = createResult('opstruth', worstStatus(childResults.map((item) => item.status)), {
|
|
55
87
|
summary: 'One-command read-only proof run completed.',
|
|
@@ -75,18 +107,8 @@ export async function runOrchestrator(options = {}) {
|
|
|
75
107
|
stack,
|
|
76
108
|
probes: {
|
|
77
109
|
catalogueSize: probeSelection.catalogueSize,
|
|
78
|
-
selected: probeSelection.selected.map(
|
|
79
|
-
|
|
80
|
-
name: probe.name,
|
|
81
|
-
area: probe.area,
|
|
82
|
-
stack: probe.stack,
|
|
83
|
-
safetyLevel: probe.safetyLevel,
|
|
84
|
-
defaultMode: probe.defaultMode,
|
|
85
|
-
proves: probe.proves,
|
|
86
|
-
doesNotProve: probe.doesNotProve,
|
|
87
|
-
evidenceCollected: probe.evidenceCollected
|
|
88
|
-
})),
|
|
89
|
-
skipped: probeSelection.skipped.map((probe) => ({ id: probe.id, area: probe.area, stack: probe.stack, reason: probe.reason }))
|
|
110
|
+
selected: probeSelection.selected.map(probeJson),
|
|
111
|
+
skipped: probeSelection.skipped.map((probe) => ({ ...probeJson(probe), reason: probe.reason }))
|
|
90
112
|
},
|
|
91
113
|
childResults
|
|
92
114
|
},
|