@timidan/rite 0.1.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/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/assets/favicon.ico +0 -0
- package/dist/assets/index-CUOHpkRl.js +18 -0
- package/dist/assets/index-kPEQ2Fln.css +1 -0
- package/dist/assets/rite-180.png +0 -0
- package/dist/assets/rite-logo-primary-1200.png +0 -0
- package/dist/assets/rite-og-1200x630.png +0 -0
- package/dist/evidence/proof.json +3473 -0
- package/dist/index.html +30 -0
- package/package.json +70 -0
- package/src/cli/rite.js +510 -0
- package/src/core/engine.js +263 -0
- package/src/core/loader.js +72 -0
- package/src/core/sarif.js +126 -0
- package/src/github/app.js +134 -0
- package/src/github/server.js +411 -0
- package/src/graph/walker.js +220 -0
- package/src/instrument/auto-adapter.js +237 -0
- package/src/instrument/effect-schema.js +168 -0
- package/src/mcp/server.js +185 -0
- package/src/watsonx/draft.js +252 -0
- package/templates/rite.yml +43 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/engine.js — Shared verification engine.
|
|
3
|
+
*
|
|
4
|
+
* verify(config, adapter, context) is the single function that owns
|
|
5
|
+
* validation, case execution, effect comparison, and report construction.
|
|
6
|
+
* CLI, MCP, and CI all call this function; none of them re-implement policy.
|
|
7
|
+
*
|
|
8
|
+
* No DOM globals. No React. No Vite-specific globals.
|
|
9
|
+
* Filesystem reads (adapter import, config load) are handled by callers;
|
|
10
|
+
* this module receives already-parsed config and a resolved adapter path.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { compareEffects } from '../instrument/effect-schema.js';
|
|
14
|
+
|
|
15
|
+
export const SCHEMA_VERSION = 1;
|
|
16
|
+
export const ENGINE_VERSION = '0.1.0';
|
|
17
|
+
|
|
18
|
+
// ------------------------------------------------------------------ //
|
|
19
|
+
// Config validation //
|
|
20
|
+
// ------------------------------------------------------------------ //
|
|
21
|
+
|
|
22
|
+
const VALID_DECISIONS = new Set(['allow', 'deny']);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {{
|
|
26
|
+
* version: number,
|
|
27
|
+
* ruleId: string,
|
|
28
|
+
* rule: string,
|
|
29
|
+
* adapter: string,
|
|
30
|
+
* paths: Array<{ entry: string, sink: string, source: string }>,
|
|
31
|
+
* cases: Array<{
|
|
32
|
+
* id: string,
|
|
33
|
+
* path: string,
|
|
34
|
+
* actor: string,
|
|
35
|
+
* input: object,
|
|
36
|
+
* expected: {
|
|
37
|
+
* decision: 'allow' | 'deny',
|
|
38
|
+
* effects: object[],
|
|
39
|
+
* stateChanged: boolean,
|
|
40
|
+
* }
|
|
41
|
+
* }>
|
|
42
|
+
* }} RiteConfig
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Validate a parsed config object.
|
|
47
|
+
* Returns an array of error strings (empty = valid).
|
|
48
|
+
* @param {unknown} config
|
|
49
|
+
* @returns {string[]}
|
|
50
|
+
*/
|
|
51
|
+
export function validateConfig(config) {
|
|
52
|
+
const errs = [];
|
|
53
|
+
if (!config || typeof config !== 'object') {
|
|
54
|
+
errs.push('config must be an object');
|
|
55
|
+
return errs;
|
|
56
|
+
}
|
|
57
|
+
const c = /** @type {Record<string,unknown>} */ (config);
|
|
58
|
+
|
|
59
|
+
if (c.version !== 1) errs.push(`config.version must be 1 (got ${c.version})`);
|
|
60
|
+
if (typeof c.ruleId !== 'string' || !c.ruleId.trim())
|
|
61
|
+
errs.push('config.ruleId must be a non-empty string');
|
|
62
|
+
if (typeof c.rule !== 'string' || !c.rule.trim())
|
|
63
|
+
errs.push('config.rule must be a non-empty string');
|
|
64
|
+
if (typeof c.adapter !== 'string' || !c.adapter.trim())
|
|
65
|
+
errs.push('config.adapter must be a non-empty string');
|
|
66
|
+
|
|
67
|
+
// Paths
|
|
68
|
+
if (!Array.isArray(c.paths) || c.paths.length === 0)
|
|
69
|
+
errs.push('config.paths must be a non-empty array');
|
|
70
|
+
else {
|
|
71
|
+
const pathNames = new Set();
|
|
72
|
+
for (let i = 0; i < c.paths.length; i++) {
|
|
73
|
+
const p = /** @type {Record<string,unknown>} */ (c.paths[i]);
|
|
74
|
+
if (typeof p.entry !== 'string' || !p.entry.trim())
|
|
75
|
+
errs.push(`config.paths[${i}].entry must be a non-empty string`);
|
|
76
|
+
else {
|
|
77
|
+
if (pathNames.has(p.entry)) errs.push(`Duplicate path entry: ${p.entry}`);
|
|
78
|
+
pathNames.add(p.entry);
|
|
79
|
+
}
|
|
80
|
+
if (typeof p.sink !== 'string' || !p.sink.trim())
|
|
81
|
+
errs.push(`config.paths[${i}].sink must be a non-empty string`);
|
|
82
|
+
if (typeof p.source !== 'string' || !p.source.trim())
|
|
83
|
+
errs.push(`config.paths[${i}].source must be a non-empty string`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Cases
|
|
88
|
+
if (!Array.isArray(c.cases) || c.cases.length === 0)
|
|
89
|
+
errs.push('config.cases must be a non-empty array');
|
|
90
|
+
else {
|
|
91
|
+
const caseIds = new Set();
|
|
92
|
+
const configPaths = Array.isArray(c.paths)
|
|
93
|
+
? new Set(c.paths.map(p => /** @type {Record<string,unknown>} */ (p).entry))
|
|
94
|
+
: new Set();
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < c.cases.length; i++) {
|
|
97
|
+
const cs = /** @type {Record<string,unknown>} */ (c.cases[i]);
|
|
98
|
+
if (typeof cs.id !== 'string' || !cs.id.trim())
|
|
99
|
+
errs.push(`config.cases[${i}].id must be a non-empty string`);
|
|
100
|
+
else {
|
|
101
|
+
if (caseIds.has(cs.id)) errs.push(`Duplicate case id: ${cs.id}`);
|
|
102
|
+
caseIds.add(cs.id);
|
|
103
|
+
}
|
|
104
|
+
if (typeof cs.path !== 'string' || !cs.path.trim())
|
|
105
|
+
errs.push(`config.cases[${i}].path must be a non-empty string`);
|
|
106
|
+
else if (!configPaths.has(cs.path))
|
|
107
|
+
errs.push(`config.cases[${i}].path "${cs.path}" not in config.paths`);
|
|
108
|
+
if (typeof cs.actor !== 'string' || !cs.actor.trim())
|
|
109
|
+
errs.push(`config.cases[${i}].actor must be a non-empty string`);
|
|
110
|
+
if (!cs.input || typeof cs.input !== 'object')
|
|
111
|
+
errs.push(`config.cases[${i}].input must be an object`);
|
|
112
|
+
|
|
113
|
+
const exp = /** @type {Record<string,unknown>} */ (cs.expected);
|
|
114
|
+
if (!exp || typeof exp !== 'object')
|
|
115
|
+
errs.push(`config.cases[${i}].expected must be an object`);
|
|
116
|
+
else {
|
|
117
|
+
if (!VALID_DECISIONS.has(/** @type {string} */ (exp.decision)))
|
|
118
|
+
errs.push(`config.cases[${i}].expected.decision must be 'allow'|'deny'`);
|
|
119
|
+
if (!Array.isArray(exp.effects))
|
|
120
|
+
errs.push(`config.cases[${i}].expected.effects must be an array`);
|
|
121
|
+
if (typeof exp.stateChanged !== 'boolean')
|
|
122
|
+
errs.push(`config.cases[${i}].expected.stateChanged must be a boolean`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return errs;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ------------------------------------------------------------------ //
|
|
131
|
+
// Case execution //
|
|
132
|
+
// ------------------------------------------------------------------ //
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @typedef {{
|
|
136
|
+
* id: string,
|
|
137
|
+
* path: string,
|
|
138
|
+
* status: 'PASS'|'FAIL'|'ERROR',
|
|
139
|
+
* expected: object,
|
|
140
|
+
* observed: object,
|
|
141
|
+
* failures: string[],
|
|
142
|
+
* error: string | null,
|
|
143
|
+
* }} CaseResult
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Run one case through the adapter.
|
|
148
|
+
* The adapter is responsible for all isolation and fixture setup.
|
|
149
|
+
* @param {object} caseSpec
|
|
150
|
+
* @param {object} adapter — { runCase }
|
|
151
|
+
* @returns {Promise<CaseResult>}
|
|
152
|
+
*/
|
|
153
|
+
async function runOneCase(caseSpec, adapter) {
|
|
154
|
+
const result = {
|
|
155
|
+
id: caseSpec.id,
|
|
156
|
+
path: caseSpec.path,
|
|
157
|
+
status: /** @type {'PASS'|'FAIL'|'ERROR'} */ ('PASS'),
|
|
158
|
+
expected: caseSpec.expected,
|
|
159
|
+
observed: null,
|
|
160
|
+
failures: [],
|
|
161
|
+
error: null,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
let observed;
|
|
165
|
+
try {
|
|
166
|
+
observed = await adapter.runCase(caseSpec);
|
|
167
|
+
} catch (err) {
|
|
168
|
+
result.status = 'ERROR';
|
|
169
|
+
result.error = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!observed || typeof observed !== 'object') {
|
|
174
|
+
result.status = 'ERROR';
|
|
175
|
+
result.error = `Adapter returned non-object: ${JSON.stringify(observed)}`;
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
result.observed = observed;
|
|
180
|
+
|
|
181
|
+
const exp = caseSpec.expected;
|
|
182
|
+
const failures = [];
|
|
183
|
+
|
|
184
|
+
// 1. Decision
|
|
185
|
+
const observedDecision = observed.decision;
|
|
186
|
+
if (observedDecision !== exp.decision)
|
|
187
|
+
failures.push(`decision: expected "${exp.decision}"; observed "${observedDecision}"`);
|
|
188
|
+
|
|
189
|
+
// 2–4. Effects: count + structural match + schema (unified via compareEffects)
|
|
190
|
+
const observedEffects = Array.isArray(observed.effects) ? observed.effects : [];
|
|
191
|
+
failures.push(...compareEffects(observedEffects, exp.effects, caseSpec.effectSchema));
|
|
192
|
+
|
|
193
|
+
// 5. State change
|
|
194
|
+
const observedStateChanged = observed.stateChanged;
|
|
195
|
+
if (observedStateChanged !== exp.stateChanged)
|
|
196
|
+
failures.push(`stateChanged: expected ${exp.stateChanged}; observed ${observedStateChanged}`);
|
|
197
|
+
|
|
198
|
+
result.failures = failures;
|
|
199
|
+
result.status = failures.length > 0 ? 'FAIL' : 'PASS';
|
|
200
|
+
return result;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ------------------------------------------------------------------ //
|
|
204
|
+
// Main verify function //
|
|
205
|
+
// ------------------------------------------------------------------ //
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* @typedef {{
|
|
209
|
+
* schemaVersion: number,
|
|
210
|
+
* engineVersion: string,
|
|
211
|
+
* ruleId: string,
|
|
212
|
+
* rule: string,
|
|
213
|
+
* configuredPaths: Array<{ entry: string, sink: string, source: string }>,
|
|
214
|
+
* generatedAt: string,
|
|
215
|
+
* status: 'PASS'|'FAIL'|'ERROR',
|
|
216
|
+
* results: CaseResult[],
|
|
217
|
+
* error: string | null,
|
|
218
|
+
* }} VerifyReport
|
|
219
|
+
*/
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Run the full verification against a loaded adapter.
|
|
223
|
+
* Returns a VerifyReport regardless of pass/fail.
|
|
224
|
+
*
|
|
225
|
+
* @param {RiteConfig} config
|
|
226
|
+
* @param {{ runCase: (caseSpec: object) => Promise<object> }} adapter
|
|
227
|
+
* @param {{ configPath?: string }} [context]
|
|
228
|
+
* @returns {Promise<VerifyReport>}
|
|
229
|
+
*/
|
|
230
|
+
export async function verify(config, adapter, context = {}) {
|
|
231
|
+
const report = {
|
|
232
|
+
schemaVersion: SCHEMA_VERSION,
|
|
233
|
+
engineVersion: ENGINE_VERSION,
|
|
234
|
+
ruleId: config.ruleId,
|
|
235
|
+
rule: config.rule,
|
|
236
|
+
configuredPaths: config.paths,
|
|
237
|
+
generatedAt: new Date().toISOString(),
|
|
238
|
+
status: /** @type {'PASS'|'FAIL'|'ERROR'} */ ('PASS'),
|
|
239
|
+
results: [],
|
|
240
|
+
error: null,
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
if (!config.cases?.length) {
|
|
244
|
+
report.status = 'ERROR';
|
|
245
|
+
report.error = 'No cases configured.';
|
|
246
|
+
return report;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
for (const caseSpec of config.cases) {
|
|
250
|
+
const result = await runOneCase(caseSpec, adapter);
|
|
251
|
+
report.results.push(result);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Aggregate: ERROR > FAIL > PASS
|
|
255
|
+
let status = 'PASS';
|
|
256
|
+
for (const r of report.results) {
|
|
257
|
+
if (r.status === 'ERROR') { status = 'ERROR'; break; }
|
|
258
|
+
if (r.status === 'FAIL') status = 'FAIL';
|
|
259
|
+
}
|
|
260
|
+
report.status = /** @type {'PASS'|'FAIL'|'ERROR'} */ (status);
|
|
261
|
+
|
|
262
|
+
return report;
|
|
263
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/loader.js — Shared adapter loader.
|
|
3
|
+
*
|
|
4
|
+
* Resolves, bounds-checks, and imports a Rite adapter from a config-relative
|
|
5
|
+
* path. Used by the CLI, MCP server, and GitHub server — none of them
|
|
6
|
+
* re-implement path validation or the pathToFileURL import pattern.
|
|
7
|
+
*
|
|
8
|
+
* No DOM globals. No React. Pure Node.js (fs + url + path).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync } from 'node:fs';
|
|
12
|
+
import { resolve, relative, isAbsolute, dirname } from 'node:path';
|
|
13
|
+
import { pathToFileURL } from 'node:url';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{
|
|
17
|
+
* ok: true,
|
|
18
|
+
* adapter: { runCase: (caseSpec: object) => Promise<object> },
|
|
19
|
+
* adapterPath: string,
|
|
20
|
+
* } | {
|
|
21
|
+
* ok: false,
|
|
22
|
+
* error: string,
|
|
23
|
+
* }} LoadResult
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load a Rite adapter from a path relative to the config file's directory.
|
|
28
|
+
*
|
|
29
|
+
* Validates:
|
|
30
|
+
* - adapterRel does not escape the configDir (no path traversal)
|
|
31
|
+
* - The resolved file exists
|
|
32
|
+
* - The loaded module exports a runCase() function
|
|
33
|
+
*
|
|
34
|
+
* @param {string} configDir — absolute directory containing rite.config.json
|
|
35
|
+
* @param {string} adapterRel — config.adapter value (relative path)
|
|
36
|
+
* @returns {Promise<LoadResult>}
|
|
37
|
+
*/
|
|
38
|
+
export async function loadAdapter(configDir, adapterRel) {
|
|
39
|
+
// Bounds check — adapter must stay inside config dir
|
|
40
|
+
const adapterPath = resolve(configDir, adapterRel);
|
|
41
|
+
const rel = relative(configDir, adapterPath);
|
|
42
|
+
if (isAbsolute(rel) || rel.startsWith('..')) {
|
|
43
|
+
return { ok: false, error: `Adapter path escapes the config directory: ${adapterRel}` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!existsSync(adapterPath)) {
|
|
47
|
+
return { ok: false, error: `Adapter not found: ${adapterPath}` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let adapter;
|
|
51
|
+
try {
|
|
52
|
+
adapter = await import(pathToFileURL(adapterPath).href);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return { ok: false, error: `Failed to load adapter: ${e.message}` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof adapter.runCase !== 'function') {
|
|
58
|
+
return { ok: false, error: `Adapter must export runCase() — not found in ${adapterPath}` };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { ok: true, adapter, adapterPath };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Convenience: load adapter from a fully-parsed config object + config file path.
|
|
66
|
+
* @param {object} config — validated RiteConfig
|
|
67
|
+
* @param {string} configPath — absolute path to the config file
|
|
68
|
+
* @returns {Promise<LoadResult>}
|
|
69
|
+
*/
|
|
70
|
+
export async function loadAdapterFromConfig(config, configPath) {
|
|
71
|
+
return loadAdapter(dirname(configPath), config.adapter);
|
|
72
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/sarif.js — SARIF 2.1.0 output formatter.
|
|
3
|
+
*
|
|
4
|
+
* Converts a Rite VerifyReport into a SARIF document suitable for upload
|
|
5
|
+
* to GitHub Code Scanning via `actions/upload-sarif`.
|
|
6
|
+
*
|
|
7
|
+
* Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
|
|
8
|
+
* GitHub upload: https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* rite verify --config rite.config.json --sarif results.sarif
|
|
12
|
+
*
|
|
13
|
+
* SARIF level mapping:
|
|
14
|
+
* FAIL → error (blocks merge when uploaded as a required check)
|
|
15
|
+
* ERROR → error
|
|
16
|
+
* PASS → note (informational, does not block)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { ENGINE_VERSION } from './engine.js';
|
|
20
|
+
|
|
21
|
+
const SARIF_SCHEMA = 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json';
|
|
22
|
+
const SARIF_VERSION = '2.1.0';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {import('./engine.js').VerifyReport} VerifyReport
|
|
26
|
+
* @typedef {import('./engine.js').CaseResult} CaseResult
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Map a case status to a SARIF notification level.
|
|
31
|
+
* @param {'PASS'|'FAIL'|'ERROR'} status
|
|
32
|
+
* @returns {'error'|'warning'|'note'}
|
|
33
|
+
*/
|
|
34
|
+
function toLevel(status) {
|
|
35
|
+
if (status === 'PASS') return 'note';
|
|
36
|
+
return 'error';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build a SARIF result for one case.
|
|
41
|
+
* @param {CaseResult} r
|
|
42
|
+
* @param {string} ruleId
|
|
43
|
+
* @returns {object}
|
|
44
|
+
*/
|
|
45
|
+
function buildResult(r, ruleId) {
|
|
46
|
+
const level = toLevel(r.status);
|
|
47
|
+
const message = r.status === 'PASS'
|
|
48
|
+
? `${r.id}: authorization check passed on path "${r.path}".`
|
|
49
|
+
: r.status === 'ERROR'
|
|
50
|
+
? `${r.id}: case execution error on path "${r.path}": ${r.error?.split('\n')[0]}`
|
|
51
|
+
: `${r.id}: authorization check FAILED on path "${r.path}". ${r.failures.join(' | ')}`;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
ruleId,
|
|
55
|
+
level,
|
|
56
|
+
message: { text: message },
|
|
57
|
+
locations: [
|
|
58
|
+
{
|
|
59
|
+
physicalLocation: {
|
|
60
|
+
artifactLocation: {
|
|
61
|
+
uri: r.observed?.sourceFile ?? 'src/refunds.js',
|
|
62
|
+
uriBaseId: '%SRCROOT%',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
logicalLocations: [
|
|
66
|
+
{ name: r.path, kind: 'function' },
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
partialFingerprints: {
|
|
71
|
+
'rite/caseId': r.id,
|
|
72
|
+
'rite/path': r.path,
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Convert a VerifyReport to a SARIF 2.1.0 document.
|
|
79
|
+
* @param {VerifyReport} report
|
|
80
|
+
* @returns {object} — serialize with JSON.stringify
|
|
81
|
+
*/
|
|
82
|
+
export function toSarif(report) {
|
|
83
|
+
const ruleId = report.ruleId ?? 'rite-authorization';
|
|
84
|
+
|
|
85
|
+
const rules = [
|
|
86
|
+
{
|
|
87
|
+
id: ruleId,
|
|
88
|
+
name: 'RiteAuthorizationCheck',
|
|
89
|
+
shortDescription: { text: report.rule ?? 'Authorization rule check' },
|
|
90
|
+
fullDescription: {
|
|
91
|
+
text: `Rite checks that the written authorization rule holds across all configured entry paths. Rule: ${report.rule}`,
|
|
92
|
+
},
|
|
93
|
+
defaultConfiguration: { level: 'error' },
|
|
94
|
+
helpUri: 'https://github.com/timidan/rite',
|
|
95
|
+
properties: {
|
|
96
|
+
tags: ['security', 'authorization', 'access-control'],
|
|
97
|
+
precision: 'high',
|
|
98
|
+
'security-severity': '8.0', // CVSS-style; IDOR is typically 7-9
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const results = (report.results ?? []).map(r => buildResult(r, ruleId));
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
$schema: SARIF_SCHEMA,
|
|
107
|
+
version: SARIF_VERSION,
|
|
108
|
+
runs: [
|
|
109
|
+
{
|
|
110
|
+
tool: {
|
|
111
|
+
driver: {
|
|
112
|
+
name: 'Rite',
|
|
113
|
+
version: ENGINE_VERSION,
|
|
114
|
+
informationUri: 'https://github.com/timidan/rite',
|
|
115
|
+
rules,
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
results,
|
|
119
|
+
automationDetails: {
|
|
120
|
+
id: `rite/${report.ruleId ?? 'check'}/${report.generatedAt}`,
|
|
121
|
+
},
|
|
122
|
+
columnKind: 'utf16CodeUnits',
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/github/app.js — GitHub App OAuth and repository helpers.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* GET /github/login — Start OAuth flow
|
|
6
|
+
* GET /github/callback — OAuth callback, set session
|
|
7
|
+
* GET /github/installations — List repos the user authorised
|
|
8
|
+
* GET /github/runs — List recent Rite workflow runs for a repo
|
|
9
|
+
* GET /github/report — Fetch and validate a run's Rite report artifact
|
|
10
|
+
* POST /github/logout — Clear session
|
|
11
|
+
*
|
|
12
|
+
* Security model:
|
|
13
|
+
* - User access token (user-to-server) for identity and session.
|
|
14
|
+
* - Short-lived installation token (server-to-server) for artifact reads.
|
|
15
|
+
* - App private key and session secret stay server-side — never
|
|
16
|
+
* in browser assets, GitHub Actions logs, or Rite report JSON.
|
|
17
|
+
* - Repository owner/name is NEVER trusted from a browser query parameter;
|
|
18
|
+
* only repos in the user's authenticated installation list are served.
|
|
19
|
+
* - Report schema and embedded SHA are validated against GitHub run metadata
|
|
20
|
+
* before display. Mismatch → explicit error state, never PASS.
|
|
21
|
+
*
|
|
22
|
+
* Environment variables required:
|
|
23
|
+
* GITHUB_APP_ID — numeric App ID from GitHub App settings
|
|
24
|
+
* GITHUB_APP_CLIENT_ID — OAuth client ID
|
|
25
|
+
* GITHUB_APP_CLIENT_SECRET
|
|
26
|
+
* GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_FILE
|
|
27
|
+
* RITE_SESSION_SECRET — Random 32+ char string for session signing
|
|
28
|
+
* RITE_PUBLIC_URL — e.g. https://rite.timidan.xyz (for OAuth callback)
|
|
29
|
+
*
|
|
30
|
+
* Register the App and set the environment variables above before starting
|
|
31
|
+
* the server. See docs/github-app-setup.md.
|
|
32
|
+
*
|
|
33
|
+
* GitHub App docs: https://docs.github.com/en/apps
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check required environment variables.
|
|
38
|
+
* @returns {{ ok: boolean, missing: string[] }}
|
|
39
|
+
*/
|
|
40
|
+
export function checkEnv() {
|
|
41
|
+
const required = [
|
|
42
|
+
'GITHUB_APP_ID',
|
|
43
|
+
'GITHUB_APP_CLIENT_ID',
|
|
44
|
+
'GITHUB_APP_CLIENT_SECRET',
|
|
45
|
+
'RITE_SESSION_SECRET',
|
|
46
|
+
'RITE_PUBLIC_URL',
|
|
47
|
+
];
|
|
48
|
+
const missing = required.filter(k => !process.env[k]);
|
|
49
|
+
if (!process.env.GITHUB_APP_PRIVATE_KEY && !process.env.GITHUB_APP_PRIVATE_KEY_FILE) {
|
|
50
|
+
missing.push('GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_FILE');
|
|
51
|
+
}
|
|
52
|
+
return { ok: missing.length === 0, missing };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Build the GitHub App OAuth authorization URL.
|
|
57
|
+
* @param {string} state — CSRF token, stored in session
|
|
58
|
+
* @returns {string}
|
|
59
|
+
*/
|
|
60
|
+
export function buildAuthUrl(state) {
|
|
61
|
+
const clientId = process.env.GITHUB_APP_CLIENT_ID ?? '';
|
|
62
|
+
const redirectUri = encodeURIComponent(`${process.env.RITE_PUBLIC_URL}/github/callback`);
|
|
63
|
+
return `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&state=${state}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Start installation through Rite so GitHub's OAuth-on-install callback carries
|
|
68
|
+
* the same CSRF state stored in the user's session.
|
|
69
|
+
*/
|
|
70
|
+
export function buildInstallUrl(state) {
|
|
71
|
+
return `https://github.com/apps/rite-authorization-check/installations/new?state=${encodeURIComponent(state)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Exchange a GitHub OAuth code for a user access token.
|
|
76
|
+
* @param {string} code
|
|
77
|
+
* @returns {Promise<{ token: string, tokenType: string }>}
|
|
78
|
+
*/
|
|
79
|
+
export async function exchangeCode(code) {
|
|
80
|
+
const resp = await fetch('https://github.com/login/oauth/access_token', {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
client_id: process.env.GITHUB_APP_CLIENT_ID,
|
|
85
|
+
client_secret: process.env.GITHUB_APP_CLIENT_SECRET,
|
|
86
|
+
code,
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
if (!resp.ok) throw new Error(`GitHub token exchange failed: ${resp.status}`);
|
|
90
|
+
const data = await resp.json();
|
|
91
|
+
if (data.error) throw new Error(`GitHub OAuth error: ${data.error_description ?? data.error}`);
|
|
92
|
+
return { token: data.access_token, tokenType: data.token_type };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* List repositories accessible to the authenticated user via their installations.
|
|
97
|
+
* Uses the user access token — only repos the user authorised appear here.
|
|
98
|
+
* @param {string} userToken — user access token
|
|
99
|
+
* @returns {Promise<Array<{ id: number, fullName: string, installationId: number }>>}
|
|
100
|
+
*/
|
|
101
|
+
export async function listAuthorizedRepos(userToken) {
|
|
102
|
+
// 1. Get installations this user has authorised
|
|
103
|
+
const instResp = await fetch('https://api.github.com/user/installations', {
|
|
104
|
+
headers: {
|
|
105
|
+
Authorization: `Bearer ${userToken}`,
|
|
106
|
+
Accept: 'application/vnd.github+json',
|
|
107
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
if (!instResp.ok) throw new Error(`GitHub installations list failed: ${instResp.status}`);
|
|
111
|
+
const instData = await instResp.json();
|
|
112
|
+
const installations = instData.installations ?? [];
|
|
113
|
+
|
|
114
|
+
// 2. For each installation, list repos — filter to Rite-relevant ones
|
|
115
|
+
const allRepos = [];
|
|
116
|
+
for (const inst of installations) {
|
|
117
|
+
const repoResp = await fetch(
|
|
118
|
+
`https://api.github.com/user/installations/${inst.id}/repositories`,
|
|
119
|
+
{
|
|
120
|
+
headers: {
|
|
121
|
+
Authorization: `Bearer ${userToken}`,
|
|
122
|
+
Accept: 'application/vnd.github+json',
|
|
123
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
if (!repoResp.ok) continue;
|
|
128
|
+
const repoData = await repoResp.json();
|
|
129
|
+
for (const repo of repoData.repositories ?? []) {
|
|
130
|
+
allRepos.push({ id: repo.id, fullName: repo.full_name, installationId: inst.id });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return allRepos;
|
|
134
|
+
}
|